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