diff options
Diffstat (limited to 'nixos/modules/services/home-automation/home-assistant.nix')
-rw-r--r-- | nixos/modules/services/home-automation/home-assistant.nix | 552 |
1 files changed, 552 insertions, 0 deletions
diff --git a/nixos/modules/services/home-automation/home-assistant.nix b/nixos/modules/services/home-automation/home-assistant.nix new file mode 100644 index 00000000000..6022227f6ea --- /dev/null +++ b/nixos/modules/services/home-automation/home-assistant.nix @@ -0,0 +1,552 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.home-assistant; + format = pkgs.formats.yaml {}; + + # Render config attribute sets to YAML + # Values that are null will be filtered from the output, so this is one way to have optional + # options shown in settings. + # We post-process the result to add support for YAML functions, like secrets or includes, see e.g. + # https://www.home-assistant.io/docs/configuration/secrets/ + filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [ null ])) cfg.config or {}; + configFile = pkgs.runCommand "configuration.yaml" { preferLocalBuild = true; } '' + cp ${format.generate "configuration.yaml" filteredConfig} $out + sed -i -e "s/'\!\([a-z_]\+\) \(.*\)'/\!\1 \2/;s/^\!\!/\!/;" $out + ''; + lovelaceConfig = cfg.lovelaceConfig or {}; + lovelaceConfigFile = format.generate "ui-lovelace.yaml" lovelaceConfig; + + # Components advertised by the home-assistant package + availableComponents = cfg.package.availableComponents; + + # Components that were added by overriding the package + explicitComponents = cfg.package.extraComponents; + useExplicitComponent = component: elem component explicitComponents; + + # Given a component "platform", looks up whether it is used in the config + # as `platform = "platform";`. + # + # For example, the component mqtt.sensor is used as follows: + # config.sensor = [ { + # platform = "mqtt"; + # ... + # } ]; + usedPlatforms = config: + if isAttrs config then + optional (config ? platform) config.platform + ++ concatMap usedPlatforms (attrValues config) + else if isList config then + concatMap usedPlatforms config + else [ ]; + + useComponentPlatform = component: elem component (usedPlatforms cfg.config); + + # Returns whether component is used in config, explicitly passed into package or + # configured in the module. + useComponent = component: + hasAttrByPath (splitString "." component) cfg.config + || useComponentPlatform component + || useExplicitComponent component + || builtins.elem component cfg.extraComponents; + + # Final list of components passed into the package to include required dependencies + extraComponents = filter useComponent availableComponents; + + package = (cfg.package.override (oldArgs: { + # Respect overrides that already exist in the passed package and + # concat it with values passed via the module. + extraComponents = oldArgs.extraComponents or [] ++ extraComponents; + extraPackages = ps: (oldArgs.extraPackages or (_: []) ps) ++ (cfg.extraPackages ps); + })); +in { + imports = [ + # Migrations in NixOS 22.05 + (mkRemovedOptionModule [ "services" "home-assistant" "applyDefaultConfig" ] "The default config was migrated into services.home-assistant.config") + (mkRemovedOptionModule [ "services" "home-assistant" "autoExtraComponents" ] "Components are now parsed from services.home-assistant.config unconditionally") + (mkRenamedOptionModule [ "services" "home-assistant" "port" ] [ "services" "home-assistant" "config" "http" "server_port" ]) + ]; + + meta = { + buildDocsInSandbox = false; + maintainers = teams.home-assistant.members; + }; + + options.services.home-assistant = { + # Running home-assistant on NixOS is considered an installation method that is unsupported by the upstream project. + # https://github.com/home-assistant/architecture/blob/master/adr/0012-define-supported-installation-method.md#decision + enable = mkEnableOption "Home Assistant. Please note that this installation method is unsupported upstream"; + + configDir = mkOption { + default = "/var/lib/hass"; + type = types.path; + description = "The config directory, where your <filename>configuration.yaml</filename> is located."; + }; + + extraComponents = mkOption { + type = types.listOf (types.enum availableComponents); + default = [ + # List of components required to complete the onboarding + "default_config" + "met" + "esphome" + ] ++ optionals (pkgs.stdenv.hostPlatform.isAarch32 || pkgs.stdenv.hostPlatform.isAarch64) [ + # Use the platform as an indicator that we might be running on a RaspberryPi and include + # relevant components + "rpi_power" + ]; + example = literalExpression '' + [ + "analytics" + "default_config" + "esphome" + "my" + "shopping_list" + "wled" + ] + ''; + description = '' + List of <link xlink:href="https://www.home-assistant.io/integrations/">components</link> that have their dependencies included in the package. + + The component name can be found in the URL, for example <literal>https://www.home-assistant.io/integrations/ffmpeg/</literal> would map to <literal>ffmpeg</literal>. + ''; + }; + + extraPackages = mkOption { + type = types.functionTo (types.listOf types.package); + default = _: []; + defaultText = literalExpression '' + python3Packages: with python3Packages; []; + ''; + example = literalExpression '' + python3Packages: with python3Packages; [ + # postgresql support + psycopg2 + ]; + ''; + description = '' + List of packages to add to propagatedBuildInputs. + + A popular example is <package>python3Packages.psycopg2</package> + for PostgreSQL support in the recorder component. + ''; + }; + + config = mkOption { + type = types.nullOr (types.submodule { + freeformType = format.type; + options = { + # This is a partial selection of the most common options, so new users can quickly + # pick up how to match home-assistants config structure to ours. It also lets us preset + # config values intelligently. + + homeassistant = { + # https://www.home-assistant.io/docs/configuration/basic/ + name = mkOption { + type = types.nullOr types.str; + default = null; + example = "Home"; + description = '' + Name of the location where Home Assistant is running. + ''; + }; + + latitude = mkOption { + type = types.nullOr (types.either types.float types.str); + default = null; + example = 52.3; + description = '' + Latitude of your location required to calculate the time the sun rises and sets. + ''; + }; + + longitude = mkOption { + type = types.nullOr (types.either types.float types.str); + default = null; + example = 4.9; + description = '' + Longitude of your location required to calculate the time the sun rises and sets. + ''; + }; + + unit_system = mkOption { + type = types.nullOr (types.enum [ "metric" "imperial" ]); + default = null; + example = "metric"; + description = '' + The unit system to use. This also sets temperature_unit, Celsius for Metric and Fahrenheit for Imperial. + ''; + }; + + temperature_unit = mkOption { + type = types.nullOr (types.enum [ "C" "F" ]); + default = null; + example = "C"; + description = '' + Override temperature unit set by unit_system. <literal>C</literal> for Celsius, <literal>F</literal> for Fahrenheit. + ''; + }; + + time_zone = mkOption { + type = types.nullOr types.str; + default = config.time.timeZone or null; + defaultText = literalExpression '' + config.time.timeZone or null + ''; + example = "Europe/Amsterdam"; + description = '' + Pick your time zone from the column TZ of Wikipedia’s <link xlink:href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">list of tz database time zones</link>. + ''; + }; + }; + + http = { + # https://www.home-assistant.io/integrations/http/ + server_host = mkOption { + type = types.either types.str (types.listOf types.str); + default = [ + "0.0.0.0" + "::" + ]; + example = "::1"; + description = '' + Only listen to incoming requests on specific IP/host. The default listed assumes support for IPv4 and IPv6. + ''; + }; + + server_port = mkOption { + default = 8123; + type = types.port; + description = '' + The port on which to listen. + ''; + }; + }; + + lovelace = { + # https://www.home-assistant.io/lovelace/dashboards/ + mode = mkOption { + type = types.enum [ "yaml" "storage" ]; + default = if cfg.lovelaceConfig != null + then "yaml" + else "storage"; + defaultText = literalExpression '' + if cfg.lovelaceConfig != null + then "yaml" + else "storage"; + ''; + example = "yaml"; + description = '' + In what mode should the main Lovelace panel be, <literal>yaml</literal> or <literal>storage</literal> (UI managed). + ''; + }; + }; + }; + }); + example = literalExpression '' + { + homeassistant = { + name = "Home"; + latitude = "!secret latitude"; + longitude = "!secret longitude"; + elevation = "!secret elevation"; + unit_system = "metric"; + time_zone = "UTC"; + }; + frontend = { + themes = "!include_dir_merge_named themes"; + }; + http = {}; + feedreader.urls = [ "https://nixos.org/blogs.xml" ]; + } + ''; + description = '' + Your <filename>configuration.yaml</filename> as a Nix attribute set. + + YAML functions like <link xlink:href="https://www.home-assistant.io/docs/configuration/secrets/">secrets</link> + can be passed as a string and will be unquoted automatically. + + Unless this option is explicitly set to <literal>null</literal> + we assume your <filename>configuration.yaml</filename> is + managed through this module and thereby overwritten on startup. + ''; + }; + + configWritable = mkOption { + default = false; + type = types.bool; + description = '' + Whether to make <filename>configuration.yaml</filename> writable. + + This will allow you to edit it from Home Assistant's web interface. + + This only has an effect if <option>config</option> is set. + However, bear in mind that it will be overwritten at every start of the service. + ''; + }; + + lovelaceConfig = mkOption { + default = null; + type = types.nullOr format.type; + # from https://www.home-assistant.io/lovelace/dashboards/ + example = literalExpression '' + { + title = "My Awesome Home"; + views = [ { + title = "Example"; + cards = [ { + type = "markdown"; + title = "Lovelace"; + content = "Welcome to your **Lovelace UI**."; + } ]; + } ]; + } + ''; + description = '' + Your <filename>ui-lovelace.yaml</filename> as a Nix attribute set. + Setting this option will automatically set <literal>lovelace.mode</literal> to <literal>yaml</literal>. + + Beware that setting this option will delete your previous <filename>ui-lovelace.yaml</filename> + ''; + }; + + lovelaceConfigWritable = mkOption { + default = false; + type = types.bool; + description = '' + Whether to make <filename>ui-lovelace.yaml</filename> writable. + + This will allow you to edit it from Home Assistant's web interface. + + This only has an effect if <option>lovelaceConfig</option> is set. + However, bear in mind that it will be overwritten at every start of the service. + ''; + }; + + package = mkOption { + default = pkgs.home-assistant.overrideAttrs (oldAttrs: { + doInstallCheck = false; + }); + defaultText = literalExpression '' + pkgs.home-assistant.overrideAttrs (oldAttrs: { + doInstallCheck = false; + }) + ''; + type = types.package; + example = literalExpression '' + pkgs.home-assistant.override { + extraPackages = python3Packages: with python3Packages; [ + psycopg2 + ]; + extraComponents = [ + "default_config" + "esphome" + "met" + ]; + } + ''; + description = '' + The Home Assistant package to use. + ''; + }; + + openFirewall = mkOption { + default = false; + type = types.bool; + description = "Whether to open the firewall for the specified port."; + }; + }; + + config = mkIf cfg.enable { + networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ]; + + systemd.services.home-assistant = { + description = "Home Assistant"; + after = [ + "network-online.target" + + # prevent races with database creation + "mysql.service" + "postgresql.service" + ]; + preStart = let + copyConfig = if cfg.configWritable then '' + cp --no-preserve=mode ${configFile} "${cfg.configDir}/configuration.yaml" + '' else '' + rm -f "${cfg.configDir}/configuration.yaml" + ln -s ${configFile} "${cfg.configDir}/configuration.yaml" + ''; + copyLovelaceConfig = if cfg.lovelaceConfigWritable then '' + cp --no-preserve=mode ${lovelaceConfigFile} "${cfg.configDir}/ui-lovelace.yaml" + '' else '' + rm -f "${cfg.configDir}/ui-lovelace.yaml" + ln -s ${lovelaceConfigFile} "${cfg.configDir}/ui-lovelace.yaml" + ''; + in + (optionalString (cfg.config != null) copyConfig) + + (optionalString (cfg.lovelaceConfig != null) copyLovelaceConfig) + ; + serviceConfig = let + # List of capabilities to equip home-assistant with, depending on configured components + capabilities = [ + # Empty string first, so we will never accidentally have an empty capability bounding set + # https://github.com/NixOS/nixpkgs/issues/120617#issuecomment-830685115 + "" + ] ++ (unique (optionals (useComponent "bluetooth_tracker" || useComponent "bluetooth_le_tracker") [ + # Required for interaction with hci devices and bluetooth sockets + # https://www.home-assistant.io/integrations/bluetooth_le_tracker/#rootless-setup-on-core-installs + "CAP_NET_ADMIN" + "CAP_NET_RAW" + ] ++ lib.optionals (useComponent "emulated_hue") [ + # Alexa looks for the service on port 80 + # https://www.home-assistant.io/integrations/emulated_hue + "CAP_NET_BIND_SERVICE" + ] ++ lib.optionals (useComponent "nmap_tracker") [ + # https://www.home-assistant.io/integrations/nmap_tracker#linux-capabilities + "CAP_NET_ADMIN" + "CAP_NET_BIND_SERVICE" + "CAP_NET_RAW" + ])); + componentsUsingBluetooth = [ + # Components that require the AF_BLUETOOTH address family + "bluetooth_tracker" + "bluetooth_le_tracker" + ]; + componentsUsingPing = [ + # Components that require the capset syscall for the ping wrapper + "ping" + "wake_on_lan" + ]; + componentsUsingSerialDevices = [ + # Components that require access to serial devices (/dev/tty*) + # List generated from home-assistant documentation: + # git clone https://github.com/home-assistant/home-assistant.io/ + # cd source/_integrations + # rg "/dev/tty" -l | cut -d'/' -f3 | cut -d'.' -f1 | sort + # And then extended by references found in the source code, these + # mostly the ones using config flows already. + "acer_projector" + "alarmdecoder" + "arduino" + "blackbird" + "deconz" + "dsmr" + "edl21" + "elkm1" + "elv" + "enocean" + "firmata" + "flexit" + "gpsd" + "insteon" + "kwb" + "lacrosse" + "mhz19" + "modbus" + "modem_callerid" + "mysensors" + "nad" + "numato" + "rflink" + "rfxtrx" + "scsgate" + "serial" + "serial_pm" + "sms" + "upb" + "usb" + "velbus" + "w800rf32" + "xbee" + "zha" + "zwave" + "zwave_js" + ]; + in { + ExecStart = "${package}/bin/hass --config '${cfg.configDir}'"; + ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; + User = "hass"; + Group = "hass"; + Restart = "on-failure"; + RestartForceExitStatus = "100"; + SuccessExitStatus = "100"; + KillSignal = "SIGINT"; + + # Hardening + AmbientCapabilities = capabilities; + CapabilityBoundingSet = capabilities; + DeviceAllow = (optionals (any useComponent componentsUsingSerialDevices) [ + "char-ttyACM rw" + "char-ttyAMA rw" + "char-ttyUSB rw" + ]); + DevicePolicy = "closed"; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateTmp = true; + PrivateUsers = false; # prevents gaining capabilities in the host namespace + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProcSubset = "all"; + ProtectSystem = "strict"; + RemoveIPC = true; + ReadWritePaths = let + # Allow rw access to explicitly configured paths + cfgPath = [ "config" "homeassistant" "allowlist_external_dirs" ]; + value = attrByPath cfgPath [] cfg; + allowPaths = if isList value then value else singleton value; + in [ "${cfg.configDir}" ] ++ allowPaths; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_NETLINK" + "AF_UNIX" + ] ++ optionals (any useComponent componentsUsingBluetooth) [ + "AF_BLUETOOTH" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SupplementaryGroups = optionals (any useComponent componentsUsingSerialDevices) [ + "dialout" + ]; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "~@privileged" + ] ++ optionals (any useComponent componentsUsingPing) [ + "capset" + ]; + UMask = "0077"; + }; + path = [ + "/run/wrappers" # needed for ping + ]; + }; + + systemd.targets.home-assistant = rec { + description = "Home Assistant"; + wantedBy = [ "multi-user.target" ]; + wants = [ "home-assistant.service" ]; + after = wants; + }; + + users.users.hass = { + home = cfg.configDir; + createHome = true; + group = "hass"; + uid = config.ids.uids.hass; + }; + + users.groups.hass.gid = config.ids.gids.hass; + }; +} |