1{
2 config,
3 lib,
4 pkgs,
5 utils,
6 ...
7}:
8
9let
10 inherit (lib)
11 any
12 attrByPath
13 attrValues
14 concatMap
15 concatStrings
16 converge
17 elem
18 escapeShellArg
19 escapeShellArgs
20 filter
21 filterAttrsRecursive
22 flatten
23 getAttr
24 hasAttrByPath
25 isAttrs
26 isDerivation
27 isList
28 isStorePath
29 literalExpression
30 mapAttrsToList
31 mergeAttrsList
32 mkEnableOption
33 mkIf
34 mkMerge
35 mkOption
36 mkRemovedOptionModule
37 mkRenamedOptionModule
38 optionals
39 optionalString
40 recursiveUpdate
41 singleton
42 splitString
43 substring
44 types
45 unique
46 ;
47
48 inherit (utils)
49 escapeSystemdExecArgs
50 ;
51
52 cfg = config.services.home-assistant;
53 format = pkgs.formats.yaml { };
54
55 # Post-process YAML output to add support for YAML functions, like
56 # secrets or includes, by naively unquoting strings with leading bangs
57 # and at least one space-separated parameter.
58 # https://www.home-assistant.io/docs/configuration/secrets/
59 renderYAMLFile =
60 fn: yaml:
61 pkgs.runCommand fn
62 {
63 preferLocalBuilds = true;
64 }
65 ''
66 cp ${format.generate fn yaml} $out
67 sed -i -e "s/'\!\([a-z_]\+\) \(.*\)'/\!\1 \2/;s/^\!\!/\!/;" $out
68 '';
69
70 # Filter null values from the configuration, so that we can still advertise
71 # optional options in the config attribute.
72 filteredConfig = converge (filterAttrsRecursive (_: v: !elem v [ null ])) (
73 recursiveUpdate customLovelaceModulesResources (cfg.config or { })
74 );
75 configFile = renderYAMLFile "configuration.yaml" filteredConfig;
76
77 lovelaceConfigFile =
78 if cfg.lovelaceConfig != null then
79 renderYAMLFile "ui-lovelace.yaml" cfg.lovelaceConfig
80 else
81 cfg.lovelaceConfigFile;
82
83 # Components advertised by the home-assistant package
84 availableComponents = cfg.package.availableComponents;
85
86 # Components that were added by overriding the package
87 explicitComponents = cfg.package.extraComponents;
88 useExplicitComponent = component: elem component explicitComponents;
89
90 # Given a component "platform", looks up whether it is used in the config
91 # as `platform = "platform";`.
92 #
93 # For example, the component mqtt.sensor is used as follows:
94 # config.sensor = [ {
95 # platform = "mqtt";
96 # ...
97 # } ];
98 usedPlatforms =
99 config:
100 # don't recurse into derivations possibly creating an infinite recursion
101 if isDerivation config then
102 [ ]
103 else if isAttrs config then
104 optionals (config ? platform) [ config.platform ] ++ concatMap usedPlatforms (attrValues config)
105 else if isList config then
106 concatMap usedPlatforms config
107 else
108 [ ];
109
110 useComponentPlatform = component: elem component (usedPlatforms cfg.config);
111
112 # Returns whether component is used in config, explicitly passed into package or
113 # configured in the module.
114 useComponent =
115 component:
116 hasAttrByPath (splitString "." component) cfg.config
117 || useComponentPlatform component
118 || useExplicitComponent component
119 || builtins.elem component (
120 cfg.extraComponents ++ cfg.defaultIntegrations ++ map (getAttr "domain") cfg.customComponents
121 );
122
123 # Final list of components passed into the package to include required dependencies
124 extraComponents = filter useComponent availableComponents;
125
126 package = (
127 cfg.package.override (oldArgs: {
128 # Respect overrides that already exist in the passed package and
129 # concat it with values passed via the module.
130 extraComponents = oldArgs.extraComponents or [ ] ++ extraComponents;
131 extraPackages =
132 ps:
133 (oldArgs.extraPackages or (_: [ ]) ps)
134 ++ (cfg.extraPackages ps)
135 ++ (concatMap (component: component.propagatedBuildInputs or [ ]) cfg.customComponents);
136 })
137 );
138
139 # Create a directory that holds all lovelace modules
140 customLovelaceModulesDir = pkgs.buildEnv {
141 name = "home-assistant-custom-lovelace-modules";
142 paths = cfg.customLovelaceModules;
143 };
144
145 # Create parts of the lovelace config that reference lovelave modules as resources
146 customLovelaceModulesResources = {
147 lovelace.resources = map (card: {
148 url = "/local/nixos-lovelace-modules/${card.entrypoint or (card.pname + ".js")}?${card.version}";
149 type = "module";
150 }) cfg.customLovelaceModules;
151 };
152in
153{
154 imports = [
155 # Migrations in NixOS 22.05
156 (mkRemovedOptionModule [
157 "services"
158 "home-assistant"
159 "applyDefaultConfig"
160 ] "The default config was migrated into services.home-assistant.config")
161 (mkRemovedOptionModule [
162 "services"
163 "home-assistant"
164 "autoExtraComponents"
165 ] "Components are now parsed from services.home-assistant.config unconditionally")
166 (mkRenamedOptionModule
167 [ "services" "home-assistant" "port" ]
168 [ "services" "home-assistant" "config" "http" "server_port" ]
169 )
170 ];
171
172 meta = {
173 buildDocsInSandbox = false;
174 maintainers = lib.teams.home-assistant.members;
175 };
176
177 options.services.home-assistant = {
178 # Running home-assistant on NixOS is considered an installation method that is unsupported by the upstream project.
179 # https://github.com/home-assistant/architecture/blob/master/adr/0012-define-supported-installation-method.md#decision
180 enable = mkEnableOption "Home Assistant. Please note that this installation method is unsupported upstream";
181
182 extraArgs = mkOption {
183 type = types.listOf types.str;
184 default = [ ];
185 example = [ "--debug" ];
186 description = ''
187 Extra arguments to pass to the hass executable.
188 '';
189 };
190
191 configDir = mkOption {
192 default = "/var/lib/hass";
193 type = types.path;
194 description = "The config directory, where your {file}`configuration.yaml` is located.";
195 };
196
197 defaultIntegrations = mkOption {
198 type = types.listOf (types.enum availableComponents);
199 # https://github.com/home-assistant/core/blob/dev/homeassistant/bootstrap.py#L109
200 default = [
201 "application_credentials"
202 "frontend"
203 "hardware"
204 "logger"
205 "network"
206 "system_health"
207
208 # key features
209 "automation"
210 "person"
211 "scene"
212 "script"
213 "tag"
214 "zone"
215
216 # built-in helpers
217 "counter"
218 "input_boolean"
219 "input_button"
220 "input_datetime"
221 "input_number"
222 "input_select"
223 "input_text"
224 "schedule"
225 "timer"
226
227 # non-supervisor
228 "backup"
229 ];
230 readOnly = true;
231 description = ''
232 List of integrations set are always set up, unless in recovery mode.
233 '';
234 };
235
236 extraComponents = mkOption {
237 type = types.listOf (types.enum availableComponents);
238 default = [
239 # List of components required to complete the onboarding
240 "default_config"
241 "met"
242 "esphome"
243 ]
244 ++ optionals pkgs.stdenv.hostPlatform.isAarch [
245 # Use the platform as an indicator that we might be running on a RaspberryPi and include
246 # relevant components
247 "rpi_power"
248 ];
249 example = literalExpression ''
250 [
251 "analytics"
252 "default_config"
253 "esphome"
254 "my"
255 "shopping_list"
256 "wled"
257 ]
258 '';
259 description = ''
260 List of [components](https://www.home-assistant.io/integrations/) that have their dependencies included in the package.
261
262 The component name can be found in the URL, for example `https://www.home-assistant.io/integrations/ffmpeg/` would map to `ffmpeg`.
263 '';
264 };
265
266 extraPackages = mkOption {
267 type = types.functionTo (types.listOf types.package);
268 default = _: [ ];
269 defaultText = literalExpression ''
270 python3Packages: with python3Packages; [];
271 '';
272 example = literalExpression ''
273 python3Packages: with python3Packages; [
274 # postgresql support
275 psycopg2
276 ];
277 '';
278 description = ''
279 List of packages to add to propagatedBuildInputs.
280
281 A popular example is `python3Packages.psycopg2`
282 for PostgreSQL support in the recorder component.
283 '';
284 };
285
286 customComponents = mkOption {
287 type = types.listOf (
288 types.addCheck types.package (p: p.isHomeAssistantComponent or false)
289 // {
290 name = "home-assistant-component";
291 description = "package that is a Home Assistant component";
292 }
293 );
294 default = [ ];
295 example = literalExpression ''
296 with pkgs.home-assistant-custom-components; [
297 prometheus_sensor
298 ];
299 '';
300 description = ''
301 List of custom component packages to install.
302
303 Available components can be found below `pkgs.home-assistant-custom-components`.
304 '';
305 };
306
307 customLovelaceModules = mkOption {
308 type = types.listOf types.package;
309 default = [ ];
310 example = literalExpression ''
311 with pkgs.home-assistant-custom-lovelace-modules; [
312 mini-graph-card
313 mini-media-player
314 ];
315 '';
316 description = ''
317 List of custom lovelace card packages to load as lovelace resources.
318
319 Available cards can be found below `pkgs.home-assistant-custom-lovelace-modules`.
320
321 ::: {.note}
322 Automatic loading only works with lovelace in `yaml` mode.
323 :::
324 '';
325 };
326
327 config = mkOption {
328 type = types.nullOr (
329 types.submodule {
330 freeformType = format.type;
331 options = {
332 # This is a partial selection of the most common options, so new users can quickly
333 # pick up how to match home-assistants config structure to ours. It also lets us preset
334 # config values intelligently.
335
336 homeassistant = {
337 # https://www.home-assistant.io/docs/configuration/basic/
338 name = mkOption {
339 type = types.nullOr types.str;
340 default = null;
341 example = "Home";
342 description = ''
343 Name of the location where Home Assistant is running.
344 '';
345 };
346
347 latitude = mkOption {
348 type = types.nullOr (types.either types.float types.str);
349 default = null;
350 example = 52.3;
351 description = ''
352 Latitude of your location required to calculate the time the sun rises and sets.
353 '';
354 };
355
356 longitude = mkOption {
357 type = types.nullOr (types.either types.float types.str);
358 default = null;
359 example = 4.9;
360 description = ''
361 Longitude of your location required to calculate the time the sun rises and sets.
362 '';
363 };
364
365 unit_system = mkOption {
366 type = types.nullOr (
367 types.enum [
368 "metric"
369 "us_customary"
370 ]
371 );
372 default = null;
373 example = "metric";
374 description = ''
375 The unit system to use. This also sets temperature_unit, Celsius for Metric and Fahrenheit for US Customary.
376 '';
377 };
378
379 temperature_unit = mkOption {
380 type = types.nullOr (
381 types.enum [
382 "C"
383 "F"
384 ]
385 );
386 default = null;
387 example = "C";
388 description = ''
389 Override temperature unit set by unit_system. `C` for Celsius, `F` for Fahrenheit.
390 '';
391 };
392
393 time_zone = mkOption {
394 type = types.nullOr types.str;
395 default = config.time.timeZone or null;
396 defaultText = literalExpression ''
397 config.time.timeZone or null
398 '';
399 example = "Europe/Amsterdam";
400 description = ''
401 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).
402 '';
403 };
404 };
405
406 http = {
407 # https://www.home-assistant.io/integrations/http/
408 server_host = mkOption {
409 type = types.either types.str (types.listOf types.str);
410 default = [
411 "0.0.0.0"
412 "::"
413 ];
414 example = "::1";
415 description = ''
416 Only listen to incoming requests on specific IP/host. The default listed assumes support for IPv4 and IPv6.
417 '';
418 };
419
420 server_port = mkOption {
421 default = 8123;
422 type = types.port;
423 description = ''
424 The port on which to listen.
425 '';
426 };
427 };
428
429 lovelace = {
430 # https://www.home-assistant.io/lovelace/dashboards/
431 mode = mkOption {
432 type = types.enum [
433 "yaml"
434 "storage"
435 ];
436 default =
437 if (cfg.lovelaceConfig != null || cfg.lovelaceConfigFile != null) then "yaml" else "storage";
438 defaultText = literalExpression ''
439 if (cfg.lovelaceConfig != null || cfg.lovelaceConfigFile != null)
440 then "yaml"
441 else "storage";
442 '';
443 example = "yaml";
444 description = ''
445 In what mode should the main Lovelace panel be, `yaml` or `storage` (UI managed).
446 '';
447 };
448 };
449 };
450 }
451 );
452 example = literalExpression ''
453 {
454 homeassistant = {
455 name = "Home";
456 latitude = "!secret latitude";
457 longitude = "!secret longitude";
458 elevation = "!secret elevation";
459 unit_system = "metric";
460 time_zone = "UTC";
461 };
462 frontend = {
463 themes = "!include_dir_merge_named themes";
464 };
465 http = {};
466 feedreader.urls = [ "https://nixos.org/blogs.xml" ];
467 }
468 '';
469 description = ''
470 Your {file}`configuration.yaml` as a Nix attribute set.
471
472 YAML functions like [secrets](https://www.home-assistant.io/docs/configuration/secrets/)
473 can be passed as a string and will be unquoted automatically.
474
475 Unless this option is explicitly set to `null`
476 we assume your {file}`configuration.yaml` is
477 managed through this module and thereby overwritten on startup.
478 '';
479 };
480
481 configWritable = mkOption {
482 default = false;
483 type = types.bool;
484 description = ''
485 Whether to make {file}`configuration.yaml` writable.
486
487 This will allow you to edit it from Home Assistant's web interface.
488
489 This only has an effect if {option}`config` is set.
490 However, bear in mind that it will be overwritten at every start of the service.
491 '';
492 };
493
494 lovelaceConfig = mkOption {
495 default = null;
496 type = types.nullOr format.type;
497 # from https://www.home-assistant.io/lovelace/dashboards/
498 example = literalExpression ''
499 {
500 title = "My Awesome Home";
501 views = [ {
502 title = "Example";
503 cards = [ {
504 type = "markdown";
505 title = "Lovelace";
506 content = "Welcome to your **Lovelace UI**.";
507 } ];
508 } ];
509 }
510 '';
511 description = ''
512 Your {file}`ui-lovelace.yaml` as a Nix attribute set.
513 Setting this option will automatically set `lovelace.mode` to `yaml`.
514
515 Beware that setting this option will delete your previous {file}`ui-lovelace.yaml`
516 '';
517 };
518
519 lovelaceConfigFile = mkOption {
520 default = null;
521 type = types.nullOr types.path;
522 example = "/path/to/ui-lovelace.yaml";
523 description = ''
524 Your {file}`ui-lovelace.yaml` managed as configuraton file.
525 Setting this option will automatically set `lovelace.mode` to `yaml`.
526 '';
527 };
528
529 lovelaceConfigWritable = mkOption {
530 default = false;
531 type = types.bool;
532 description = ''
533 Whether to make {file}`ui-lovelace.yaml` writable.
534
535 This will allow you to edit it from Home Assistant's web interface.
536
537 This only has an effect if {option}`lovelaceConfig` is set.
538 However, bear in mind that it will be overwritten at every start of the service.
539 '';
540 };
541
542 package = mkOption {
543 default = pkgs.home-assistant.overrideAttrs (oldAttrs: {
544 doInstallCheck = false;
545 });
546 defaultText = literalExpression ''
547 pkgs.home-assistant.overrideAttrs (oldAttrs: {
548 doInstallCheck = false;
549 })
550 '';
551 type = types.package;
552 example = literalExpression ''
553 pkgs.home-assistant.override {
554 extraPackages = python3Packages: with python3Packages; [
555 psycopg2
556 ];
557 extraComponents = [
558 "default_config"
559 "esphome"
560 "met"
561 ];
562 }
563 '';
564 description = ''
565 The Home Assistant package to use.
566 '';
567 };
568
569 openFirewall = mkOption {
570 default = false;
571 type = types.bool;
572 description = "Whether to open the firewall for the specified port.";
573 };
574
575 blueprints = mergeAttrsList (
576 map
577 (domain: {
578 ${domain} = mkOption {
579 default = [ ];
580 description = ''
581 List of ${domain}
582 [blueprints](https://www.home-assistant.io/docs/blueprint/) to
583 install into {file}`''${config.services.home-assistant.configDir}/blueprints/${domain}`.
584 '';
585 example =
586 if domain == "automation" then
587 literalExpression ''
588 [
589 (pkgs.fetchurl {
590 url = "https://github.com/home-assistant/core/raw/2025.1.4/homeassistant/components/automation/blueprints/motion_light.yaml";
591 hash = "sha256-4HrDX65ycBMfEY2nZ7A25/d3ZnIHdpHZ+80Cblp+P5w=";
592 })
593 ]
594 ''
595 else if domain == "template" then
596 literalExpression "[ \"\${pkgs.home-assistant.src}/homeassistant/components/template/blueprints/inverted_binary_sensor.yaml\" ]"
597 else
598 literalExpression "[ ./blueprint.yaml ]";
599 type = types.listOf (types.coercedTo types.path (x: "${x}") types.pathInStore);
600 };
601 })
602 # https://www.home-assistant.io/docs/blueprint/schema/#domain
603 [
604 "automation"
605 "script"
606 "template"
607 ]
608 );
609 };
610
611 config = mkIf cfg.enable {
612 assertions = [
613 {
614 assertion = cfg.openFirewall -> cfg.config != null;
615 message = "openFirewall can only be used with a declarative config";
616 }
617 {
618 assertion = !(cfg.lovelaceConfig != null && cfg.lovelaceConfigFile != null);
619 message = "Only one of `lovelaceConfig` or `lovelaceConfigFile` can be configured at the same time.";
620 }
621 ];
622
623 networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.config.http.server_port ];
624
625 # symlink the configuration to /etc/home-assistant
626 environment.etc = mkMerge [
627 (mkIf (cfg.config != null && !cfg.configWritable) {
628 "home-assistant/configuration.yaml".source = configFile;
629 })
630
631 (mkIf
632 ((cfg.lovelaceConfig != null || cfg.lovelaceConfigFile != null) && !cfg.lovelaceConfigWritable)
633 {
634 "home-assistant/ui-lovelace.yaml".source = lovelaceConfigFile;
635 }
636 )
637 ];
638
639 systemd.services.home-assistant = {
640 description = "Home Assistant";
641 wants = [ "network-online.target" ];
642 after = [
643 "network-online.target"
644
645 # prevent races with database creation
646 "mysql.service"
647 "postgresql.target"
648 ];
649 reloadTriggers =
650 optionals (cfg.config != null) [ configFile ]
651 ++ optionals (cfg.lovelaceConfig != null || cfg.lovelaceConfigFile != null) [ lovelaceConfigFile ];
652
653 preStart =
654 let
655 copyConfig =
656 if cfg.configWritable then
657 ''
658 cp --no-preserve=mode ${configFile} "${cfg.configDir}/configuration.yaml"
659 ''
660 else
661 ''
662 rm -f "${cfg.configDir}/configuration.yaml"
663 ln -s /etc/home-assistant/configuration.yaml "${cfg.configDir}/configuration.yaml"
664 '';
665 copyLovelaceConfig =
666 if cfg.lovelaceConfigWritable then
667 ''
668 rm -f "${cfg.configDir}/ui-lovelace.yaml"
669 cp --no-preserve=mode ${lovelaceConfigFile} "${cfg.configDir}/ui-lovelace.yaml"
670 ''
671 else
672 ''
673 ln -fs /etc/home-assistant/ui-lovelace.yaml "${cfg.configDir}/ui-lovelace.yaml"
674 '';
675 copyCustomLovelaceModules =
676 if cfg.customLovelaceModules != [ ] then
677 ''
678 mkdir -p "${cfg.configDir}/www"
679 ln -fns ${customLovelaceModulesDir} "${cfg.configDir}/www/nixos-lovelace-modules"
680 ''
681 else
682 ''
683 rm -f "${cfg.configDir}/www/nixos-lovelace-modules"
684 '';
685 copyCustomComponents = ''
686 mkdir -p "${cfg.configDir}/custom_components"
687
688 # remove components symlinked in from below the /nix/store
689 readarray -d "" components < <(find "${cfg.configDir}/custom_components" -maxdepth 1 -type l -print0)
690 for component in "''${components[@]}"; do
691 if [[ "$(readlink "$component")" =~ ^${escapeShellArg builtins.storeDir} ]]; then
692 rm "$component"
693 fi
694 done
695
696 # recreate symlinks for desired components
697 declare -a components=(${escapeShellArgs cfg.customComponents})
698 for component in "''${components[@]}"; do
699 readarray -t manifests < <(find "$component" -name manifest.json)
700 readarray -t paths < <(dirname "''${manifests[@]}")
701 ln -fns "''${paths[@]}" "${cfg.configDir}/custom_components/"
702 done
703 '';
704 removeBlueprints = ''
705 # remove blueprints symlinked in from below the /nix/store
706 readarray -d "" blueprints < <(find "${cfg.configDir}/blueprints" -maxdepth 2 -type l -print0)
707 for blueprint in "''${blueprints[@]}"; do
708 if [[ "$(readlink "$blueprint")" =~ ^${escapeShellArg builtins.storeDir} ]]; then
709 rm "$blueprint"
710 fi
711 done
712 '';
713 copyBlueprint =
714 domain: blueprint:
715 let
716 filename =
717 if isStorePath blueprint then substring 33 (-1) (baseNameOf blueprint) else baseNameOf blueprint;
718 path = "${cfg.configDir}/blueprints/${domain}";
719 in
720 ''
721 mkdir -p ${escapeShellArg path}
722 ln -s ${escapeShellArg blueprint} ${escapeShellArg "${path}/${filename}"}
723 '';
724 copyBlueprints = concatStrings (
725 flatten (mapAttrsToList (domain: map (copyBlueprint domain)) cfg.blueprints)
726 );
727 in
728 (optionalString (cfg.config != null) copyConfig)
729 + (optionalString (cfg.lovelaceConfig != null) copyLovelaceConfig)
730 + copyCustomLovelaceModules
731 + copyCustomComponents
732 + removeBlueprints
733 + copyBlueprints;
734 environment.PYTHONPATH = package.pythonPath;
735 serviceConfig =
736 let
737 # List of capabilities to equip home-assistant with, depending on configured components
738 capabilities = unique (
739 [
740 # Empty string first, so we will never accidentally have an empty capability bounding set
741 # https://github.com/NixOS/nixpkgs/issues/120617#issuecomment-830685115
742 ""
743 ]
744 ++ optionals (any useComponent componentsUsingBluetooth) [
745 # Required for interaction with hci devices and bluetooth sockets, identified by bluetooth-adapters dependency
746 # https://www.home-assistant.io/integrations/bluetooth_le_tracker/#rootless-setup-on-core-installs
747 "CAP_NET_ADMIN"
748 "CAP_NET_RAW"
749 ]
750 ++ optionals (useComponent "emulated_hue") [
751 # Alexa looks for the service on port 80
752 # https://www.home-assistant.io/integrations/emulated_hue
753 "CAP_NET_BIND_SERVICE"
754 ]
755 ++ optionals (useComponent "nmap_tracker") [
756 # https://www.home-assistant.io/integrations/nmap_tracker#linux-capabilities
757 "CAP_NET_ADMIN"
758 "CAP_NET_BIND_SERVICE"
759 "CAP_NET_RAW"
760 ]
761 );
762 componentsUsingBluetooth = [
763 # Components that require the AF_BLUETOOTH address family
764 "august"
765 "august_ble"
766 "airthings_ble"
767 "aranet"
768 "bluemaestro"
769 "bluetooth"
770 "bluetooth_adapters"
771 "bluetooth_le_tracker"
772 "bluetooth_tracker"
773 "bthome"
774 "default_config"
775 "eufylife_ble"
776 "esphome"
777 "fjaraskupan"
778 "gardena_bluetooth"
779 "govee_ble"
780 "homekit_controller"
781 "inkbird"
782 "improv_ble"
783 "keymitt_ble"
784 "ld2410_ble"
785 "leaone"
786 "led_ble"
787 "medcom_ble"
788 "melnor"
789 "moat"
790 "mopeka"
791 "motionblinds_ble"
792 "oralb"
793 "private_ble_device"
794 "qingping"
795 "rapt_ble"
796 "ruuvi_gateway"
797 "ruuvitag_ble"
798 "sensirion_ble"
799 "sensorpro"
800 "sensorpush"
801 "shelly"
802 "snooz"
803 "switchbot"
804 "thermobeacon"
805 "thermopro"
806 "tilt_ble"
807 "xiaomi_ble"
808 "yalexs_ble"
809 ];
810 componentsUsingPing = [
811 # Components that require the capset syscall for the ping wrapper
812 "ping"
813 "wake_on_lan"
814 ];
815 componentsUsingSerialDevices = [
816 # Components that require access to serial devices (/dev/tty*)
817 # List generated from home-assistant documentation:
818 # git clone https://github.com/home-assistant/home-assistant.io/
819 # cd source/_integrations
820 # rg "/dev/tty" -l | cut -d'/' -f3 | cut -d'.' -f1 | sort
821 # And then extended by references found in the source code, these
822 # mostly the ones using config flows already.
823 "acer_projector"
824 "alarmdecoder"
825 "aurora_abb_powerone"
826 "blackbird"
827 "bryant_evolution"
828 "crownstone"
829 "deconz"
830 "dsmr"
831 "edl21"
832 "elkm1"
833 "elv"
834 "enocean"
835 "homeassistant_hardware"
836 "homeassistant_yellow"
837 "firmata"
838 "flexit"
839 "gpsd"
840 "insteon"
841 "kwb"
842 "lacrosse"
843 "landisgyr_heat_meter"
844 "modbus"
845 "modem_callerid"
846 "mysensors"
847 "nad"
848 "numato"
849 "nut"
850 "opentherm_gw"
851 "otbr"
852 "rainforst_raven"
853 "rflink"
854 "rfxtrx"
855 "scsgate"
856 "serial"
857 "serial_pm"
858 "sms"
859 "upb"
860 "usb"
861 "velbus"
862 "w800rf32"
863 "zha"
864 "zwave"
865 "zwave_js"
866
867 # Custom components, maintained manually.
868 "amshan"
869 "benqprojector"
870 ];
871 componentsUsingInputDevices = [
872 # Components that require access to input devices (/dev/input/*)
873 "keyboard_remote"
874 ];
875 in
876 {
877 ExecStart = escapeSystemdExecArgs (
878 [
879 (lib.getExe package)
880 "--config"
881 cfg.configDir
882 ]
883 ++ cfg.extraArgs
884 );
885 ExecReload =
886 (escapeSystemdExecArgs [
887 (lib.getExe' pkgs.coreutils "kill")
888 "-HUP"
889 ])
890 + " $MAINPID";
891 User = "hass";
892 Group = "hass";
893 WorkingDirectory = cfg.configDir;
894 Restart = "on-failure";
895
896 # Signal handling
897 # homeassistant/helpers/signal.py
898 RestartForceExitStatus = "100";
899 SuccessExitStatus = "100";
900 KillSignal = "SIGINT";
901
902 # Hardening
903 AmbientCapabilities = capabilities;
904 CapabilityBoundingSet = capabilities;
905 DeviceAllow =
906 optionals (any useComponent componentsUsingSerialDevices) [
907 "char-ttyACM rw"
908 "char-ttyAMA rw"
909 "char-ttyUSB rw"
910 ]
911 ++ optionals (any useComponent componentsUsingInputDevices) [
912 "char-input rw"
913 ];
914 DevicePolicy = "closed";
915 LockPersonality = true;
916 MemoryDenyWriteExecute = true;
917 NoNewPrivileges = true;
918 PrivateTmp = true;
919 PrivateUsers = false; # prevents gaining capabilities in the host namespace
920 ProtectClock = true;
921 ProtectControlGroups = true;
922 ProtectHome = true;
923 ProtectHostname = true;
924 ProtectKernelLogs = true;
925 ProtectKernelModules = true;
926 ProtectKernelTunables = true;
927 ProtectProc = "invisible";
928 ProcSubset = "all";
929 ProtectSystem = "strict";
930 RemoveIPC = true;
931 ReadWritePaths =
932 let
933 # Allow rw access to explicitly configured paths
934 cfgPath = [
935 "config"
936 "homeassistant"
937 "allowlist_external_dirs"
938 ];
939 value = attrByPath cfgPath [ ] cfg;
940 allowPaths = if isList value then value else singleton value;
941 in
942 [ "${cfg.configDir}" ] ++ allowPaths;
943 RestrictAddressFamilies = [
944 "AF_INET"
945 "AF_INET6"
946 "AF_NETLINK"
947 "AF_UNIX"
948 ]
949 ++ optionals (any useComponent componentsUsingBluetooth) [
950 "AF_BLUETOOTH"
951 ];
952 RestrictNamespaces = true;
953 RestrictRealtime = true;
954 RestrictSUIDSGID = true;
955 SupplementaryGroups =
956 optionals (any useComponent componentsUsingSerialDevices) [
957 "dialout"
958 ]
959 ++ optionals (any useComponent componentsUsingInputDevices) [
960 "input"
961 ];
962 SystemCallArchitectures = "native";
963 SystemCallFilter = [
964 "@system-service"
965 "~@privileged"
966 ]
967 ++ optionals (any useComponent componentsUsingPing) [
968 "capset"
969 "setuid"
970 ];
971 UMask = "0077";
972 };
973 path = [
974 pkgs.unixtools.ping # needed for ping
975 ];
976 };
977
978 systemd.targets.home-assistant = rec {
979 description = "Home Assistant";
980 wantedBy = [ "multi-user.target" ];
981 wants = [ "home-assistant.service" ];
982 after = wants;
983 };
984
985 users.users.hass = {
986 home = cfg.configDir;
987 createHome = true;
988 group = "hass";
989 uid = config.ids.uids.hass;
990 };
991
992 users.groups.hass.gid = config.ids.gids.hass;
993 };
994}