lol

Merge pull request #160346 from mweinelt/hass-custom-everything

home-assistant: custom components and lovelace modules

authored by

Martin Weinelt and committed by
GitHub
35362217 b526e494

+470 -3
+2
nixos/doc/manual/release-notes/rl-2311.section.md
··· 513 513 514 514 - `services.bitcoind` now properly respects the `enable` option. 515 515 516 + - 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). 517 + 516 518 ## Nixpkgs internals {#sec-release-23.11-nixpkgs-internals} 517 519 518 520 - 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.
+81 -3
nixos/modules/services/home-automation/home-assistant.nix
··· 16 16 cp ${format.generate "configuration.yaml" filteredConfig} $out 17 17 sed -i -e "s/'\!\([a-z_]\+\) \(.*\)'/\!\1 \2/;s/^\!\!/\!/;" $out 18 18 ''; 19 - lovelaceConfig = cfg.lovelaceConfig or {}; 19 + lovelaceConfig = if (cfg.lovelaceConfig == null) then {} 20 + else (lib.recursiveUpdate customLovelaceModulesResources cfg.lovelaceConfig); 20 21 lovelaceConfigFile = format.generate "ui-lovelace.yaml" lovelaceConfig; 21 22 22 23 # Components advertised by the home-assistant package ··· 62 63 # Respect overrides that already exist in the passed package and 63 64 # concat it with values passed via the module. 64 65 extraComponents = oldArgs.extraComponents or [] ++ extraComponents; 65 - extraPackages = ps: (oldArgs.extraPackages or (_: []) ps) ++ (cfg.extraPackages ps); 66 + extraPackages = ps: (oldArgs.extraPackages or (_: []) ps) 67 + ++ (cfg.extraPackages ps) 68 + ++ (lib.concatMap (component: component.propagatedBuildInputs or []) cfg.customComponents); 66 69 })); 70 + 71 + # Create a directory that holds all lovelace modules 72 + customLovelaceModulesDir = pkgs.buildEnv { 73 + name = "home-assistant-custom-lovelace-modules"; 74 + paths = cfg.customLovelaceModules; 75 + }; 76 + 77 + # Create parts of the lovelace config that reference lovelave modules as resources 78 + customLovelaceModulesResources = { 79 + lovelace.resources = map (card: { 80 + url = "/local/nixos-lovelace-modules/${card.entrypoint or card.pname}.js?${card.version}"; 81 + type = "module"; 82 + }) cfg.customLovelaceModules; 83 + }; 67 84 in { 68 85 imports = [ 69 86 # Migrations in NixOS 22.05 ··· 134 151 135 152 A popular example is `python3Packages.psycopg2` 136 153 for PostgreSQL support in the recorder component. 154 + ''; 155 + }; 156 + 157 + customComponents = mkOption { 158 + type = types.listOf types.package; 159 + default = []; 160 + example = literalExpression '' 161 + with pkgs.home-assistant-custom-components; [ 162 + prometheus-sensor 163 + ]; 164 + ''; 165 + description = lib.mdDoc '' 166 + List of custom component packages to install. 167 + 168 + Available components can be found below `pkgs.home-assistant-custom-components`. 169 + ''; 170 + }; 171 + 172 + customLovelaceModules = mkOption { 173 + type = types.listOf types.package; 174 + default = []; 175 + example = literalExpression '' 176 + with pkgs.home-assistant-custom-lovelace-modules; [ 177 + mini-graph-card 178 + mini-media-player 179 + ]; 180 + ''; 181 + description = lib.mdDoc '' 182 + List of custom lovelace card packages to load as lovelace resources. 183 + 184 + Available cards can be found below `pkgs.home-assistant-custom-lovelace-modules`. 185 + 186 + ::: {.note} 187 + Automatic loading only works with lovelace in `yaml` mode. 188 + ::: 137 189 ''; 138 190 }; 139 191 ··· 408 460 rm -f "${cfg.configDir}/ui-lovelace.yaml" 409 461 ln -s /etc/home-assistant/ui-lovelace.yaml "${cfg.configDir}/ui-lovelace.yaml" 410 462 ''; 463 + copyCustomLovelaceModules = if cfg.customLovelaceModules != [] then '' 464 + mkdir -p "${cfg.configDir}/www" 465 + ln -fns ${customLovelaceModulesDir} "${cfg.configDir}/www/nixos-lovelace-modules" 466 + '' else '' 467 + rm -f "${cfg.configDir}/www/nixos-lovelace-modules" 468 + ''; 469 + copyCustomComponents = '' 470 + mkdir -p "${cfg.configDir}/custom_components" 471 + 472 + # remove components symlinked in from below the /nix/store 473 + components="$(find "${cfg.configDir}/custom_components" -maxdepth 1 -type l)" 474 + for component in "$components"; do 475 + if [[ "$(readlink "$component")" =~ ^${escapeShellArg builtins.storeDir} ]]; then 476 + rm "$component" 477 + fi 478 + done 479 + 480 + # recreate symlinks for desired components 481 + declare -a components=(${escapeShellArgs cfg.customComponents}) 482 + for component in "''${components[@]}"; do 483 + path="$(dirname $(find "$component" -name "manifest.json"))" 484 + ln -fns "$path" "${cfg.configDir}/custom_components/" 485 + done 486 + ''; 411 487 in 412 488 (optionalString (cfg.config != null) copyConfig) + 413 - (optionalString (cfg.lovelaceConfig != null) copyLovelaceConfig) 489 + (optionalString (cfg.lovelaceConfig != null) copyLovelaceConfig) + 490 + copyCustomLovelaceModules + 491 + copyCustomComponents 414 492 ; 415 493 environment.PYTHONPATH = package.pythonPath; 416 494 serviceConfig = let
+33
nixos/tests/home-assistant.nix
··· 43 43 psycopg2 44 44 ]; 45 45 46 + # test loading custom components 47 + customComponents = with pkgs.home-assistant-custom-components; [ 48 + prometheus-sensor 49 + ]; 50 + 51 + # test loading lovelace modules 52 + customLovelaceModules = with pkgs.home-assistant-custom-lovelace-modules; [ 53 + mini-graph-card 54 + ]; 55 + 46 56 config = { 47 57 homeassistant = { 48 58 name = "Home"; ··· 114 124 inheritParentConfig = true; 115 125 configuration.services.home-assistant.config.backup = {}; 116 126 }; 127 + 128 + specialisation.removeCustomThings = { 129 + inheritParentConfig = true; 130 + configuration.services.home-assistant = { 131 + customComponents = lib.mkForce []; 132 + customLovelaceModules = lib.mkForce []; 133 + }; 134 + }; 117 135 }; 118 136 119 137 testScript = { nodes, ... }: let ··· 161 179 hass.wait_for_open_port(8123) 162 180 hass.succeed("curl --fail http://localhost:8123/lovelace") 163 181 182 + with subtest("Check that custom components get installed"): 183 + hass.succeed("test -f ${configDir}/custom_components/prometheus_sensor/manifest.json") 184 + 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'") 185 + 186 + with subtest("Check that lovelace modules are referenced and fetchable"): 187 + hass.succeed("grep -q 'mini-graph-card-bundle.js' '${configDir}/ui-lovelace.yaml'") 188 + hass.succeed("curl --fail http://localhost:8123/local/nixos-lovelace-modules/mini-graph-card-bundle.js") 189 + 164 190 with subtest("Check that optional dependencies are in the PYTHONPATH"): 165 191 env = get_unit_property("Environment") 166 192 python_path = env.split("PYTHONPATH=")[1].split()[0] ··· 199 225 journal = get_journal_since(cursor) 200 226 for domain in ["backup"]: 201 227 assert f"Setup of domain {domain} took" in journal, f"{domain} setup missing" 228 + 229 + with subtest("Check custom components and custom lovelace modules get removed"): 230 + cursor = get_journal_cursor() 231 + hass.succeed("${system}/specialisation/removeCustomThings/bin/switch-to-configuration test") 232 + hass.fail("grep -q 'mini-graph-card-bundle.js' '${configDir}/ui-lovelace.yaml'") 233 + hass.fail("test -f ${configDir}/custom_components/prometheus_sensor/manifest.json") 234 + wait_for_homeassistant(cursor) 202 235 203 236 with subtest("Check that no errors were logged"): 204 237 hass.fail("journalctl -u home-assistant -o cat | grep -q ERROR")
+46
pkgs/servers/home-assistant/build-custom-component/check_manifest.py
··· 1 + #!/usr/bin/env python3 2 + 3 + import json 4 + import importlib_metadata 5 + import sys 6 + 7 + from packaging.requirements import Requirement 8 + 9 + 10 + def check_requirement(req: str): 11 + # https://packaging.pypa.io/en/stable/requirements.html 12 + requirement = Requirement(req) 13 + try: 14 + version = importlib_metadata.distribution(requirement.name).version 15 + except importlib_metadata.PackageNotFoundError: 16 + print(f" - Dependency {requirement.name} is missing", file=sys.stderr) 17 + return False 18 + 19 + # https://packaging.pypa.io/en/stable/specifiers.html 20 + if not version in requirement.specifier: 21 + print( 22 + f" - {requirement.name}{requirement.specifier} expected, but got {version}", 23 + file=sys.stderr, 24 + ) 25 + return False 26 + 27 + return True 28 + 29 + 30 + def check_manifest(manifest_file: str): 31 + with open(manifest_file) as fd: 32 + manifest = json.load(fd) 33 + if "requirements" in manifest: 34 + ok = True 35 + for requirement in manifest["requirements"]: 36 + ok &= check_requirement(requirement) 37 + if not ok: 38 + print("Manifest requirements are not met", file=sys.stderr) 39 + sys.exit(1) 40 + 41 + 42 + if __name__ == "__main__": 43 + if len(sys.argv) < 2: 44 + raise RuntimeError(f"Usage {sys.argv[0]} <manifest>") 45 + manifest_file = sys.argv[1] 46 + check_manifest(manifest_file)
+38
pkgs/servers/home-assistant/build-custom-component/default.nix
··· 1 + { lib 2 + , home-assistant 3 + , makeSetupHook 4 + }: 5 + 6 + { pname 7 + , version 8 + , format ? "other" 9 + , ... 10 + }@args: 11 + 12 + let 13 + manifestRequirementsCheckHook = import ./manifest-requirements-check-hook.nix { 14 + inherit makeSetupHook; 15 + inherit (home-assistant) python; 16 + }; 17 + in 18 + home-assistant.python.pkgs.buildPythonPackage ( 19 + { 20 + inherit format; 21 + 22 + installPhase = '' 23 + runHook preInstall 24 + 25 + mkdir $out 26 + cp -r $src/custom_components/ $out/ 27 + 28 + runHook postInstall 29 + ''; 30 + 31 + nativeCheckInputs = with home-assistant.python.pkgs; [ 32 + importlib-metadata 33 + manifestRequirementsCheckHook 34 + packaging 35 + ] ++ (args.nativeCheckInputs or []); 36 + 37 + } // builtins.removeAttrs args [ "nativeCheckInputs" ] 38 + )
+11
pkgs/servers/home-assistant/build-custom-component/manifest-requirements-check-hook.nix
··· 1 + { python 2 + , makeSetupHook 3 + }: 4 + 5 + makeSetupHook { 6 + name = "manifest-requirements-check-hook"; 7 + substitutions = { 8 + pythonCheckInterpreter = python.interpreter; 9 + checkManifest = ./check_manifest.py; 10 + }; 11 + } ./manifest-requirements-check-hook.sh
+25
pkgs/servers/home-assistant/build-custom-component/manifest-requirements-check-hook.sh
··· 1 + # Setup hook to check HA manifest requirements 2 + echo "Sourcing manifest-requirements-check-hook" 3 + 4 + function manifestCheckPhase() { 5 + echo "Executing manifestCheckPhase" 6 + runHook preCheck 7 + 8 + manifests=$(shopt -s nullglob; echo $out/custom_components/*/manifest.json) 9 + 10 + if [ ! -z "$manifests" ]; then 11 + echo Checking manifests $manifests 12 + @pythonCheckInterpreter@ @checkManifest@ $manifests 13 + else 14 + echo "No custom component manifests found in $out" >&2 15 + exit 1 16 + fi 17 + 18 + runHook postCheck 19 + echo "Finished executing manifestCheckPhase" 20 + } 21 + 22 + if [ -z "${dontCheckManifest-}" ] && [ -z "${installCheckPhase-}" ]; then 23 + echo "Using manifestCheckPhase" 24 + preDistPhases+=" manifestCheckPhase" 25 + fi
+57
pkgs/servers/home-assistant/custom-components/README.md
··· 1 + # Packaging guidelines 2 + 3 + ## buildHomeAssistantComponent 4 + 5 + Custom components should be packaged using the 6 + `buildHomeAssistantComponent` function, that is provided at top-level. 7 + It builds upon `buildPythonPackage` but uses a custom install and check 8 + phase. 9 + 10 + Python runtime dependencies can be directly consumed as unqualified 11 + function arguments. Pass them into `propagatedBuildInputs`, for them to 12 + be available to Home Assistant. 13 + 14 + Out-of-tree components need to use python packages from 15 + `home-assistant.python.pkgs` as to not introduce conflicting package 16 + versions into the Python environment. 17 + 18 + 19 + **Example Boilerplate:** 20 + 21 + ```nix 22 + { lib 23 + , buildHomeAssistantcomponent 24 + , fetchFromGitHub 25 + }: 26 + 27 + buildHomeAssistantComponent { 28 + # pname, version 29 + 30 + src = fetchFromGithub { 31 + # owner, repo, rev, hash 32 + }; 33 + 34 + propagatedBuildInputs = [ 35 + # python requirements, as specified in manifest.json 36 + ]; 37 + 38 + meta = with lib; { 39 + # changelog, description, homepage, license, maintainers 40 + } 41 + } 42 + 43 + ## Package name normalization 44 + 45 + Apply the same normalization rules as defined for python packages in 46 + [PEP503](https://peps.python.org/pep-0503/#normalized-names). 47 + The name should be lowercased and dots, underlines or multiple 48 + dashes should all be replaced by a single dash. 49 + 50 + ## Manifest check 51 + 52 + The `buildHomeAssistantComponent` builder uses a hook to check whether 53 + the dependencies specified in the `manifest.json` are present and 54 + inside the specified version range. 55 + 56 + There shouldn't be a need to disable this hook, but you can set 57 + `dontCheckManifest` to `true` in the derivation to achieve that.
+6
pkgs/servers/home-assistant/custom-components/default.nix
··· 1 + { callPackage 2 + }: 3 + 4 + { 5 + prometheus-sensor = callPackage ./prometheus-sensor {}; 6 + }
+26
pkgs/servers/home-assistant/custom-components/prometheus-sensor/default.nix
··· 1 + { lib 2 + , fetchFromGitHub 3 + , buildHomeAssistantComponent 4 + }: 5 + 6 + buildHomeAssistantComponent rec { 7 + pname = "prometheus-sensor"; 8 + version = "1.0.0"; 9 + 10 + src = fetchFromGitHub { 11 + owner = "mweinelt"; 12 + repo = "ha-prometheus-sensor"; 13 + rev = "refs/tags/${version}"; 14 + hash = "sha256-10COLFXvmpm8ONLyx5c0yiQdtuP0SC2NKq/ZYHro9II="; 15 + }; 16 + 17 + dontBuild = true; 18 + 19 + meta = with lib; { 20 + changelog = "https://github.com/mweinelt/ha-prometheus-sensor/blob/${version}/CHANGELOG.md"; 21 + description = "Import prometheus query results into Home Assistant"; 22 + homepage = "https://github.com/mweinelt/ha-prometheus-sensor"; 23 + maintainers = with maintainers; [ hexa ]; 24 + license = licenses.mit; 25 + }; 26 + }
+13
pkgs/servers/home-assistant/custom-lovelace-modules/README.md
··· 1 + # Packaging guidelines 2 + 3 + ## Entrypoint 4 + 5 + Every lovelace module has an entrypoint in the form of a `.js` file. By 6 + default the nixos module will try to load `${pname}.js` when a module is 7 + configured. 8 + 9 + The entrypoint used can be overridden in `passthru` like this: 10 + 11 + ```nix 12 + passthru.entrypoint = "demo-card-bundle.js"; 13 + ```
+8
pkgs/servers/home-assistant/custom-lovelace-modules/default.nix
··· 1 + { callPackage 2 + }: 3 + 4 + { 5 + mini-graph-card = callPackage ./mini-graph-card {}; 6 + 7 + mini-media-player = callPackage ./mini-media-player {}; 8 + }
+38
pkgs/servers/home-assistant/custom-lovelace-modules/mini-graph-card/default.nix
··· 1 + { lib 2 + , buildNpmPackage 3 + , fetchFromGitHub 4 + }: 5 + 6 + buildNpmPackage rec { 7 + pname = "mini-graph-card"; 8 + version = "0.11.0"; 9 + 10 + src = fetchFromGitHub { 11 + owner = "kalkih"; 12 + repo = "mini-graph-card"; 13 + rev = "refs/tags/v${version}"; 14 + hash = "sha256-AC4VawRtWTeHbFqDJ6oQchvUu08b4F3ManiPPXpyGPc="; 15 + }; 16 + 17 + npmDepsHash = "sha256-0ErOTkcCnMqMTsTkVL320SxZaET/izFj9GiNWC2tQtQ="; 18 + 19 + installPhase = '' 20 + runHook preInstall 21 + 22 + mkdir $out 23 + cp -v dist/mini-graph-card-bundle.js $out/ 24 + 25 + runHook postInstall 26 + ''; 27 + 28 + passthru.entrypoint = "mini-graph-card-bundle.js"; 29 + 30 + meta = with lib; { 31 + changelog = "https://github.com/kalkih/mini-graph-card/releases/tag/v${version}"; 32 + description = "Minimalistic graph card for Home Assistant Lovelace UI"; 33 + homepage = "https://github.com/kalkih/mini-graph-card"; 34 + maintainers = with maintainers; [ hexa ]; 35 + license = licenses.mit; 36 + }; 37 + } 38 +
+37
pkgs/servers/home-assistant/custom-lovelace-modules/mini-media-player/default.nix
··· 1 + { lib 2 + , buildNpmPackage 3 + , fetchFromGitHub 4 + }: 5 + 6 + buildNpmPackage rec { 7 + pname = "mini-media-player"; 8 + version = "1.16.5"; 9 + 10 + src = fetchFromGitHub { 11 + owner = "kalkih"; 12 + repo = "mini-media-player"; 13 + rev = "v${version}"; 14 + hash = "sha256-ydkY7Qx2GMh4CpvvBAQubJ7PlxSscDZRJayn82bOczM="; 15 + }; 16 + 17 + npmDepsHash = "sha256-v9NvZOrQPMOoG3LKACnu79jKgZtcnGiopWad+dFbplw="; 18 + 19 + installPhase = '' 20 + runHook preInstall 21 + 22 + mkdir $out 23 + cp -v ./dist/mini-media-player-bundle.js $out/ 24 + 25 + runHook postInstall 26 + ''; 27 + 28 + passthru.entrypoint = "mini-media-player-bundle.js"; 29 + 30 + meta = with lib; { 31 + changelog = "https://github.com/kalkih/mini-media-player/releases/tag/v${version}"; 32 + description = "Minimalistic media card for Home Assistant Lovelace UI"; 33 + homepage = "https://github.com/kalkih/mini-media-player"; 34 + license = licenses.mit; 35 + maintainers = with maintainers; [ hexa ]; 36 + }; 37 + }
+4
pkgs/servers/home-assistant/default.nix
··· 393 393 394 394 # leave this in, so users don't have to constantly update their downstream patch handling 395 395 patches = [ 396 + # Follow symlinks in /var/lib/hass/www 397 + ./patches/static-symlinks.patch 398 + 399 + # Patch path to ffmpeg binary 396 400 (substituteAll { 397 401 src = ./patches/ffmpeg-path.patch; 398 402 ffmpeg = "${lib.getBin ffmpeg-headless}/bin/ffmpeg";
+37
pkgs/servers/home-assistant/patches/static-symlinks.patch
··· 1 + diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py 2 + index 2ec991750f..9a937006ce 100644 3 + --- a/homeassistant/components/frontend/__init__.py 4 + +++ b/homeassistant/components/frontend/__init__.py 5 + @@ -383,7 +383,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 6 + 7 + local = hass.config.path("www") 8 + if os.path.isdir(local): 9 + - hass.http.register_static_path("/local", local, not is_dev) 10 + + hass.http.register_static_path("/local", local, not is_dev, follow_symlinks=True) 11 + 12 + # Can be removed in 2023 13 + hass.http.register_redirect("/config/server_control", "/developer-tools/yaml") 14 + diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py 15 + index 122b7b79ce..3cf2b7e0db 100644 16 + --- a/homeassistant/components/http/__init__.py 17 + +++ b/homeassistant/components/http/__init__.py 18 + @@ -411,16 +411,16 @@ class HomeAssistantHTTP: 19 + ) 20 + 21 + def register_static_path( 22 + - self, url_path: str, path: str, cache_headers: bool = True 23 + + self, url_path: str, path: str, cache_headers: bool = True, follow_symlinks: bool = False 24 + ) -> None: 25 + """Register a folder or file to serve as a static path.""" 26 + if os.path.isdir(path): 27 + if cache_headers: 28 + resource: CachingStaticResource | web.StaticResource = ( 29 + - CachingStaticResource(url_path, path) 30 + + CachingStaticResource(url_path, path, follow_symlinks=follow_symlinks) 31 + ) 32 + else: 33 + - resource = web.StaticResource(url_path, path) 34 + + resource = web.StaticResource(url_path, path, follow_symlinks=follow_symlinks) 35 + self.app.router.register_resource(resource) 36 + self.app["allow_configured_cors"](resource) 37 + return
+8
pkgs/top-level/all-packages.nix
··· 26472 26472 26473 26473 home-assistant = callPackage ../servers/home-assistant { }; 26474 26474 26475 + buildHomeAssistantComponent = callPackage ../servers/home-assistant/build-custom-component { }; 26476 + home-assistant-custom-components = lib.recurseIntoAttrs 26477 + (callPackage ../servers/home-assistant/custom-components { 26478 + inherit (home-assistant.python.pkgs) callPackage; 26479 + }); 26480 + home-assistant-custom-lovelace-modules = lib.recurseIntoAttrs 26481 + (callPackage ../servers/home-assistant/custom-lovelace-modules {}); 26482 + 26475 26483 home-assistant-cli = callPackage ../servers/home-assistant/cli.nix { }; 26476 26484 26477 26485 home-assistant-component-tests = recurseIntoAttrs home-assistant.tests.components;