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