grafana-alloy: don't build the frontend twice
[NixPkgs.git] / nixos / modules / services / home-automation / home-assistant.nix
blobc7b28b91051575cc4329500e75e42f78783bd535
1 { config, lib, pkgs, ... }:
3 with lib;
5 let
6   cfg = config.services.home-assistant;
7   format = pkgs.formats.yaml {};
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;
21   # Components advertised by the home-assistant package
22   availableComponents = cfg.package.availableComponents;
24   # Components that were added by overriding the package
25   explicitComponents = cfg.package.extraComponents;
26   useExplicitComponent = component: elem component explicitComponents;
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 [ ];
47   useComponentPlatform = component: elem component (usedPlatforms cfg.config);
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);
57   # Final list of components passed into the package to include required dependencies
58   extraComponents = filter useComponent availableComponents;
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   }));
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   };
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   };
82 in {
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   ];
90   meta = {
91     buildDocsInSandbox = false;
92     maintainers = teams.home-assistant.members;
93   };
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";
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     };
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"
117         # key features
118         "automation"
119         "person"
120         "scene"
121         "script"
122         "tag"
123         "zone"
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"
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     };
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.
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     };
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.
189         A popular example is `python3Packages.psycopg2`
190         for PostgreSQL support in the recorder component.
191       '';
192     };
194     customComponents = mkOption {
195       type = types.listOf (
196         types.addCheck types.package (p: p.isHomeAssistantComponent or false) // {
197           name = "home-assistant-component";
198           description = "package that is a Home Assistant component";
199         }
200       );
201       default = [];
202       example = literalExpression ''
203         with pkgs.home-assistant-custom-components; [
204           prometheus_sensor
205         ];
206       '';
207       description = ''
208         List of custom component packages to install.
210         Available components can be found below `pkgs.home-assistant-custom-components`.
211       '';
212     };
214     customLovelaceModules = mkOption {
215       type = types.listOf types.package;
216       default = [];
217       example = literalExpression ''
218         with pkgs.home-assistant-custom-lovelace-modules; [
219           mini-graph-card
220           mini-media-player
221         ];
222       '';
223       description = ''
224         List of custom lovelace card packages to load as lovelace resources.
226         Available cards can be found below `pkgs.home-assistant-custom-lovelace-modules`.
228         ::: {.note}
229         Automatic loading only works with lovelace in `yaml` mode.
230         :::
231       '';
232     };
234     config = mkOption {
235       type = types.nullOr (types.submodule {
236         freeformType = format.type;
237         options = {
238           # This is a partial selection of the most common options, so new users can quickly
239           # pick up how to match home-assistants config structure to ours. It also lets us preset
240           # config values intelligently.
242           homeassistant = {
243             # https://www.home-assistant.io/docs/configuration/basic/
244             name = mkOption {
245               type = types.nullOr types.str;
246               default = null;
247               example = "Home";
248               description = ''
249                 Name of the location where Home Assistant is running.
250               '';
251             };
253             latitude = mkOption {
254               type = types.nullOr (types.either types.float types.str);
255               default = null;
256               example = 52.3;
257               description = ''
258                 Latitude of your location required to calculate the time the sun rises and sets.
259               '';
260             };
262             longitude = mkOption {
263               type = types.nullOr (types.either types.float types.str);
264               default = null;
265               example = 4.9;
266               description = ''
267                 Longitude of your location required to calculate the time the sun rises and sets.
268               '';
269             };
271             unit_system = mkOption {
272               type = types.nullOr (types.enum [ "metric" "imperial" ]);
273               default = null;
274               example = "metric";
275               description = ''
276                 The unit system to use. This also sets temperature_unit, Celsius for Metric and Fahrenheit for Imperial.
277               '';
278             };
280             temperature_unit = mkOption {
281               type = types.nullOr (types.enum [ "C" "F" ]);
282               default = null;
283               example = "C";
284               description = ''
285                 Override temperature unit set by unit_system. `C` for Celsius, `F` for Fahrenheit.
286               '';
287             };
289             time_zone = mkOption {
290               type = types.nullOr types.str;
291               default = config.time.timeZone or null;
292               defaultText = literalExpression ''
293                 config.time.timeZone or null
294               '';
295               example = "Europe/Amsterdam";
296               description = ''
297                 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).
298               '';
299             };
300           };
302           http = {
303             # https://www.home-assistant.io/integrations/http/
304             server_host = mkOption {
305               type = types.either types.str (types.listOf types.str);
306               default = [
307                 "0.0.0.0"
308                 "::"
309               ];
310               example = "::1";
311               description = ''
312                 Only listen to incoming requests on specific IP/host. The default listed assumes support for IPv4 and IPv6.
313               '';
314             };
316             server_port = mkOption {
317               default = 8123;
318               type = types.port;
319               description = ''
320                 The port on which to listen.
321               '';
322             };
323           };
325           lovelace = {
326             # https://www.home-assistant.io/lovelace/dashboards/
327             mode = mkOption {
328               type = types.enum [ "yaml" "storage" ];
329               default = if cfg.lovelaceConfig != null
330                 then "yaml"
331                 else "storage";
332               defaultText = literalExpression ''
333                 if cfg.lovelaceConfig != null
334                   then "yaml"
335                 else "storage";
336               '';
337               example = "yaml";
338               description = ''
339                 In what mode should the main Lovelace panel be, `yaml` or `storage` (UI managed).
340               '';
341             };
342           };
343         };
344       });
345       example = literalExpression ''
346         {
347           homeassistant = {
348             name = "Home";
349             latitude = "!secret latitude";
350             longitude = "!secret longitude";
351             elevation = "!secret elevation";
352             unit_system = "metric";
353             time_zone = "UTC";
354           };
355           frontend = {
356             themes = "!include_dir_merge_named themes";
357           };
358           http = {};
359           feedreader.urls = [ "https://nixos.org/blogs.xml" ];
360         }
361       '';
362       description = ''
363         Your {file}`configuration.yaml` as a Nix attribute set.
365         YAML functions like [secrets](https://www.home-assistant.io/docs/configuration/secrets/)
366         can be passed as a string and will be unquoted automatically.
368         Unless this option is explicitly set to `null`
369         we assume your {file}`configuration.yaml` is
370         managed through this module and thereby overwritten on startup.
371       '';
372     };
374     configWritable = mkOption {
375       default = false;
376       type = types.bool;
377       description = ''
378         Whether to make {file}`configuration.yaml` writable.
380         This will allow you to edit it from Home Assistant's web interface.
382         This only has an effect if {option}`config` is set.
383         However, bear in mind that it will be overwritten at every start of the service.
384       '';
385     };
387     lovelaceConfig = mkOption {
388       default = null;
389       type = types.nullOr format.type;
390       # from https://www.home-assistant.io/lovelace/dashboards/
391       example = literalExpression ''
392         {
393           title = "My Awesome Home";
394           views = [ {
395             title = "Example";
396             cards = [ {
397               type = "markdown";
398               title = "Lovelace";
399               content = "Welcome to your **Lovelace UI**.";
400             } ];
401           } ];
402         }
403       '';
404       description = ''
405         Your {file}`ui-lovelace.yaml` as a Nix attribute set.
406         Setting this option will automatically set `lovelace.mode` to `yaml`.
408         Beware that setting this option will delete your previous {file}`ui-lovelace.yaml`
409       '';
410     };
412     lovelaceConfigWritable = mkOption {
413       default = false;
414       type = types.bool;
415       description = ''
416         Whether to make {file}`ui-lovelace.yaml` writable.
418         This will allow you to edit it from Home Assistant's web interface.
420         This only has an effect if {option}`lovelaceConfig` is set.
421         However, bear in mind that it will be overwritten at every start of the service.
422       '';
423     };
425     package = mkOption {
426       default = pkgs.home-assistant.overrideAttrs (oldAttrs: {
427         doInstallCheck = false;
428       });
429       defaultText = literalExpression ''
430         pkgs.home-assistant.overrideAttrs (oldAttrs: {
431           doInstallCheck = false;
432         })
433       '';
434       type = types.package;
435       example = literalExpression ''
436         pkgs.home-assistant.override {
437           extraPackages = python3Packages: with python3Packages; [
438             psycopg2
439           ];
440           extraComponents = [
441             "default_config"
442             "esphome"
443             "met"
444           ];
445         }
446       '';
447       description = ''
448         The Home Assistant package to use.
449       '';
450     };
452     openFirewall = mkOption {
453       default = false;
454       type = types.bool;
455       description = "Whether to open the firewall for the specified port.";
456     };
457   };
459   config = mkIf cfg.enable {
460     assertions = [
461       {
462         assertion = cfg.openFirewall -> cfg.config != null;
463         message = "openFirewall can only be used with a declarative config";
464       }
465     ];
467     networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.config.http.server_port ];
469     # symlink the configuration to /etc/home-assistant
470     environment.etc = lib.mkMerge [
471       (lib.mkIf (cfg.config != null && !cfg.configWritable) {
472         "home-assistant/configuration.yaml".source = configFile;
473       })
475       (lib.mkIf (cfg.lovelaceConfig != null && !cfg.lovelaceConfigWritable) {
476         "home-assistant/ui-lovelace.yaml".source = lovelaceConfigFile;
477       })
478     ];
480     systemd.services.home-assistant = {
481       description = "Home Assistant";
482       wants = [ "network-online.target" ];
483       after = [
484         "network-online.target"
486         # prevent races with database creation
487         "mysql.service"
488         "postgresql.service"
489       ];
490       reloadTriggers = lib.optional (cfg.config != null) configFile
491       ++ lib.optional (cfg.lovelaceConfig != null) lovelaceConfigFile;
493       preStart = let
494         copyConfig = if cfg.configWritable then ''
495           cp --no-preserve=mode ${configFile} "${cfg.configDir}/configuration.yaml"
496         '' else ''
497           rm -f "${cfg.configDir}/configuration.yaml"
498           ln -s /etc/home-assistant/configuration.yaml "${cfg.configDir}/configuration.yaml"
499         '';
500         copyLovelaceConfig = if cfg.lovelaceConfigWritable then ''
501           rm -f "${cfg.configDir}/ui-lovelace.yaml"
502           cp --no-preserve=mode ${lovelaceConfigFile} "${cfg.configDir}/ui-lovelace.yaml"
503         '' else ''
504           ln -fs /etc/home-assistant/ui-lovelace.yaml "${cfg.configDir}/ui-lovelace.yaml"
505         '';
506         copyCustomLovelaceModules = if cfg.customLovelaceModules != [] then ''
507           mkdir -p "${cfg.configDir}/www"
508           ln -fns ${customLovelaceModulesDir} "${cfg.configDir}/www/nixos-lovelace-modules"
509         '' else ''
510           rm -f "${cfg.configDir}/www/nixos-lovelace-modules"
511         '';
512         copyCustomComponents = ''
513           mkdir -p "${cfg.configDir}/custom_components"
515           # remove components symlinked in from below the /nix/store
516           readarray -d "" components < <(find "${cfg.configDir}/custom_components" -maxdepth 1 -type l -print0)
517           for component in "''${components[@]}"; do
518             if [[ "$(readlink "$component")" =~ ^${escapeShellArg builtins.storeDir} ]]; then
519               rm "$component"
520             fi
521           done
523           # recreate symlinks for desired components
524           declare -a components=(${escapeShellArgs cfg.customComponents})
525           for component in "''${components[@]}"; do
526             readarray -t manifests < <(find "$component" -name manifest.json)
527             readarray -t paths < <(dirname "''${manifests[@]}")
528             ln -fns "''${paths[@]}" "${cfg.configDir}/custom_components/"
529           done
530         '';
531       in
532         (optionalString (cfg.config != null) copyConfig) +
533         (optionalString (cfg.lovelaceConfig != null) copyLovelaceConfig) +
534         copyCustomLovelaceModules +
535         copyCustomComponents
536       ;
537       environment.PYTHONPATH = package.pythonPath;
538       serviceConfig = let
539         # List of capabilities to equip home-assistant with, depending on configured components
540         capabilities = lib.unique ([
541           # Empty string first, so we will never accidentally have an empty capability bounding set
542           # https://github.com/NixOS/nixpkgs/issues/120617#issuecomment-830685115
543           ""
544         ] ++ lib.optionals (builtins.any useComponent componentsUsingBluetooth) [
545           # Required for interaction with hci devices and bluetooth sockets, identified by bluetooth-adapters dependency
546           # https://www.home-assistant.io/integrations/bluetooth_le_tracker/#rootless-setup-on-core-installs
547           "CAP_NET_ADMIN"
548           "CAP_NET_RAW"
549         ] ++ lib.optionals (useComponent "emulated_hue") [
550           # Alexa looks for the service on port 80
551           # https://www.home-assistant.io/integrations/emulated_hue
552           "CAP_NET_BIND_SERVICE"
553         ] ++ lib.optionals (useComponent "nmap_tracker") [
554           # https://www.home-assistant.io/integrations/nmap_tracker#linux-capabilities
555           "CAP_NET_ADMIN"
556           "CAP_NET_BIND_SERVICE"
557           "CAP_NET_RAW"
558         ]);
559         componentsUsingBluetooth = [
560           # Components that require the AF_BLUETOOTH address family
561           "august"
562           "august_ble"
563           "airthings_ble"
564           "aranet"
565           "bluemaestro"
566           "bluetooth"
567           "bluetooth_adapters"
568           "bluetooth_le_tracker"
569           "bluetooth_tracker"
570           "bthome"
571           "default_config"
572           "eufylife_ble"
573           "esphome"
574           "fjaraskupan"
575           "gardena_bluetooth"
576           "govee_ble"
577           "homekit_controller"
578           "inkbird"
579           "improv_ble"
580           "keymitt_ble"
581           "leaone-ble"
582           "led_ble"
583           "medcom_ble"
584           "melnor"
585           "moat"
586           "mopeka"
587           "oralb"
588           "private_ble_device"
589           "qingping"
590           "rapt_ble"
591           "ruuvi_gateway"
592           "ruuvitag_ble"
593           "sensirion_ble"
594           "sensorpro"
595           "sensorpush"
596           "shelly"
597           "snooz"
598           "switchbot"
599           "thermobeacon"
600           "thermopro"
601           "tilt_ble"
602           "xiaomi_ble"
603           "yalexs_ble"
604         ];
605         componentsUsingPing = [
606           # Components that require the capset syscall for the ping wrapper
607           "ping"
608           "wake_on_lan"
609         ];
610         componentsUsingSerialDevices = [
611           # Components that require access to serial devices (/dev/tty*)
612           # List generated from home-assistant documentation:
613           #   git clone https://github.com/home-assistant/home-assistant.io/
614           #   cd source/_integrations
615           #   rg "/dev/tty" -l | cut -d'/' -f3 | cut -d'.' -f1 | sort
616           # And then extended by references found in the source code, these
617           # mostly the ones using config flows already.
618           "acer_projector"
619           "alarmdecoder"
620           "blackbird"
621           "deconz"
622           "dsmr"
623           "edl21"
624           "elkm1"
625           "elv"
626           "enocean"
627           "firmata"
628           "flexit"
629           "gpsd"
630           "insteon"
631           "kwb"
632           "lacrosse"
633           "modbus"
634           "modem_callerid"
635           "mysensors"
636           "nad"
637           "numato"
638           "otbr"
639           "rflink"
640           "rfxtrx"
641           "scsgate"
642           "serial"
643           "serial_pm"
644           "sms"
645           "upb"
646           "usb"
647           "velbus"
648           "w800rf32"
649           "zha"
650           "zwave"
651           "zwave_js"
652         ];
653       in {
654         ExecStart = "${package}/bin/hass --config '${cfg.configDir}'";
655         ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
656         User = "hass";
657         Group = "hass";
658         WorkingDirectory = cfg.configDir;
659         Restart = "on-failure";
660         RestartForceExitStatus = "100";
661         SuccessExitStatus = "100";
662         KillSignal = "SIGINT";
664         # Hardening
665         AmbientCapabilities = capabilities;
666         CapabilityBoundingSet = capabilities;
667         DeviceAllow = (optionals (any useComponent componentsUsingSerialDevices) [
668           "char-ttyACM rw"
669           "char-ttyAMA rw"
670           "char-ttyUSB rw"
671         ]);
672         DevicePolicy = "closed";
673         LockPersonality = true;
674         MemoryDenyWriteExecute = true;
675         NoNewPrivileges = true;
676         PrivateTmp = true;
677         PrivateUsers = false; # prevents gaining capabilities in the host namespace
678         ProtectClock = true;
679         ProtectControlGroups = true;
680         ProtectHome = true;
681         ProtectHostname = true;
682         ProtectKernelLogs = true;
683         ProtectKernelModules = true;
684         ProtectKernelTunables = true;
685         ProtectProc = "invisible";
686         ProcSubset = "all";
687         ProtectSystem = "strict";
688         RemoveIPC = true;
689         ReadWritePaths = let
690           # Allow rw access to explicitly configured paths
691           cfgPath = [ "config" "homeassistant" "allowlist_external_dirs" ];
692           value = attrByPath cfgPath [] cfg;
693           allowPaths = if isList value then value else singleton value;
694         in [ "${cfg.configDir}" ] ++ allowPaths;
695         RestrictAddressFamilies = [
696           "AF_INET"
697           "AF_INET6"
698           "AF_NETLINK"
699           "AF_UNIX"
700         ] ++ optionals (any useComponent componentsUsingBluetooth) [
701           "AF_BLUETOOTH"
702         ];
703         RestrictNamespaces = true;
704         RestrictRealtime = true;
705         RestrictSUIDSGID = true;
706         SupplementaryGroups = optionals (any useComponent componentsUsingSerialDevices) [
707           "dialout"
708         ];
709         SystemCallArchitectures = "native";
710         SystemCallFilter = [
711           "@system-service"
712           "~@privileged"
713         ] ++ optionals (any useComponent componentsUsingPing) [
714           "capset"
715           "setuid"
716         ];
717         UMask = "0077";
718       };
719       path = [
720         pkgs.unixtools.ping # needed for ping
721       ];
722     };
724     systemd.targets.home-assistant = rec {
725       description = "Home Assistant";
726       wantedBy = [ "multi-user.target" ];
727       wants = [ "home-assistant.service" ];
728       after = wants;
729     };
731     users.users.hass = {
732       home = cfg.configDir;
733       createHome = true;
734       group = "hass";
735       uid = config.ids.uids.hass;
736     };
738     users.groups.hass.gid = config.ids.gids.hass;
739   };