diff --git a/nixos/doc/manual/release-notes/rl-2311.section.md b/nixos/doc/manual/release-notes/rl-2311.section.md index f6359e5c341d..a3dff6f90412 100644 --- a/nixos/doc/manual/release-notes/rl-2311.section.md +++ b/nixos/doc/manual/release-notes/rl-2311.section.md @@ -513,6 +513,8 @@ The module update takes care of the new config syntax and the data itself (user - `services.bitcoind` now properly respects the `enable` option. +- The Home Assistant module now offers support for installing custom components and lovelace modules. Available at [`services.home-assistant.customComponents`](#opt-services.home-assistant.customComponents) and [`services.home-assistant.customLovelaceModules`](#opt-services.home-assistant.customLovelaceModules). + ## Nixpkgs internals {#sec-release-23.11-nixpkgs-internals} - The use of `sourceRoot = "source";`, `sourceRoot = "source/subdir";`, and similar lines in package derivations using the default `unpackPhase` is deprecated as it requires `unpackPhase` to always produce a directory named "source". Use `sourceRoot = src.name`, `sourceRoot = "${src.name}/subdir";`, or `setSourceRoot = "sourceRoot=$(echo */subdir)";` or similar instead. diff --git a/nixos/modules/services/home-automation/home-assistant.nix b/nixos/modules/services/home-automation/home-assistant.nix index 789b06af19b1..54fd3e17292f 100644 --- a/nixos/modules/services/home-automation/home-assistant.nix +++ b/nixos/modules/services/home-automation/home-assistant.nix @@ -16,7 +16,8 @@ let cp ${format.generate "configuration.yaml" filteredConfig} $out sed -i -e "s/'\!\([a-z_]\+\) \(.*\)'/\!\1 \2/;s/^\!\!/\!/;" $out ''; - lovelaceConfig = cfg.lovelaceConfig or {}; + lovelaceConfig = if (cfg.lovelaceConfig == null) then {} + else (lib.recursiveUpdate customLovelaceModulesResources cfg.lovelaceConfig); lovelaceConfigFile = format.generate "ui-lovelace.yaml" lovelaceConfig; # Components advertised by the home-assistant package @@ -62,8 +63,24 @@ let # 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); + 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; + }; in { imports = [ # Migrations in NixOS 22.05 @@ -137,6 +154,41 @@ in { ''; }; + 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; @@ -408,9 +460,35 @@ in { rm -f "${cfg.configDir}/ui-lovelace.yaml" ln -s /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 + components="$(find "${cfg.configDir}/custom_components" -maxdepth 1 -type l)" + 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) + (optionalString (cfg.lovelaceConfig != null) copyLovelaceConfig) + + copyCustomLovelaceModules + + copyCustomComponents ; environment.PYTHONPATH = package.pythonPath; serviceConfig = let diff --git a/nixos/tests/home-assistant.nix b/nixos/tests/home-assistant.nix index b7deb95b2c19..e97e8a467b18 100644 --- a/nixos/tests/home-assistant.nix +++ b/nixos/tests/home-assistant.nix @@ -43,6 +43,16 @@ in { psycopg2 ]; + # test loading custom components + customComponents = with pkgs.home-assistant-custom-components; [ + prometheus-sensor + ]; + + # test loading lovelace modules + customLovelaceModules = with pkgs.home-assistant-custom-lovelace-modules; [ + mini-graph-card + ]; + config = { homeassistant = { name = "Home"; @@ -114,6 +124,14 @@ in { inheritParentConfig = true; configuration.services.home-assistant.config.backup = {}; }; + + specialisation.removeCustomThings = { + inheritParentConfig = true; + configuration.services.home-assistant = { + customComponents = lib.mkForce []; + customLovelaceModules = lib.mkForce []; + }; + }; }; testScript = { nodes, ... }: let @@ -161,6 +179,14 @@ in { hass.wait_for_open_port(8123) hass.succeed("curl --fail http://localhost:8123/lovelace") + with subtest("Check that custom components get installed"): + hass.succeed("test -f ${configDir}/custom_components/prometheus_sensor/manifest.json") + hass.wait_until_succeeds("journalctl -u home-assistant.service | grep -q 'We found a custom integration prometheus_sensor which has not been tested by Home Assistant'") + + with subtest("Check that lovelace modules are referenced and fetchable"): + hass.succeed("grep -q 'mini-graph-card-bundle.js' '${configDir}/ui-lovelace.yaml'") + hass.succeed("curl --fail http://localhost:8123/local/nixos-lovelace-modules/mini-graph-card-bundle.js") + with subtest("Check that optional dependencies are in the PYTHONPATH"): env = get_unit_property("Environment") python_path = env.split("PYTHONPATH=")[1].split()[0] @@ -200,6 +226,13 @@ in { for domain in ["backup"]: assert f"Setup of domain {domain} took" in journal, f"{domain} setup missing" + with subtest("Check custom components and custom lovelace modules get removed"): + cursor = get_journal_cursor() + hass.succeed("${system}/specialisation/removeCustomThings/bin/switch-to-configuration test") + hass.fail("grep -q 'mini-graph-card-bundle.js' '${configDir}/ui-lovelace.yaml'") + hass.fail("test -f ${configDir}/custom_components/prometheus_sensor/manifest.json") + wait_for_homeassistant(cursor) + with subtest("Check that no errors were logged"): hass.fail("journalctl -u home-assistant -o cat | grep -q ERROR") diff --git a/pkgs/servers/home-assistant/build-custom-component/check_manifest.py b/pkgs/servers/home-assistant/build-custom-component/check_manifest.py new file mode 100644 index 000000000000..bbe9535824e7 --- /dev/null +++ b/pkgs/servers/home-assistant/build-custom-component/check_manifest.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + +import json +import importlib_metadata +import sys + +from packaging.requirements import Requirement + + +def check_requirement(req: str): + # https://packaging.pypa.io/en/stable/requirements.html + requirement = Requirement(req) + try: + version = importlib_metadata.distribution(requirement.name).version + except importlib_metadata.PackageNotFoundError: + print(f" - Dependency {requirement.name} is missing", file=sys.stderr) + return False + + # https://packaging.pypa.io/en/stable/specifiers.html + if not version in requirement.specifier: + print( + f" - {requirement.name}{requirement.specifier} expected, but got {version}", + file=sys.stderr, + ) + return False + + return True + + +def check_manifest(manifest_file: str): + with open(manifest_file) as fd: + manifest = json.load(fd) + if "requirements" in manifest: + ok = True + for requirement in manifest["requirements"]: + ok &= check_requirement(requirement) + if not ok: + print("Manifest requirements are not met", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + raise RuntimeError(f"Usage {sys.argv[0]} ") + manifest_file = sys.argv[1] + check_manifest(manifest_file) diff --git a/pkgs/servers/home-assistant/build-custom-component/default.nix b/pkgs/servers/home-assistant/build-custom-component/default.nix new file mode 100644 index 000000000000..05b7c2d4b039 --- /dev/null +++ b/pkgs/servers/home-assistant/build-custom-component/default.nix @@ -0,0 +1,38 @@ +{ lib +, home-assistant +, makeSetupHook +}: + +{ pname +, version +, format ? "other" +, ... +}@args: + +let + manifestRequirementsCheckHook = import ./manifest-requirements-check-hook.nix { + inherit makeSetupHook; + inherit (home-assistant) python; + }; +in +home-assistant.python.pkgs.buildPythonPackage ( + { + inherit format; + + installPhase = '' + runHook preInstall + + mkdir $out + cp -r $src/custom_components/ $out/ + + runHook postInstall + ''; + + nativeCheckInputs = with home-assistant.python.pkgs; [ + importlib-metadata + manifestRequirementsCheckHook + packaging + ] ++ (args.nativeCheckInputs or []); + + } // builtins.removeAttrs args [ "nativeCheckInputs" ] +) diff --git a/pkgs/servers/home-assistant/build-custom-component/manifest-requirements-check-hook.nix b/pkgs/servers/home-assistant/build-custom-component/manifest-requirements-check-hook.nix new file mode 100644 index 000000000000..76317c9d0bc8 --- /dev/null +++ b/pkgs/servers/home-assistant/build-custom-component/manifest-requirements-check-hook.nix @@ -0,0 +1,11 @@ +{ python +, makeSetupHook +}: + +makeSetupHook { + name = "manifest-requirements-check-hook"; + substitutions = { + pythonCheckInterpreter = python.interpreter; + checkManifest = ./check_manifest.py; + }; +} ./manifest-requirements-check-hook.sh diff --git a/pkgs/servers/home-assistant/build-custom-component/manifest-requirements-check-hook.sh b/pkgs/servers/home-assistant/build-custom-component/manifest-requirements-check-hook.sh new file mode 100644 index 000000000000..74f29ca399ed --- /dev/null +++ b/pkgs/servers/home-assistant/build-custom-component/manifest-requirements-check-hook.sh @@ -0,0 +1,25 @@ +# Setup hook to check HA manifest requirements +echo "Sourcing manifest-requirements-check-hook" + +function manifestCheckPhase() { + echo "Executing manifestCheckPhase" + runHook preCheck + + manifests=$(shopt -s nullglob; echo $out/custom_components/*/manifest.json) + + if [ ! -z "$manifests" ]; then + echo Checking manifests $manifests + @pythonCheckInterpreter@ @checkManifest@ $manifests + else + echo "No custom component manifests found in $out" >&2 + exit 1 + fi + + runHook postCheck + echo "Finished executing manifestCheckPhase" +} + +if [ -z "${dontCheckManifest-}" ] && [ -z "${installCheckPhase-}" ]; then + echo "Using manifestCheckPhase" + preDistPhases+=" manifestCheckPhase" +fi diff --git a/pkgs/servers/home-assistant/custom-components/README.md b/pkgs/servers/home-assistant/custom-components/README.md new file mode 100644 index 000000000000..a7244b25c173 --- /dev/null +++ b/pkgs/servers/home-assistant/custom-components/README.md @@ -0,0 +1,57 @@ +# Packaging guidelines + +## buildHomeAssistantComponent + +Custom components should be packaged using the + `buildHomeAssistantComponent` function, that is provided at top-level. +It builds upon `buildPythonPackage` but uses a custom install and check +phase. + +Python runtime dependencies can be directly consumed as unqualified +function arguments. Pass them into `propagatedBuildInputs`, for them to +be available to Home Assistant. + +Out-of-tree components need to use python packages from +`home-assistant.python.pkgs` as to not introduce conflicting package +versions into the Python environment. + + +**Example Boilerplate:** + +```nix +{ lib +, buildHomeAssistantcomponent +, fetchFromGitHub +}: + +buildHomeAssistantComponent { + # pname, version + + src = fetchFromGithub { + # owner, repo, rev, hash + }; + + propagatedBuildInputs = [ + # python requirements, as specified in manifest.json + ]; + + meta = with lib; { + # changelog, description, homepage, license, maintainers + } +} + +## Package name normalization + +Apply the same normalization rules as defined for python packages in +[PEP503](https://peps.python.org/pep-0503/#normalized-names). +The name should be lowercased and dots, underlines or multiple +dashes should all be replaced by a single dash. + +## Manifest check + +The `buildHomeAssistantComponent` builder uses a hook to check whether +the dependencies specified in the `manifest.json` are present and +inside the specified version range. + +There shouldn't be a need to disable this hook, but you can set +`dontCheckManifest` to `true` in the derivation to achieve that. diff --git a/pkgs/servers/home-assistant/custom-components/default.nix b/pkgs/servers/home-assistant/custom-components/default.nix new file mode 100644 index 000000000000..4a96b305964a --- /dev/null +++ b/pkgs/servers/home-assistant/custom-components/default.nix @@ -0,0 +1,6 @@ +{ callPackage +}: + +{ + prometheus-sensor = callPackage ./prometheus-sensor {}; +} diff --git a/pkgs/servers/home-assistant/custom-components/prometheus-sensor/default.nix b/pkgs/servers/home-assistant/custom-components/prometheus-sensor/default.nix new file mode 100644 index 000000000000..07bcd9abec1c --- /dev/null +++ b/pkgs/servers/home-assistant/custom-components/prometheus-sensor/default.nix @@ -0,0 +1,26 @@ +{ lib +, fetchFromGitHub +, buildHomeAssistantComponent +}: + +buildHomeAssistantComponent rec { + pname = "prometheus-sensor"; + version = "1.0.0"; + + src = fetchFromGitHub { + owner = "mweinelt"; + repo = "ha-prometheus-sensor"; + rev = "refs/tags/${version}"; + hash = "sha256-10COLFXvmpm8ONLyx5c0yiQdtuP0SC2NKq/ZYHro9II="; + }; + + dontBuild = true; + + meta = with lib; { + changelog = "https://github.com/mweinelt/ha-prometheus-sensor/blob/${version}/CHANGELOG.md"; + description = "Import prometheus query results into Home Assistant"; + homepage = "https://github.com/mweinelt/ha-prometheus-sensor"; + maintainers = with maintainers; [ hexa ]; + license = licenses.mit; + }; +} diff --git a/pkgs/servers/home-assistant/custom-lovelace-modules/README.md b/pkgs/servers/home-assistant/custom-lovelace-modules/README.md new file mode 100644 index 000000000000..b67fd0fb91d8 --- /dev/null +++ b/pkgs/servers/home-assistant/custom-lovelace-modules/README.md @@ -0,0 +1,13 @@ +# Packaging guidelines + +## Entrypoint + +Every lovelace module has an entrypoint in the form of a `.js` file. By +default the nixos module will try to load `${pname}.js` when a module is +configured. + +The entrypoint used can be overridden in `passthru` like this: + +```nix +passthru.entrypoint = "demo-card-bundle.js"; +``` diff --git a/pkgs/servers/home-assistant/custom-lovelace-modules/default.nix b/pkgs/servers/home-assistant/custom-lovelace-modules/default.nix new file mode 100644 index 000000000000..4bb1e63b5f7f --- /dev/null +++ b/pkgs/servers/home-assistant/custom-lovelace-modules/default.nix @@ -0,0 +1,8 @@ +{ callPackage +}: + +{ + mini-graph-card = callPackage ./mini-graph-card {}; + + mini-media-player = callPackage ./mini-media-player {}; +} diff --git a/pkgs/servers/home-assistant/custom-lovelace-modules/mini-graph-card/default.nix b/pkgs/servers/home-assistant/custom-lovelace-modules/mini-graph-card/default.nix new file mode 100644 index 000000000000..60942d5f4ed2 --- /dev/null +++ b/pkgs/servers/home-assistant/custom-lovelace-modules/mini-graph-card/default.nix @@ -0,0 +1,38 @@ +{ lib +, buildNpmPackage +, fetchFromGitHub +}: + +buildNpmPackage rec { + pname = "mini-graph-card"; + version = "0.11.0"; + + src = fetchFromGitHub { + owner = "kalkih"; + repo = "mini-graph-card"; + rev = "refs/tags/v${version}"; + hash = "sha256-AC4VawRtWTeHbFqDJ6oQchvUu08b4F3ManiPPXpyGPc="; + }; + + npmDepsHash = "sha256-0ErOTkcCnMqMTsTkVL320SxZaET/izFj9GiNWC2tQtQ="; + + installPhase = '' + runHook preInstall + + mkdir $out + cp -v dist/mini-graph-card-bundle.js $out/ + + runHook postInstall + ''; + + passthru.entrypoint = "mini-graph-card-bundle.js"; + + meta = with lib; { + changelog = "https://github.com/kalkih/mini-graph-card/releases/tag/v${version}"; + description = "Minimalistic graph card for Home Assistant Lovelace UI"; + homepage = "https://github.com/kalkih/mini-graph-card"; + maintainers = with maintainers; [ hexa ]; + license = licenses.mit; + }; +} + diff --git a/pkgs/servers/home-assistant/custom-lovelace-modules/mini-media-player/default.nix b/pkgs/servers/home-assistant/custom-lovelace-modules/mini-media-player/default.nix new file mode 100644 index 000000000000..6945b18bde20 --- /dev/null +++ b/pkgs/servers/home-assistant/custom-lovelace-modules/mini-media-player/default.nix @@ -0,0 +1,37 @@ +{ lib +, buildNpmPackage +, fetchFromGitHub +}: + +buildNpmPackage rec { + pname = "mini-media-player"; + version = "1.16.5"; + + src = fetchFromGitHub { + owner = "kalkih"; + repo = "mini-media-player"; + rev = "v${version}"; + hash = "sha256-ydkY7Qx2GMh4CpvvBAQubJ7PlxSscDZRJayn82bOczM="; + }; + + npmDepsHash = "sha256-v9NvZOrQPMOoG3LKACnu79jKgZtcnGiopWad+dFbplw="; + + installPhase = '' + runHook preInstall + + mkdir $out + cp -v ./dist/mini-media-player-bundle.js $out/ + + runHook postInstall + ''; + + passthru.entrypoint = "mini-media-player-bundle.js"; + + meta = with lib; { + changelog = "https://github.com/kalkih/mini-media-player/releases/tag/v${version}"; + description = "Minimalistic media card for Home Assistant Lovelace UI"; + homepage = "https://github.com/kalkih/mini-media-player"; + license = licenses.mit; + maintainers = with maintainers; [ hexa ]; + }; +} diff --git a/pkgs/servers/home-assistant/default.nix b/pkgs/servers/home-assistant/default.nix index c0adb6bb4bac..08d005313915 100644 --- a/pkgs/servers/home-assistant/default.nix +++ b/pkgs/servers/home-assistant/default.nix @@ -393,6 +393,10 @@ in python.pkgs.buildPythonApplication rec { # leave this in, so users don't have to constantly update their downstream patch handling patches = [ + # Follow symlinks in /var/lib/hass/www + ./patches/static-symlinks.patch + + # Patch path to ffmpeg binary (substituteAll { src = ./patches/ffmpeg-path.patch; ffmpeg = "${lib.getBin ffmpeg-headless}/bin/ffmpeg"; diff --git a/pkgs/servers/home-assistant/patches/static-symlinks.patch b/pkgs/servers/home-assistant/patches/static-symlinks.patch new file mode 100644 index 000000000000..7784a60f6b2a --- /dev/null +++ b/pkgs/servers/home-assistant/patches/static-symlinks.patch @@ -0,0 +1,37 @@ +diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py +index 2ec991750f..9a937006ce 100644 +--- a/homeassistant/components/frontend/__init__.py ++++ b/homeassistant/components/frontend/__init__.py +@@ -383,7 +383,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + + local = hass.config.path("www") + if os.path.isdir(local): +- hass.http.register_static_path("/local", local, not is_dev) ++ hass.http.register_static_path("/local", local, not is_dev, follow_symlinks=True) + + # Can be removed in 2023 + hass.http.register_redirect("/config/server_control", "/developer-tools/yaml") +diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py +index 122b7b79ce..3cf2b7e0db 100644 +--- a/homeassistant/components/http/__init__.py ++++ b/homeassistant/components/http/__init__.py +@@ -411,16 +411,16 @@ class HomeAssistantHTTP: + ) + + def register_static_path( +- self, url_path: str, path: str, cache_headers: bool = True ++ self, url_path: str, path: str, cache_headers: bool = True, follow_symlinks: bool = False + ) -> None: + """Register a folder or file to serve as a static path.""" + if os.path.isdir(path): + if cache_headers: + resource: CachingStaticResource | web.StaticResource = ( +- CachingStaticResource(url_path, path) ++ CachingStaticResource(url_path, path, follow_symlinks=follow_symlinks) + ) + else: +- resource = web.StaticResource(url_path, path) ++ resource = web.StaticResource(url_path, path, follow_symlinks=follow_symlinks) + self.app.router.register_resource(resource) + self.app["allow_configured_cors"](resource) + return diff --git a/pkgs/top-level/all-packages.nix b/pkgs/top-level/all-packages.nix index 6dcfedc6b2f5..8e2eefb8a0e7 100644 --- a/pkgs/top-level/all-packages.nix +++ b/pkgs/top-level/all-packages.nix @@ -26472,6 +26472,14 @@ with pkgs; home-assistant = callPackage ../servers/home-assistant { }; + buildHomeAssistantComponent = callPackage ../servers/home-assistant/build-custom-component { }; + home-assistant-custom-components = lib.recurseIntoAttrs + (callPackage ../servers/home-assistant/custom-components { + inherit (home-assistant.python.pkgs) callPackage; + }); + home-assistant-custom-lovelace-modules = lib.recurseIntoAttrs + (callPackage ../servers/home-assistant/custom-lovelace-modules {}); + home-assistant-cli = callPackage ../servers/home-assistant/cli.nix { }; home-assistant-component-tests = recurseIntoAttrs home-assistant.tests.components;