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

home-assistant: custom components and lovelace modules
This commit is contained in:
Martin Weinelt 2023-11-11 00:08:25 +01:00 committed by GitHub
commit 3536221702
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 470 additions and 3 deletions

View File

@ -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.

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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" ]
)

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -0,0 +1,6 @@
{ callPackage
}:
{
prometheus-sensor = callPackage ./prometheus-sensor {};
}

View File

@ -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;
};
}

View File

@ -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";
```

View File

@ -0,0 +1,8 @@
{ callPackage
}:
{
mini-graph-card = callPackage ./mini-graph-card {};
mini-media-player = callPackage ./mini-media-player {};
}

View File

@ -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;
};
}

View File

@ -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 ];
};
}

View File

@ -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";

View File

@ -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

View File

@ -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;