diff --git a/modules/nixos/services/home-assistant/default.nix b/modules/nixos/services/home-assistant/default.nix index c308c4e..16da12a 100644 --- a/modules/nixos/services/home-assistant/default.nix +++ b/modules/nixos/services/home-assistant/default.nix @@ -1,153 +1,251 @@ { lib, - pkgs, self, + pkgs, config, namespace, ... }: let - inherit (lib) mkEnableOption mkIf types; + inherit (lib) + mkEnableOption + mkIf + mkMerge + types + optionals + ; inherit (self.lib.options) mkOpt; cfg = config.${namespace}.services.home-assistant; - yaml = pkgs.formats.yaml { }; - - settings = yaml.generate "configuration.yaml" { - # Includes dependencies for a basic setup - # https://www.home-assistant.io/integrations/default_config/ - default_config = { }; - - http = { - server_host = "::1"; - trusted_proxies = [ "::1" ]; - use_x_forwarded_for = true; - }; - - "automation ui" = "!include automations.yaml"; - "scene ui" = "!include scenes.yaml"; - "script ui" = "!include scripts.yaml"; + domain = config.${namespace}.services.domain; + fqdn = "${cfg.subdomain}.${domain}"; + + # A single radio speaks either Zigbee or Thread, never both, so the border + # router only exists in thread mode. + threadMode = cfg.radio.mode == "thread"; + otbrDevice = if cfg.otbr.device != null then cfg.otbr.device else cfg.radio.device; + + # Seeded once into the config volume, afterwards Home Assistant owns these files. + seed = { + "configuration.yaml" = pkgs.writeText "configuration.yaml" '' + default_config: + + frontend: + themes: !include_dir_merge_named themes + + # Home Assistant sits behind Traefik on the host network, so the only + # proxy that can ever reach it is loopback. + http: + use_x_forwarded_for: true + trusted_proxies: + - 127.0.0.1 + - ::1 + + automation: !include automations.yaml + script: !include scripts.yaml + scene: !include scenes.yaml + ''; + + "automations.yaml" = pkgs.writeText "automations.yaml" "[]\n"; + "scripts.yaml" = pkgs.writeText "scripts.yaml" "{}\n"; + "scenes.yaml" = pkgs.writeText "scenes.yaml" "[]\n"; }; - - # The YAML generator quotes the custom tags (!include, !secret, ...), which - # makes Home Assistant read them as plain strings. - configuration = pkgs.runCommand "configuration.yaml" { } '' - sed -e "s/'\!\([a-z_]\+\) \(.*\)'/\!\1 \2/;" ${settings} > $out - ''; in { options.${namespace}.services.home-assistant = { - enable = mkEnableOption "Home Assistant, the ultimate home automation service."; - subdomain = mkOpt types.str "home" "The subdomain the service should be exposed on."; + enable = mkEnableOption "Home Assistant, a self-hostable home automation platform."; + + subdomain = + mkOpt types.str "home" + "The subdomain, of the system domain, the service should be exposed on."; + path = mkOpt types.str "/var/lib/containers/home-assistant" "The path this service should use for persistent data."; - openthread-device = - mkOpt types.str "/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_94A990D18A9C-if00" - "The path to your thread border router serial device."; - openthread-interface = mkOpt types.str "enp1s0" "The system network interface shorthand."; - }; - config = mkIf cfg.enable { - services.avahi = { - enable = true; - publish.enable = true; - publish.addresses = true; - publish.workstation = false; - nssmdns4 = true; - }; + version = mkOpt types.str "2026.8.1" "The pinned Home Assistant container tag."; - # Additional required service for thread border router support (e.g. ZBT-2). - # Stays on the host, as it manages the serial radio, host routes and firewall. - services.openthread-border-router = { - enable = true; + radio = { + device = + mkOpt types.str "/dev/zbt-2" + "Stable device node of the ZBT-2 radio, created by the udev rule below."; - radio = { - device = cfg.openthread-device; - baudRate = 460800; - flowControl = false; - }; + mode = mkOpt (types.enum [ "thread" "zigbee" ]) "thread" '' + Which protocol the radio is flashed for. A ZBT-2 cannot serve both at + once. "thread" runs the border router container and pairs Matter over + Thread devices, "zigbee" leaves the stick to the ZHA integration. + ''; - backboneInterfaces = [ cfg.openthread-interface ]; + udevRule = mkOpt types.bool true '' + Create a stable symlink for the Home Assistant Connect ZBT-2 + (USB 303a:831a). Disable if you address the stick by its by-id path. + ''; + }; - rest = { - listenAddress = "::1"; - listenPort = 8081; + matter = { + enable = mkEnableOption "the Matter server companion container." // { + default = true; }; + + version = mkOpt types.str "8.1.2" "The pinned python-matter-server container tag."; + port = mkOpt types.port 5580 "Port the Matter server websocket listens on."; + + bluetoothAdapter = mkOpt (types.nullOr types.int) null '' + hci index used for Bluetooth commissioning of Matter devices. Thread + devices are commissioned over BLE, so this is needed unless you pair + them from a phone. Null disables Bluetooth commissioning. + ''; }; - systemd.tmpfiles.rules = [ - "d ${cfg.path} 0755 root root -" - "d ${cfg.path}/config 0755 root root -" - "d ${cfg.path}/matter 0755 root root -" + otbr = { + version = mkOpt types.str "v0.3.0" "The pinned hass-otbr-docker container tag."; - # Ensure required files from the above config list are actually available - "f ${cfg.path}/config/automations.yaml 0644 root root -" - "f ${cfg.path}/config/scenes.yaml 0644 root root -" - "f ${cfg.path}/config/scripts.yaml 0644 root root -" - ]; + device = mkOpt (types.nullOr types.str) null '' + Radio running OpenThread RCP firmware. Null uses the main radio, set + this only if the border router gets a second, dedicated stick. + ''; - virtualisation.oci-containers.containers = { - home-assistant = { - image = "ghcr.io/home-assistant/home-assistant:2026.8.1"; + # NOTE: The ZBT-2 talks over an ESP32-S3 USB bridge, which has no real + # hardware flow control lines. + baudrate = mkOpt types.int 460800 "Serial baudrate of the RCP firmware."; + flowControl = mkOpt types.bool false "Whether the RCP firmware uses hardware flow control."; + + backboneInterface = + mkOpt types.str "enp1s0" + "Host interface facing the LAN, used as the Thread backbone link."; + + restPort = mkOpt types.port 8081 "REST API port consumed by the OTBR integration."; + webPort = mkOpt types.port 8080 "Port of the OTBR web dashboard."; + }; + }; + + config = mkIf cfg.enable (mkMerge [ + { + systemd.tmpfiles.rules = [ + "d ${cfg.path} 0755 root root -" + "d ${cfg.path}/config 0755 root root -" + "d ${cfg.path}/config/themes 0755 root root -" + ] + ++ lib.mapAttrsToList ( + name: file: "C ${cfg.path}/config/${name} 0644 root root - ${file}" + ) seed; + + # HA does its own mDNS discovery on the host network. + networking.firewall.allowedUDPPorts = [ 5353 ]; + + services.udev.extraRules = lib.mkIf cfg.radio.udevRule '' + SUBSYSTEM=="tty", ATTRS{idVendor}=="303a", ATTRS{idProduct}=="831a", ENV{ID_USB_INTERFACE_NUM}=="00", SYMLINK+="${lib.removePrefix "/dev/" cfg.radio.device}", GROUP="dialout", MODE="0660" + ''; + + virtualisation.oci-containers.containers.home-assistant = { + image = "ghcr.io/home-assistant/home-assistant:${cfg.version}"; + + # The official container docs run Home Assistant privileged on the host + # network: it needs the host /dev for USB radios and mDNS/SSDP for discovery. + privileged = true; + extraOptions = [ "--network=host" ]; volumes = [ "${cfg.path}/config:/config" - "${configuration}:/config/configuration.yaml:ro" "/run/dbus:/run/dbus:ro" - "/etc/localtime:/etc/localtime:ro" ]; environment = { - "TZ" = "${config.time.timeZone}"; + "TZ" = config.time.timeZone; }; + }; - # Host networking is required for device discovery (mDNS, SSDP) and to - # reach the border router and matter server on ::1 - extraOptions = [ "--network=host" ]; - - dependsOn = [ "matter-server" ]; + services.traefik.dynamicConfigOptions.http = { + services.home-assistant.loadBalancer.servers = [ { url = "http://localhost:8123"; } ]; + routers.home-assistant = { + entryPoints = [ "websecure" ]; + service = "home-assistant"; + rule = "Host(`${fqdn}`)"; + }; }; + } - # Additional required service for Matter support - matter-server = { - image = "ghcr.io/matter-js/python-matter-server:8.1.2"; + (mkIf cfg.matter.enable { + systemd.tmpfiles.rules = [ "d ${cfg.path}/matter 0755 root root -" ]; + + # Matter operational discovery and commissioning. + networking.firewall.allowedUDPPorts = [ 5540 ]; + + virtualisation.oci-containers.containers.matter-server = { + image = "ghcr.io/matter-js/python-matter-server:${cfg.matter.version}"; + + # Host network is required, Matter relies on mDNS and IPv6 link-local. + extraOptions = [ + "--network=host" + "--security-opt=apparmor=unconfined" + ]; volumes = [ "${cfg.path}/matter:/data" "/run/dbus:/run/dbus:ro" ]; - environment = { - "TZ" = "${config.time.timeZone}"; - }; - cmd = [ "--storage-path" "/data" "--paa-root-cert-dir" "/data/credentials" - "--log-level" - "debug" - ]; - - extraOptions = [ - "--network=host" - "--security-opt=apparmor=unconfined" + "--port" + (toString cfg.matter.port) + ] + ++ optionals (cfg.matter.bluetoothAdapter != null) [ + "--bluetooth-adapter" + (toString cfg.matter.bluetoothAdapter) ]; }; - }; + }) + + (mkIf threadMode { + systemd.tmpfiles.rules = [ "d ${cfg.path}/otbr 0755 root root -" ]; + + # A border router routes between the Thread mesh and the LAN, so the host + # has to forward IPv6 and accept the more-specific routes the mesh + # advertises back onto the backbone. + boot.kernel.sysctl = { + "net.ipv6.conf.all.disable_ipv6" = 0; + "net.ipv4.conf.all.forwarding" = 1; + "net.ipv6.conf.all.forwarding" = 1; + "net.ipv6.conf.all.accept_ra" = 2; + "net.ipv6.conf.all.accept_ra_rt_info_max_plen" = 64; + }; - services.traefik.dynamicConfigOptions = { - http = { - services.home-assistant.loadBalancer.servers = [ { url = "http://localhost:8123"; } ]; - routers.home-assistant = { - entryPoints = [ "websecure" ]; - service = "home-assistant"; - rule = "Host(`${cfg.subdomain}.${config.${namespace}.services.domain}`)"; + networking.firewall.allowedTCPPorts = [ + cfg.otbr.restPort + cfg.otbr.webPort + ]; + + virtualisation.oci-containers.containers.otbr = { + image = "ghcr.io/ownbee/hass-otbr-docker:${cfg.otbr.version}"; + + extraOptions = [ "--network=host" ]; + capabilities.NET_ADMIN = true; + capabilities.NET_RAW = true; + + devices = [ "${otbrDevice}:/dev/ttyACM0" ]; + volumes = [ "${cfg.path}/otbr:/var/lib/thread" ]; + + environment = { + "DEVICE" = "/dev/ttyACM0"; + "BAUDRATE" = toString cfg.otbr.baudrate; + "FLOW_CONTROL" = if cfg.otbr.flowControl then "1" else "0"; + "BACKBONE_IF" = cfg.otbr.backboneInterface; + "OTBR_REST_PORT" = toString cfg.otbr.restPort; + "OTBR_WEB_PORT" = toString cfg.otbr.webPort; + "OTBR_WEB" = "1"; + "OTBR_LOG_LEVEL" = "notice"; + "FIREWALL" = "1"; + "NAT64" = "1"; + + # Only ever flashed deliberately, and this cannot flash a ZBT-2 anyway. + "AUTOFLASH_FIRMWARE" = "0"; }; }; - }; - }; + }) + ]); } diff --git a/systems/x86_64-nixos/absolutesolver/default.nix b/systems/x86_64-nixos/absolutesolver/default.nix index 31cc0bb..4bc9344 100644 --- a/systems/x86_64-nixos/absolutesolver/default.nix +++ b/systems/x86_64-nixos/absolutesolver/default.nix @@ -64,6 +64,7 @@ home-assistant = { enable = true; + path = "/mnt/storage/active/docker/volumes/home-assistant"; }; }; };