at 24.11-pre 25 kB view raw
1{ config, lib, pkgs, ... }: 2 3with lib; 4 5let 6 cfg = config.services.home-assistant; 7 format = pkgs.formats.yaml {}; 8 9 # Render config attribute sets to YAML 10 # Values that are null will be filtered from the output, so this is one way to have optional 11 # options shown in settings. 12 # We post-process the result to add support for YAML functions, like secrets or includes, see e.g. 13 # https://www.home-assistant.io/docs/configuration/secrets/ 14 filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [ null ])) (lib.recursiveUpdate customLovelaceModulesResources (cfg.config or {})); 15 configFile = pkgs.runCommandLocal "configuration.yaml" { } '' 16 cp ${format.generate "configuration.yaml" filteredConfig} $out 17 sed -i -e "s/'\!\([a-z_]\+\) \(.*\)'/\!\1 \2/;s/^\!\!/\!/;" $out 18 ''; 19 lovelaceConfigFile = format.generate "ui-lovelace.yaml" cfg.lovelaceConfig; 20 21 # Components advertised by the home-assistant package 22 availableComponents = cfg.package.availableComponents; 23 24 # Components that were added by overriding the package 25 explicitComponents = cfg.package.extraComponents; 26 useExplicitComponent = component: elem component explicitComponents; 27 28 # Given a component "platform", looks up whether it is used in the config 29 # as `platform = "platform";`. 30 # 31 # For example, the component mqtt.sensor is used as follows: 32 # config.sensor = [ { 33 # platform = "mqtt"; 34 # ... 35 # } ]; 36 usedPlatforms = config: 37 # don't recurse into derivations possibly creating an infinite recursion 38 if isDerivation config then 39 [ ] 40 else if isAttrs config then 41 optional (config ? platform) config.platform 42 ++ concatMap usedPlatforms (attrValues config) 43 else if isList config then 44 concatMap usedPlatforms config 45 else [ ]; 46 47 useComponentPlatform = component: elem component (usedPlatforms cfg.config); 48 49 # Returns whether component is used in config, explicitly passed into package or 50 # configured in the module. 51 useComponent = component: 52 hasAttrByPath (splitString "." component) cfg.config 53 || useComponentPlatform component 54 || useExplicitComponent component 55 || builtins.elem component (cfg.extraComponents ++ cfg.defaultIntegrations); 56 57 # Final list of components passed into the package to include required dependencies 58 extraComponents = filter useComponent availableComponents; 59 60 package = (cfg.package.override (oldArgs: { 61 # Respect overrides that already exist in the passed package and 62 # concat it with values passed via the module. 63 extraComponents = oldArgs.extraComponents or [] ++ extraComponents; 64 extraPackages = ps: (oldArgs.extraPackages or (_: []) ps) 65 ++ (cfg.extraPackages ps) 66 ++ (lib.concatMap (component: component.propagatedBuildInputs or []) cfg.customComponents); 67 })); 68 69 # Create a directory that holds all lovelace modules 70 customLovelaceModulesDir = pkgs.buildEnv { 71 name = "home-assistant-custom-lovelace-modules"; 72 paths = cfg.customLovelaceModules; 73 }; 74 75 # Create parts of the lovelace config that reference lovelave modules as resources 76 customLovelaceModulesResources = { 77 lovelace.resources = map (card: { 78 url = "/local/nixos-lovelace-modules/${card.entrypoint or (card.pname + ".js")}?${card.version}"; 79 type = "module"; 80 }) cfg.customLovelaceModules; 81 }; 82in { 83 imports = [ 84 # Migrations in NixOS 22.05 85 (mkRemovedOptionModule [ "services" "home-assistant" "applyDefaultConfig" ] "The default config was migrated into services.home-assistant.config") 86 (mkRemovedOptionModule [ "services" "home-assistant" "autoExtraComponents" ] "Components are now parsed from services.home-assistant.config unconditionally") 87 (mkRenamedOptionModule [ "services" "home-assistant" "port" ] [ "services" "home-assistant" "config" "http" "server_port" ]) 88 ]; 89 90 meta = { 91 buildDocsInSandbox = false; 92 maintainers = teams.home-assistant.members; 93 }; 94 95 options.services.home-assistant = { 96 # Running home-assistant on NixOS is considered an installation method that is unsupported by the upstream project. 97 # https://github.com/home-assistant/architecture/blob/master/adr/0012-define-supported-installation-method.md#decision 98 enable = mkEnableOption "Home Assistant. Please note that this installation method is unsupported upstream"; 99 100 configDir = mkOption { 101 default = "/var/lib/hass"; 102 type = types.path; 103 description = "The config directory, where your {file}`configuration.yaml` is located."; 104 }; 105 106 defaultIntegrations = mkOption { 107 type = types.listOf (types.enum availableComponents); 108 # https://github.com/home-assistant/core/blob/dev/homeassistant/bootstrap.py#L109 109 default = [ 110 "application_credentials" 111 "frontend" 112 "hardware" 113 "logger" 114 "network" 115 "system_health" 116 117 # key features 118 "automation" 119 "person" 120 "scene" 121 "script" 122 "tag" 123 "zone" 124 125 # built-in helpers 126 "counter" 127 "input_boolean" 128 "input_button" 129 "input_datetime" 130 "input_number" 131 "input_select" 132 "input_text" 133 "schedule" 134 "timer" 135 136 # non-supervisor 137 "backup" 138 ]; 139 readOnly = true; 140 description = '' 141 List of integrations set are always set up, unless in recovery mode. 142 ''; 143 }; 144 145 extraComponents = mkOption { 146 type = types.listOf (types.enum availableComponents); 147 default = [ 148 # List of components required to complete the onboarding 149 "default_config" 150 "met" 151 "esphome" 152 ] ++ optionals pkgs.stdenv.hostPlatform.isAarch [ 153 # Use the platform as an indicator that we might be running on a RaspberryPi and include 154 # relevant components 155 "rpi_power" 156 ]; 157 example = literalExpression '' 158 [ 159 "analytics" 160 "default_config" 161 "esphome" 162 "my" 163 "shopping_list" 164 "wled" 165 ] 166 ''; 167 description = '' 168 List of [components](https://www.home-assistant.io/integrations/) that have their dependencies included in the package. 169 170 The component name can be found in the URL, for example `https://www.home-assistant.io/integrations/ffmpeg/` would map to `ffmpeg`. 171 ''; 172 }; 173 174 extraPackages = mkOption { 175 type = types.functionTo (types.listOf types.package); 176 default = _: []; 177 defaultText = literalExpression '' 178 python3Packages: with python3Packages; []; 179 ''; 180 example = literalExpression '' 181 python3Packages: with python3Packages; [ 182 # postgresql support 183 psycopg2 184 ]; 185 ''; 186 description = '' 187 List of packages to add to propagatedBuildInputs. 188 189 A popular example is `python3Packages.psycopg2` 190 for PostgreSQL support in the recorder component. 191 ''; 192 }; 193 194 customComponents = mkOption { 195 type = types.listOf types.package; 196 default = []; 197 example = literalExpression '' 198 with pkgs.home-assistant-custom-components; [ 199 prometheus_sensor 200 ]; 201 ''; 202 description = '' 203 List of custom component packages to install. 204 205 Available components can be found below `pkgs.home-assistant-custom-components`. 206 ''; 207 }; 208 209 customLovelaceModules = mkOption { 210 type = types.listOf types.package; 211 default = []; 212 example = literalExpression '' 213 with pkgs.home-assistant-custom-lovelace-modules; [ 214 mini-graph-card 215 mini-media-player 216 ]; 217 ''; 218 description = '' 219 List of custom lovelace card packages to load as lovelace resources. 220 221 Available cards can be found below `pkgs.home-assistant-custom-lovelace-modules`. 222 223 ::: {.note} 224 Automatic loading only works with lovelace in `yaml` mode. 225 ::: 226 ''; 227 }; 228 229 config = mkOption { 230 type = types.nullOr (types.submodule { 231 freeformType = format.type; 232 options = { 233 # This is a partial selection of the most common options, so new users can quickly 234 # pick up how to match home-assistants config structure to ours. It also lets us preset 235 # config values intelligently. 236 237 homeassistant = { 238 # https://www.home-assistant.io/docs/configuration/basic/ 239 name = mkOption { 240 type = types.nullOr types.str; 241 default = null; 242 example = "Home"; 243 description = '' 244 Name of the location where Home Assistant is running. 245 ''; 246 }; 247 248 latitude = mkOption { 249 type = types.nullOr (types.either types.float types.str); 250 default = null; 251 example = 52.3; 252 description = '' 253 Latitude of your location required to calculate the time the sun rises and sets. 254 ''; 255 }; 256 257 longitude = mkOption { 258 type = types.nullOr (types.either types.float types.str); 259 default = null; 260 example = 4.9; 261 description = '' 262 Longitude of your location required to calculate the time the sun rises and sets. 263 ''; 264 }; 265 266 unit_system = mkOption { 267 type = types.nullOr (types.enum [ "metric" "imperial" ]); 268 default = null; 269 example = "metric"; 270 description = '' 271 The unit system to use. This also sets temperature_unit, Celsius for Metric and Fahrenheit for Imperial. 272 ''; 273 }; 274 275 temperature_unit = mkOption { 276 type = types.nullOr (types.enum [ "C" "F" ]); 277 default = null; 278 example = "C"; 279 description = '' 280 Override temperature unit set by unit_system. `C` for Celsius, `F` for Fahrenheit. 281 ''; 282 }; 283 284 time_zone = mkOption { 285 type = types.nullOr types.str; 286 default = config.time.timeZone or null; 287 defaultText = literalExpression '' 288 config.time.timeZone or null 289 ''; 290 example = "Europe/Amsterdam"; 291 description = '' 292 Pick your time zone from the column TZ of Wikipedias [list of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). 293 ''; 294 }; 295 }; 296 297 http = { 298 # https://www.home-assistant.io/integrations/http/ 299 server_host = mkOption { 300 type = types.either types.str (types.listOf types.str); 301 default = [ 302 "0.0.0.0" 303 "::" 304 ]; 305 example = "::1"; 306 description = '' 307 Only listen to incoming requests on specific IP/host. The default listed assumes support for IPv4 and IPv6. 308 ''; 309 }; 310 311 server_port = mkOption { 312 default = 8123; 313 type = types.port; 314 description = '' 315 The port on which to listen. 316 ''; 317 }; 318 }; 319 320 lovelace = { 321 # https://www.home-assistant.io/lovelace/dashboards/ 322 mode = mkOption { 323 type = types.enum [ "yaml" "storage" ]; 324 default = if cfg.lovelaceConfig != null 325 then "yaml" 326 else "storage"; 327 defaultText = literalExpression '' 328 if cfg.lovelaceConfig != null 329 then "yaml" 330 else "storage"; 331 ''; 332 example = "yaml"; 333 description = '' 334 In what mode should the main Lovelace panel be, `yaml` or `storage` (UI managed). 335 ''; 336 }; 337 }; 338 }; 339 }); 340 example = literalExpression '' 341 { 342 homeassistant = { 343 name = "Home"; 344 latitude = "!secret latitude"; 345 longitude = "!secret longitude"; 346 elevation = "!secret elevation"; 347 unit_system = "metric"; 348 time_zone = "UTC"; 349 }; 350 frontend = { 351 themes = "!include_dir_merge_named themes"; 352 }; 353 http = {}; 354 feedreader.urls = [ "https://nixos.org/blogs.xml" ]; 355 } 356 ''; 357 description = '' 358 Your {file}`configuration.yaml` as a Nix attribute set. 359 360 YAML functions like [secrets](https://www.home-assistant.io/docs/configuration/secrets/) 361 can be passed as a string and will be unquoted automatically. 362 363 Unless this option is explicitly set to `null` 364 we assume your {file}`configuration.yaml` is 365 managed through this module and thereby overwritten on startup. 366 ''; 367 }; 368 369 configWritable = mkOption { 370 default = false; 371 type = types.bool; 372 description = '' 373 Whether to make {file}`configuration.yaml` writable. 374 375 This will allow you to edit it from Home Assistant's web interface. 376 377 This only has an effect if {option}`config` is set. 378 However, bear in mind that it will be overwritten at every start of the service. 379 ''; 380 }; 381 382 lovelaceConfig = mkOption { 383 default = null; 384 type = types.nullOr format.type; 385 # from https://www.home-assistant.io/lovelace/dashboards/ 386 example = literalExpression '' 387 { 388 title = "My Awesome Home"; 389 views = [ { 390 title = "Example"; 391 cards = [ { 392 type = "markdown"; 393 title = "Lovelace"; 394 content = "Welcome to your **Lovelace UI**."; 395 } ]; 396 } ]; 397 } 398 ''; 399 description = '' 400 Your {file}`ui-lovelace.yaml` as a Nix attribute set. 401 Setting this option will automatically set `lovelace.mode` to `yaml`. 402 403 Beware that setting this option will delete your previous {file}`ui-lovelace.yaml` 404 ''; 405 }; 406 407 lovelaceConfigWritable = mkOption { 408 default = false; 409 type = types.bool; 410 description = '' 411 Whether to make {file}`ui-lovelace.yaml` writable. 412 413 This will allow you to edit it from Home Assistant's web interface. 414 415 This only has an effect if {option}`lovelaceConfig` is set. 416 However, bear in mind that it will be overwritten at every start of the service. 417 ''; 418 }; 419 420 package = mkOption { 421 default = pkgs.home-assistant.overrideAttrs (oldAttrs: { 422 doInstallCheck = false; 423 }); 424 defaultText = literalExpression '' 425 pkgs.home-assistant.overrideAttrs (oldAttrs: { 426 doInstallCheck = false; 427 }) 428 ''; 429 type = types.package; 430 example = literalExpression '' 431 pkgs.home-assistant.override { 432 extraPackages = python3Packages: with python3Packages; [ 433 psycopg2 434 ]; 435 extraComponents = [ 436 "default_config" 437 "esphome" 438 "met" 439 ]; 440 } 441 ''; 442 description = '' 443 The Home Assistant package to use. 444 ''; 445 }; 446 447 openFirewall = mkOption { 448 default = false; 449 type = types.bool; 450 description = "Whether to open the firewall for the specified port."; 451 }; 452 }; 453 454 config = mkIf cfg.enable { 455 assertions = [ 456 { 457 assertion = cfg.openFirewall -> cfg.config != null; 458 message = "openFirewall can only be used with a declarative config"; 459 } 460 ]; 461 462 networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.config.http.server_port ]; 463 464 # symlink the configuration to /etc/home-assistant 465 environment.etc = lib.mkMerge [ 466 (lib.mkIf (cfg.config != null && !cfg.configWritable) { 467 "home-assistant/configuration.yaml".source = configFile; 468 }) 469 470 (lib.mkIf (cfg.lovelaceConfig != null && !cfg.lovelaceConfigWritable) { 471 "home-assistant/ui-lovelace.yaml".source = lovelaceConfigFile; 472 }) 473 ]; 474 475 systemd.services.home-assistant = { 476 description = "Home Assistant"; 477 wants = [ "network-online.target" ]; 478 after = [ 479 "network-online.target" 480 481 # prevent races with database creation 482 "mysql.service" 483 "postgresql.service" 484 ]; 485 reloadTriggers = lib.optional (cfg.config != null) configFile 486 ++ lib.optional (cfg.lovelaceConfig != null) lovelaceConfigFile; 487 488 preStart = let 489 copyConfig = if cfg.configWritable then '' 490 cp --no-preserve=mode ${configFile} "${cfg.configDir}/configuration.yaml" 491 '' else '' 492 rm -f "${cfg.configDir}/configuration.yaml" 493 ln -s /etc/home-assistant/configuration.yaml "${cfg.configDir}/configuration.yaml" 494 ''; 495 copyLovelaceConfig = if cfg.lovelaceConfigWritable then '' 496 rm -f "${cfg.configDir}/ui-lovelace.yaml" 497 cp --no-preserve=mode ${lovelaceConfigFile} "${cfg.configDir}/ui-lovelace.yaml" 498 '' else '' 499 ln -fs /etc/home-assistant/ui-lovelace.yaml "${cfg.configDir}/ui-lovelace.yaml" 500 ''; 501 copyCustomLovelaceModules = if cfg.customLovelaceModules != [] then '' 502 mkdir -p "${cfg.configDir}/www" 503 ln -fns ${customLovelaceModulesDir} "${cfg.configDir}/www/nixos-lovelace-modules" 504 '' else '' 505 rm -f "${cfg.configDir}/www/nixos-lovelace-modules" 506 ''; 507 copyCustomComponents = '' 508 mkdir -p "${cfg.configDir}/custom_components" 509 510 # remove components symlinked in from below the /nix/store 511 readarray -d "" components < <(find "${cfg.configDir}/custom_components" -maxdepth 1 -type l -print0) 512 for component in "''${components[@]}"; do 513 if [[ "$(readlink "$component")" =~ ^${escapeShellArg builtins.storeDir} ]]; then 514 rm "$component" 515 fi 516 done 517 518 # recreate symlinks for desired components 519 declare -a components=(${escapeShellArgs cfg.customComponents}) 520 for component in "''${components[@]}"; do 521 path="$(dirname $(find "$component" -name "manifest.json"))" 522 ln -fns "$path" "${cfg.configDir}/custom_components/" 523 done 524 ''; 525 in 526 (optionalString (cfg.config != null) copyConfig) + 527 (optionalString (cfg.lovelaceConfig != null) copyLovelaceConfig) + 528 copyCustomLovelaceModules + 529 copyCustomComponents 530 ; 531 environment.PYTHONPATH = package.pythonPath; 532 serviceConfig = let 533 # List of capabilities to equip home-assistant with, depending on configured components 534 capabilities = lib.unique ([ 535 # Empty string first, so we will never accidentally have an empty capability bounding set 536 # https://github.com/NixOS/nixpkgs/issues/120617#issuecomment-830685115 537 "" 538 ] ++ lib.optionals (builtins.any useComponent componentsUsingBluetooth) [ 539 # Required for interaction with hci devices and bluetooth sockets, identified by bluetooth-adapters dependency 540 # https://www.home-assistant.io/integrations/bluetooth_le_tracker/#rootless-setup-on-core-installs 541 "CAP_NET_ADMIN" 542 "CAP_NET_RAW" 543 ] ++ lib.optionals (useComponent "emulated_hue") [ 544 # Alexa looks for the service on port 80 545 # https://www.home-assistant.io/integrations/emulated_hue 546 "CAP_NET_BIND_SERVICE" 547 ] ++ lib.optionals (useComponent "nmap_tracker") [ 548 # https://www.home-assistant.io/integrations/nmap_tracker#linux-capabilities 549 "CAP_NET_ADMIN" 550 "CAP_NET_BIND_SERVICE" 551 "CAP_NET_RAW" 552 ]); 553 componentsUsingBluetooth = [ 554 # Components that require the AF_BLUETOOTH address family 555 "august" 556 "august_ble" 557 "airthings_ble" 558 "aranet" 559 "bluemaestro" 560 "bluetooth" 561 "bluetooth_adapters" 562 "bluetooth_le_tracker" 563 "bluetooth_tracker" 564 "bthome" 565 "default_config" 566 "eufylife_ble" 567 "esphome" 568 "fjaraskupan" 569 "gardena_bluetooth" 570 "govee_ble" 571 "homekit_controller" 572 "inkbird" 573 "improv_ble" 574 "keymitt_ble" 575 "leaone-ble" 576 "led_ble" 577 "medcom_ble" 578 "melnor" 579 "moat" 580 "mopeka" 581 "oralb" 582 "private_ble_device" 583 "qingping" 584 "rapt_ble" 585 "ruuvi_gateway" 586 "ruuvitag_ble" 587 "sensirion_ble" 588 "sensorpro" 589 "sensorpush" 590 "shelly" 591 "snooz" 592 "switchbot" 593 "thermobeacon" 594 "thermopro" 595 "tilt_ble" 596 "xiaomi_ble" 597 "yalexs_ble" 598 ]; 599 componentsUsingPing = [ 600 # Components that require the capset syscall for the ping wrapper 601 "ping" 602 "wake_on_lan" 603 ]; 604 componentsUsingSerialDevices = [ 605 # Components that require access to serial devices (/dev/tty*) 606 # List generated from home-assistant documentation: 607 # git clone https://github.com/home-assistant/home-assistant.io/ 608 # cd source/_integrations 609 # rg "/dev/tty" -l | cut -d'/' -f3 | cut -d'.' -f1 | sort 610 # And then extended by references found in the source code, these 611 # mostly the ones using config flows already. 612 "acer_projector" 613 "alarmdecoder" 614 "blackbird" 615 "deconz" 616 "dsmr" 617 "edl21" 618 "elkm1" 619 "elv" 620 "enocean" 621 "firmata" 622 "flexit" 623 "gpsd" 624 "insteon" 625 "kwb" 626 "lacrosse" 627 "modbus" 628 "modem_callerid" 629 "mysensors" 630 "nad" 631 "numato" 632 "otbr" 633 "rflink" 634 "rfxtrx" 635 "scsgate" 636 "serial" 637 "serial_pm" 638 "sms" 639 "upb" 640 "usb" 641 "velbus" 642 "w800rf32" 643 "zha" 644 "zwave" 645 "zwave_js" 646 ]; 647 in { 648 ExecStart = "${package}/bin/hass --config '${cfg.configDir}'"; 649 ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; 650 User = "hass"; 651 Group = "hass"; 652 Restart = "on-failure"; 653 RestartForceExitStatus = "100"; 654 SuccessExitStatus = "100"; 655 KillSignal = "SIGINT"; 656 657 # Hardening 658 AmbientCapabilities = capabilities; 659 CapabilityBoundingSet = capabilities; 660 DeviceAllow = (optionals (any useComponent componentsUsingSerialDevices) [ 661 "char-ttyACM rw" 662 "char-ttyAMA rw" 663 "char-ttyUSB rw" 664 ]); 665 DevicePolicy = "closed"; 666 LockPersonality = true; 667 MemoryDenyWriteExecute = true; 668 NoNewPrivileges = true; 669 PrivateTmp = true; 670 PrivateUsers = false; # prevents gaining capabilities in the host namespace 671 ProtectClock = true; 672 ProtectControlGroups = true; 673 ProtectHome = true; 674 ProtectHostname = true; 675 ProtectKernelLogs = true; 676 ProtectKernelModules = true; 677 ProtectKernelTunables = true; 678 ProtectProc = "invisible"; 679 ProcSubset = "all"; 680 ProtectSystem = "strict"; 681 RemoveIPC = true; 682 ReadWritePaths = let 683 # Allow rw access to explicitly configured paths 684 cfgPath = [ "config" "homeassistant" "allowlist_external_dirs" ]; 685 value = attrByPath cfgPath [] cfg; 686 allowPaths = if isList value then value else singleton value; 687 in [ "${cfg.configDir}" ] ++ allowPaths; 688 RestrictAddressFamilies = [ 689 "AF_INET" 690 "AF_INET6" 691 "AF_NETLINK" 692 "AF_UNIX" 693 ] ++ optionals (any useComponent componentsUsingBluetooth) [ 694 "AF_BLUETOOTH" 695 ]; 696 RestrictNamespaces = true; 697 RestrictRealtime = true; 698 RestrictSUIDSGID = true; 699 SupplementaryGroups = optionals (any useComponent componentsUsingSerialDevices) [ 700 "dialout" 701 ]; 702 SystemCallArchitectures = "native"; 703 SystemCallFilter = [ 704 "@system-service" 705 "~@privileged" 706 ] ++ optionals (any useComponent componentsUsingPing) [ 707 "capset" 708 "setuid" 709 ]; 710 UMask = "0077"; 711 }; 712 path = [ 713 pkgs.unixtools.ping # needed for ping 714 ]; 715 }; 716 717 systemd.targets.home-assistant = rec { 718 description = "Home Assistant"; 719 wantedBy = [ "multi-user.target" ]; 720 wants = [ "home-assistant.service" ]; 721 after = wants; 722 }; 723 724 users.users.hass = { 725 home = cfg.configDir; 726 createHome = true; 727 group = "hass"; 728 uid = config.ids.uids.hass; 729 }; 730 731 users.groups.hass.gid = config.ids.gids.hass; 732 }; 733}