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