{ 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 (lib.mdDoc "Home Assistant. Please note that this installation method is unsupported upstream"); configDir = mkOption { default = "/var/lib/hass"; type = types.path; description = lib.mdDoc "The config directory, where your {file}`configuration.yaml` 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.isAarch [ # 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 = lib.mdDoc '' List of [components](https://www.home-assistant.io/integrations/) that have their dependencies included in the package. The component name can be found in the URL, for example `https://www.home-assistant.io/integrations/ffmpeg/` would map to `ffmpeg`. ''; }; extraPackages = mkOption { type = types.functionTo (types.listOf types.package); default = _: []; defaultText = literalExpression '' python3Packages: with python3Packages; []; ''; example = literalExpression '' python3Packages: with python3Packages; [ # postgresql support psycopg2 ]; ''; description = lib.mdDoc '' List of packages to add to propagatedBuildInputs. A popular example is `python3Packages.psycopg2` 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' Override temperature unit set by unit_system. `C` for Celsius, `F` 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 = lib.mdDoc '' 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). ''; }; }; 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 = lib.mdDoc '' 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 = lib.mdDoc '' 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 = lib.mdDoc '' In what mode should the main Lovelace panel be, `yaml` or `storage` (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 = lib.mdDoc '' Your {file}`configuration.yaml` as a Nix attribute set. YAML functions like [secrets](https://www.home-assistant.io/docs/configuration/secrets/) can be passed as a string and will be unquoted automatically. Unless this option is explicitly set to `null` we assume your {file}`configuration.yaml` is managed through this module and thereby overwritten on startup. ''; }; configWritable = mkOption { default = false; type = types.bool; description = lib.mdDoc '' Whether to make {file}`configuration.yaml` writable. This will allow you to edit it from Home Assistant's web interface. This only has an effect if {option}`config` 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 = lib.mdDoc '' Your {file}`ui-lovelace.yaml` as a Nix attribute set. Setting this option will automatically set `lovelace.mode` to `yaml`. Beware that setting this option will delete your previous {file}`ui-lovelace.yaml` ''; }; lovelaceConfigWritable = mkOption { default = false; type = types.bool; description = lib.mdDoc '' Whether to make {file}`ui-lovelace.yaml` writable. This will allow you to edit it from Home Assistant's web interface. This only has an effect if {option}`lovelaceConfig` 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 = lib.mdDoc '' The Home Assistant package to use. ''; }; openFirewall = mkOption { default = false; type = types.bool; description = lib.mdDoc "Whether to open the firewall for the specified port."; }; }; config = mkIf cfg.enable { assertions = [ { assertion = cfg.openFirewall -> !isNull cfg.config; message = "openFirewall can only be used with a declarative config"; } ]; networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.config.http.server_port ]; # symlink the configuration to /etc/home-assistant environment.etc = lib.mkMerge [ (lib.mkIf (cfg.config != null && !cfg.configWritable) { "home-assistant/configuration.yaml".source = configFile; }) (lib.mkIf (cfg.lovelaceConfig != null && !cfg.lovelaceConfigWritable) { "home-assistant/ui-lovelace.yaml".source = lovelaceConfigFile; }) ]; systemd.services.home-assistant = { description = "Home Assistant"; after = [ "network-online.target" # prevent races with database creation "mysql.service" "postgresql.service" ]; reloadTriggers = lib.optional (cfg.config != null) configFile ++ lib.optional (cfg.lovelaceConfig != null) lovelaceConfigFile; 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 /etc/home-assistant/configuration.yaml "${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 /etc/home-assistant/ui-lovelace.yaml "${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 = lib.unique ([ # Empty string first, so we will never accidentally have an empty capability bounding set # https://github.com/NixOS/nixpkgs/issues/120617#issuecomment-830685115 "" ] ++ lib.optionals (builtins.any useComponent componentsUsingBluetooth) [ # Required for interaction with hci devices and bluetooth sockets, identified by bluetooth-adapters dependency # 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 "august" "bluemaestro" "bluetooth" "bluetooth_le_tracker" "bluetooth_tracker" "bthome" "default_config" "eq3btsmart" "esphome" "fjaraskupan" "govee_ble" "homekit_controller" "inkbird" "led_ble" "melnor" "moat" "qingping" "sensorpro" "sensorpush" "switchbot" "thermobeacon" "thermopro" "xiaomi_ble" "yalexs_ble" ]; 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" "blackbird" "deconz" "dsmr" "edl21" "elkm1" "elv" "enocean" "firmata" "flexit" "gpsd" "insteon" "kwb" "lacrosse" "modbus" "modem_callerid" "mysensors" "nad" "numato" "rflink" "rfxtrx" "scsgate" "serial" "serial_pm" "sms" "upb" "usb" "velbus" "w800rf32" "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; }; }