summary refs log tree commit diff
diff options
context:
space:
mode:
authorMartin Weinelt <mweinelt@users.noreply.github.com>2023-11-11 00:08:25 +0100
committerGitHub <noreply@github.com>2023-11-11 00:08:25 +0100
commit35362217029be9ac33b5a2186929c0adaee42bb6 (patch)
treec3862b0b88771db476178fecc7c77bf1d3c13f11
parentb526e494ccd18f85ed5a0ab89291b66d1ae3b11a (diff)
parent6d05ad6a6ba5e9c4399c13c7fb18c423f74030c2 (diff)
downloadnixpkgs-35362217029be9ac33b5a2186929c0adaee42bb6.tar
nixpkgs-35362217029be9ac33b5a2186929c0adaee42bb6.tar.gz
nixpkgs-35362217029be9ac33b5a2186929c0adaee42bb6.tar.bz2
nixpkgs-35362217029be9ac33b5a2186929c0adaee42bb6.tar.lz
nixpkgs-35362217029be9ac33b5a2186929c0adaee42bb6.tar.xz
nixpkgs-35362217029be9ac33b5a2186929c0adaee42bb6.tar.zst
nixpkgs-35362217029be9ac33b5a2186929c0adaee42bb6.zip
Merge pull request #160346 from mweinelt/hass-custom-everything
home-assistant: custom components and lovelace modules
-rw-r--r--nixos/doc/manual/release-notes/rl-2311.section.md2
-rw-r--r--nixos/modules/services/home-automation/home-assistant.nix84
-rw-r--r--nixos/tests/home-assistant.nix33
-rw-r--r--pkgs/servers/home-assistant/build-custom-component/check_manifest.py46
-rw-r--r--pkgs/servers/home-assistant/build-custom-component/default.nix38
-rw-r--r--pkgs/servers/home-assistant/build-custom-component/manifest-requirements-check-hook.nix11
-rw-r--r--pkgs/servers/home-assistant/build-custom-component/manifest-requirements-check-hook.sh25
-rw-r--r--pkgs/servers/home-assistant/custom-components/README.md57
-rw-r--r--pkgs/servers/home-assistant/custom-components/default.nix6
-rw-r--r--pkgs/servers/home-assistant/custom-components/prometheus-sensor/default.nix26
-rw-r--r--pkgs/servers/home-assistant/custom-lovelace-modules/README.md13
-rw-r--r--pkgs/servers/home-assistant/custom-lovelace-modules/default.nix8
-rw-r--r--pkgs/servers/home-assistant/custom-lovelace-modules/mini-graph-card/default.nix38
-rw-r--r--pkgs/servers/home-assistant/custom-lovelace-modules/mini-media-player/default.nix37
-rw-r--r--pkgs/servers/home-assistant/default.nix4
-rw-r--r--pkgs/servers/home-assistant/patches/static-symlinks.patch37
-rw-r--r--pkgs/top-level/all-packages.nix8
17 files changed, 470 insertions, 3 deletions
diff --git a/nixos/doc/manual/release-notes/rl-2311.section.md b/nixos/doc/manual/release-notes/rl-2311.section.md
index f6359e5c341..a3dff6f9041 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 789b06af19b..54fd3e17292 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 b7deb95b2c1..e97e8a467b1 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 00000000000..bbe9535824e
--- /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>")
+    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 00000000000..05b7c2d4b03
--- /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 00000000000..76317c9d0bc
--- /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 00000000000..74f29ca399e
--- /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 00000000000..a7244b25c17
--- /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 00000000000..4a96b305964
--- /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 00000000000..07bcd9abec1
--- /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 00000000000..b67fd0fb91d
--- /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 00000000000..4bb1e63b5f7
--- /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 00000000000..60942d5f4ed
--- /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 00000000000..6945b18bde2
--- /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 c0adb6bb4ba..08d00531391 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 00000000000..7784a60f6b2
--- /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 6dcfedc6b2f..8e2eefb8a0e 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;