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