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