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