nixpkgs/nixos/modules/services/home-automation/home-assistant.nix

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

734 lines
24 KiB
Nix
Raw Normal View History

2018-01-23 09:51:13 +00:00
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.home-assistant;
format = pkgs.formats.yaml {};
2018-01-23 09:51:13 +00:00
# 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 ])) (lib.recursiveUpdate customLovelaceModulesResources (cfg.config or {}));
configFile = pkgs.runCommandLocal "configuration.yaml" { } ''
cp ${format.generate "configuration.yaml" filteredConfig} $out
sed -i -e "s/'\!\([a-z_]\+\) \(.*\)'/\!\1 \2/;s/^\!\!/\!/;" $out
'';
lovelaceConfigFile = format.generate "ui-lovelace.yaml" cfg.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:
# don't recurse into derivations possibly creating an infinite recursion
if isDerivation config then
[ ]
else 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 ++ cfg.defaultIntegrations);
# 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)
++ (lib.concatMap (component: component.propagatedBuildInputs or []) cfg.customComponents);
}));
# Create a directory that holds all lovelace modules
customLovelaceModulesDir = pkgs.buildEnv {
name = "home-assistant-custom-lovelace-modules";
paths = cfg.customLovelaceModules;
};
# Create parts of the lovelace config that reference lovelave modules as resources
customLovelaceModulesResources = {
lovelace.resources = map (card: {
url = "/local/nixos-lovelace-modules/${card.entrypoint or (card.pname + ".js")}?${card.version}";
type = "module";
}) cfg.customLovelaceModules;
};
2018-01-23 09:51:13 +00:00
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;
};
2018-01-23 09:51:13 +00:00
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");
2018-01-23 09:51:13 +00:00
configDir = mkOption {
default = "/var/lib/hass";
type = types.path;
description = lib.mdDoc "The config directory, where your {file}`configuration.yaml` is located.";
};
defaultIntegrations = mkOption {
type = types.listOf (types.enum availableComponents);
# https://github.com/home-assistant/core/blob/dev/homeassistant/bootstrap.py#L109
default = [
"application_credentials"
"frontend"
"hardware"
"logger"
"network"
"system_health"
# key features
"automation"
"person"
"scene"
"script"
"tag"
"zone"
# built-in helpers
"counter"
"input_boolean"
"input_button"
"input_datetime"
"input_number"
"input_select"
"input_text"
"schedule"
"timer"
# non-supervisor
"backup"
];
readOnly = true;
description = ''
List of integrations set are always set up, unless in recovery mode.
'';
};
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.
'';
};
customComponents = mkOption {
type = types.listOf types.package;
default = [];
example = literalExpression ''
with pkgs.home-assistant-custom-components; [
prometheus_sensor
];
'';
description = lib.mdDoc ''
List of custom component packages to install.
Available components can be found below `pkgs.home-assistant-custom-components`.
'';
};
customLovelaceModules = mkOption {
type = types.listOf types.package;
default = [];
example = literalExpression ''
with pkgs.home-assistant-custom-lovelace-modules; [
mini-graph-card
mini-media-player
];
'';
description = lib.mdDoc ''
List of custom lovelace card packages to load as lovelace resources.
Available cards can be found below `pkgs.home-assistant-custom-lovelace-modules`.
::: {.note}
Automatic loading only works with lovelace in `yaml` mode.
:::
'';
};
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 Wikipedias [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 ''
2018-01-23 09:51:13 +00:00
{
homeassistant = {
name = "Home";
latitude = "!secret latitude";
longitude = "!secret longitude";
elevation = "!secret elevation";
unit_system = "metric";
2018-01-23 09:51:13 +00:00
time_zone = "UTC";
};
frontend = {
themes = "!include_dir_merge_named themes";
};
http = {};
feedreader.urls = [ "https://nixos.org/blogs.xml" ];
2018-01-23 09:51:13 +00:00
}
'';
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.
2018-01-23 09:51:13 +00:00
'';
};
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.
'';
};
2018-01-23 09:51:13 +00:00
package = mkOption {
default = pkgs.home-assistant.overrideAttrs (oldAttrs: {
doInstallCheck = false;
});
defaultText = literalExpression ''
pkgs.home-assistant.overrideAttrs (oldAttrs: {
doInstallCheck = false;
})
'';
2018-01-23 09:51:13 +00:00
type = types.package;
example = literalExpression ''
2018-01-23 09:51:13 +00:00
pkgs.home-assistant.override {
extraPackages = python3Packages: with python3Packages; [
psycopg2
];
extraComponents = [
"default_config"
"esphome"
"met"
];
2018-01-23 09:51:13 +00:00
}
'';
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.";
};
2018-01-23 09:51:13 +00:00
};
config = mkIf cfg.enable {
2022-05-03 00:16:03 +00:00
assertions = [
{
assertion = cfg.openFirewall -> cfg.config != null;
2022-05-03 00:16:03 +00:00
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;
})
];
2018-01-23 09:51:13 +00:00
systemd.services.home-assistant = {
description = "Home Assistant";
wants = [ "network-online.target" ];
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 ''
rm -f "${cfg.configDir}/ui-lovelace.yaml"
cp --no-preserve=mode ${lovelaceConfigFile} "${cfg.configDir}/ui-lovelace.yaml"
'' else ''
ln -fs /etc/home-assistant/ui-lovelace.yaml "${cfg.configDir}/ui-lovelace.yaml"
'';
copyCustomLovelaceModules = if cfg.customLovelaceModules != [] then ''
mkdir -p "${cfg.configDir}/www"
ln -fns ${customLovelaceModulesDir} "${cfg.configDir}/www/nixos-lovelace-modules"
'' else ''
rm -f "${cfg.configDir}/www/nixos-lovelace-modules"
'';
copyCustomComponents = ''
mkdir -p "${cfg.configDir}/custom_components"
# remove components symlinked in from below the /nix/store
readarray -d "" components < <(find "${cfg.configDir}/custom_components" -maxdepth 1 -type l -print0)
for component in "''${components[@]}"; do
if [[ "$(readlink "$component")" =~ ^${escapeShellArg builtins.storeDir} ]]; then
rm "$component"
fi
done
# recreate symlinks for desired components
declare -a components=(${escapeShellArgs cfg.customComponents})
for component in "''${components[@]}"; do
path="$(dirname $(find "$component" -name "manifest.json"))"
ln -fns "$path" "${cfg.configDir}/custom_components/"
done
'';
in
(optionalString (cfg.config != null) copyConfig) +
(optionalString (cfg.lovelaceConfig != null) copyLovelaceConfig) +
copyCustomLovelaceModules +
copyCustomComponents
;
environment.PYTHONPATH = package.pythonPath;
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"
"august_ble"
"airthings_ble"
"aranet"
"bluemaestro"
"bluetooth"
"bluetooth_adapters"
"bluetooth_le_tracker"
"bluetooth_tracker"
"bthome"
"default_config"
"eufylife_ble"
"esphome"
"fjaraskupan"
"gardena_bluetooth"
"govee_ble"
"homekit_controller"
"inkbird"
"improv_ble"
"keymitt_ble"
"leaone-ble"
"led_ble"
"medcom_ble"
"melnor"
"moat"
"mopeka"
"oralb"
"private_ble_device"
"qingping"
"rapt_ble"
"ruuvi_gateway"
"ruuvitag_ble"
"sensirion_ble"
"sensorpro"
"sensorpush"
"shelly"
"snooz"
"switchbot"
"thermobeacon"
"thermopro"
"tilt_ble"
"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"
"otbr"
"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";
2018-01-23 09:51:13 +00:00
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";
2021-05-06 14:44:16 +00:00
ProcSubset = "all";
2018-01-23 09:51:13 +00:00
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"
2021-05-06 14:44:16 +00:00
"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"
"setuid"
];
UMask = "0077";
2018-01-23 09:51:13 +00:00
};
path = [
pkgs.unixtools.ping # needed for ping
];
};
systemd.targets.home-assistant = rec {
description = "Home Assistant";
wantedBy = [ "multi-user.target" ];
wants = [ "home-assistant.service" ];
after = wants;
2018-01-23 09:51:13 +00:00
};
users.users.hass = {
2018-01-23 09:51:13 +00:00
home = cfg.configDir;
createHome = true;
group = "hass";
uid = config.ids.uids.hass;
};
users.groups.hass.gid = config.ids.gids.hass;
2018-01-23 09:51:13 +00:00
};
}