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 Wikipedia’s [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}