1{ config, lib, pkgs, ... }:
2
3with lib;
4
5let
6 cfg = config.services.home-assistant;
7 format = pkgs.formats.yaml {};
8
9 # Render config attribute sets to YAML
10 # Values that are null will be filtered from the output, so this is one way to have optional
11 # options shown in settings.
12 # We post-process the result to add support for YAML functions, like secrets or includes, see e.g.
13 # https://www.home-assistant.io/docs/configuration/secrets/
14 filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [ null ])) cfg.config or {};
15 configFile = pkgs.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;
22
23 # Components advertised by the home-assistant package
24 availableComponents = cfg.package.availableComponents;
25
26 # Components that were added by overriding the package
27 explicitComponents = cfg.package.extraComponents;
28 useExplicitComponent = component: elem component explicitComponents;
29
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 [ ];
48
49 useComponentPlatform = component: elem component (usedPlatforms cfg.config);
50
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;
58
59 # Final list of components passed into the package to include required dependencies
60 extraComponents = filter useComponent availableComponents;
61
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 }));
70
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 };
76
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 };
84in {
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 ];
91
92 meta = {
93 buildDocsInSandbox = false;
94 maintainers = teams.home-assistant.members;
95 };
96
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");
101
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 };
107
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.
132
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 };
136
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.
151
152 A popular example is `python3Packages.psycopg2`
153 for PostgreSQL support in the recorder component.
154 '';
155 };
156
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.
167
168 Available components can be found below `pkgs.home-assistant-custom-components`.
169 '';
170 };
171
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.
183
184 Available cards can be found below `pkgs.home-assistant-custom-lovelace-modules`.
185
186 ::: {.note}
187 Automatic loading only works with lovelace in `yaml` mode.
188 :::
189 '';
190 };
191
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.
199
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 };
210
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 };
219
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 };
228
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 };
237
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 };
246
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 };
259
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 };
273
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 };
282
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.
322
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.
325
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 };
331
332 configWritable = mkOption {
333 default = false;
334 type = types.bool;
335 description = lib.mdDoc ''
336 Whether to make {file}`configuration.yaml` writable.
337
338 This will allow you to edit it from Home Assistant's web interface.
339
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 };
344
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`.
365
366 Beware that setting this option will delete your previous {file}`ui-lovelace.yaml`
367 '';
368 };
369
370 lovelaceConfigWritable = mkOption {
371 default = false;
372 type = types.bool;
373 description = lib.mdDoc ''
374 Whether to make {file}`ui-lovelace.yaml` writable.
375
376 This will allow you to edit it from Home Assistant's web interface.
377
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 };
382
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 };
409
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 };
416
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 ];
424
425 networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.config.http.server_port ];
426
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 })
432
433 (lib.mkIf (cfg.lovelaceConfig != null && !cfg.lovelaceConfigWritable) {
434 "home-assistant/ui-lovelace.yaml".source = lovelaceConfigFile;
435 })
436 ];
437
438 systemd.services.home-assistant = {
439 description = "Home Assistant";
440 after = [
441 "network-online.target"
442
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;
449
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"
471
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
479
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";
618
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 };
678
679 systemd.targets.home-assistant = rec {
680 description = "Home Assistant";
681 wantedBy = [ "multi-user.target" ];
682 wants = [ "home-assistant.service" ];
683 after = wants;
684 };
685
686 users.users.hass = {
687 home = cfg.configDir;
688 createHome = true;
689 group = "hass";
690 uid = config.ids.uids.hass;
691 };
692
693 users.groups.hass.gid = config.ids.gids.hass;
694 };
695}