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