1{
2 lib,
3 ...
4}:
5
6let
7 configDir = "/var/lib/foobar";
8in
9{
10 name = "home-assistant";
11 meta.maintainers = lib.teams.home-assistant.members;
12
13 nodes.hass =
14 { pkgs, ... }:
15 {
16 services.postgresql = {
17 enable = true;
18 ensureDatabases = [ "hass" ];
19 ensureUsers = [
20 {
21 name = "hass";
22 ensureDBOwnership = true;
23 }
24 ];
25 };
26
27 services.home-assistant = {
28 enable = true;
29 inherit configDir;
30
31 # provide dependencies through package overrides
32 package = (
33 pkgs.home-assistant.override {
34 extraPackages =
35 ps: with ps; [
36 colorama
37 ];
38 extraComponents = [
39 # test char-tty device allow propagation into the service
40 "zha"
41 ];
42 }
43 );
44
45 # provide component dependencies explicitly from the module
46 extraComponents = [
47 "mqtt"
48 ];
49
50 # provide package for postgresql support
51 extraPackages =
52 python3Packages: with python3Packages; [
53 psycopg2
54 ];
55
56 # test loading custom components
57 customComponents = with pkgs.home-assistant-custom-components; [
58 prometheus_sensor
59 # tests loading multiple components from a single package
60 spook
61 ];
62
63 # test loading lovelace modules
64 customLovelaceModules = with pkgs.home-assistant-custom-lovelace-modules; [
65 mini-graph-card
66 ];
67
68 config = {
69 homeassistant = {
70 name = "Home";
71 time_zone = "UTC";
72 latitude = "0.0";
73 longitude = "0.0";
74 elevation = 0;
75 };
76
77 # configure the recorder component to use the postgresql db
78 recorder.db_url = "postgresql://@/hass";
79
80 # we can't load default_config, because the updater requires
81 # network access and would cause an error, so load frontend
82 # here explicitly.
83 # https://www.home-assistant.io/integrations/frontend/
84 frontend = { };
85
86 # include some popular integrations, that absolutely shouldn't break
87 knx = { };
88 zha = { };
89
90 # set up a wake-on-lan switch to test capset capability required
91 # for the ping suid wrapper
92 # https://www.home-assistant.io/integrations/wake_on_lan/
93 switch = [
94 {
95 platform = "wake_on_lan";
96 mac = "00:11:22:33:44:55";
97 host = "127.0.0.1";
98 }
99 ];
100
101 # test component-based capability assignment (CAP_NET_BIND_SERVICE)
102 # https://www.home-assistant.io/integrations/emulated_hue/
103 emulated_hue = {
104 host_ip = "127.0.0.1";
105 listen_port = 80;
106 };
107
108 # https://www.home-assistant.io/integrations/logger/
109 logger = {
110 default = "debug";
111 };
112 };
113
114 # configure the sample lovelace dashboard
115 lovelaceConfig = {
116 title = "My Awesome Home";
117 views = [
118 {
119 title = "Example";
120 cards = [
121 {
122 type = "markdown";
123 title = "Lovelace";
124 content = "Welcome to your **Lovelace UI**.";
125 }
126 ];
127 }
128 ];
129 };
130 lovelaceConfigWritable = true;
131 };
132
133 # Add blueprints next, because we want to test the installation of the default blueprints first
134 specialisation.addBlueprints = {
135 inheritParentConfig = true;
136 configuration.services.home-assistant = {
137 blueprints.automation = [
138 (pkgs.fetchurl {
139 url = "https://github.com/home-assistant/core/raw/2025.1.4/homeassistant/components/automation/blueprints/motion_light.yaml";
140 hash = "sha256-4HrDX65ycBMfEY2nZ7A25/d3ZnIHdpHZ+80Cblp+P5w=";
141 })
142 ];
143 blueprints.template = [
144 "${pkgs.home-assistant.src}/homeassistant/components/template/blueprints/inverted_binary_sensor.yaml"
145 ];
146 };
147 };
148
149 # Cause a configuration change inside `configuration.yml` and verify that the process is being reloaded.
150 specialisation.differentName = {
151 inheritParentConfig = true;
152 configuration.services.home-assistant.config.homeassistant.name = lib.mkForce "Test Home";
153 };
154
155 # Cause a configuration change that requires a service restart as we added a new runtime dependency
156 specialisation.newFeature = {
157 inheritParentConfig = true;
158 configuration.services.home-assistant.config.prometheus = { };
159 };
160
161 specialisation.removeCustomThings = {
162 inheritParentConfig = true;
163 configuration.services.home-assistant = {
164 customComponents = lib.mkForce [ ];
165 customLovelaceModules = lib.mkForce [ ];
166 blueprints.automation = lib.mkForce [ ];
167 blueprints.template = lib.mkForce [ ];
168 };
169 };
170 };
171
172 testScript =
173 { nodes, ... }:
174 let
175 system = nodes.hass.system.build.toplevel;
176 in
177 ''
178 import json
179
180 start_all()
181
182
183 def get_journal_cursor() -> str:
184 exit, out = hass.execute("journalctl -u home-assistant.service -n1 -o json-pretty --output-fields=__CURSOR")
185 assert exit == 0
186 return json.loads(out)["__CURSOR"]
187
188
189 def get_journal_since(cursor) -> str:
190 exit, out = hass.execute(f"journalctl --after-cursor='{cursor}' -u home-assistant.service")
191 assert exit == 0
192 return out
193
194
195 def get_unit_property(property) -> str:
196 exit, out = hass.execute(f"systemctl show --property={property} home-assistant.service")
197 assert exit == 0
198 return out
199
200
201 def wait_for_homeassistant(cursor):
202 hass.wait_until_succeeds(f"journalctl --after-cursor='{cursor}' -u home-assistant.service | grep -q 'Home Assistant initialized in'")
203
204
205 hass.wait_for_unit("home-assistant.service")
206 cursor = get_journal_cursor()
207
208 with subtest("Check that YAML configuration file is in place"):
209 hass.succeed("test -L ${configDir}/configuration.yaml")
210
211 with subtest("Check the lovelace config is copied because lovelaceConfigWritable = true"):
212 hass.succeed("test -f ${configDir}/ui-lovelace.yaml")
213
214 with subtest("Check that Home Assistant's web interface and API can be reached"):
215 wait_for_homeassistant(cursor)
216 hass.wait_for_open_port(8123)
217 hass.succeed("curl --fail http://localhost:8123/lovelace")
218
219 with subtest("Check that custom components get installed"):
220 hass.succeed("test -f ${configDir}/custom_components/prometheus_sensor/manifest.json")
221 for integration in ("prometheus_sensor", "spook", "spook_inverse"):
222 hass.wait_until_succeeds(f"journalctl -u home-assistant.service | grep -q 'We found a custom integration {integration} which has not been tested by Home Assistant'")
223
224 with subtest("Check that lovelace modules are referenced and fetchable"):
225 hass.succeed("grep -q 'mini-graph-card-bundle.js' '${configDir}/configuration.yaml'")
226 hass.succeed("curl --fail http://localhost:8123/local/nixos-lovelace-modules/mini-graph-card-bundle.js")
227
228 with subtest("Check that optional dependencies are in the PYTHONPATH"):
229 env = get_unit_property("Environment")
230 python_path = env.split("PYTHONPATH=")[1].split()[0]
231 for package in ["colorama", "paho-mqtt", "psycopg2"]:
232 assert package in python_path, f"{package} not in PYTHONPATH"
233
234 with subtest("Check that declaratively configured components get setup"):
235 journal = get_journal_since(cursor)
236 for domain in ["emulated_hue", "wake_on_lan"]:
237 assert f"Setup of domain {domain} took" in journal, f"{domain} setup missing"
238
239 with subtest("Check that capabilities are passed for emulated_hue to bind to port 80"):
240 hass.wait_for_open_port(80)
241 hass.succeed("curl --fail http://localhost:80/description.xml")
242
243 with subtest("Check extra components are considered in systemd unit hardening"):
244 hass.succeed("systemctl show -p DeviceAllow home-assistant.service | grep -q char-ttyUSB")
245
246 with subtest("Check that default blueprints are copied writable"):
247 hass.succeed("stat -c '%a' ${configDir}/blueprints/automation/homeassistant | grep 700")
248 hass.succeed("stat -c '%a' ${configDir}/blueprints/automation/homeassistant/motion_light.yaml | grep 600")
249 # Delete blueprints, so we can check the declarative setup next
250 hass.execute("rm -rf ${configDir}/blueprints")
251
252 with subtest("Check that configured blueprints are installed"):
253 cursor = get_journal_cursor()
254 hass.succeed("${system}/specialisation/addBlueprints/bin/switch-to-configuration test")
255 wait_for_homeassistant(cursor)
256 hass.succeed("test -L '${configDir}/blueprints/automation/motion_light.yaml'")
257 hass.succeed("test -L '${configDir}/blueprints/template/inverted_binary_sensor.yaml'")
258
259 with subtest("Check service restart from SIGHUP"):
260 pid = hass.succeed("systemctl show --property=MainPID home-assistant.service")
261 cursor = get_journal_cursor()
262 hass.succeed("${system}/specialisation/differentName/bin/switch-to-configuration test")
263 wait_for_homeassistant(cursor)
264 new_pid = hass.succeed("systemctl show --property=MainPID home-assistant.service")
265 assert pid != new_pid, "The PID of the process must change after sending SIGHUP"
266
267 with subtest("Check service restarts when dependencies change"):
268 pid = new_pid
269 cursor = get_journal_cursor()
270 hass.succeed("${system}/specialisation/newFeature/bin/switch-to-configuration test")
271 wait_for_homeassistant(cursor)
272 new_pid = hass.succeed("systemctl show --property=MainPID home-assistant.service")
273 assert pid != new_pid, "The PID of the process must change when its PYTHONPATH changess"
274
275 with subtest("Check that new components get setup after restart"):
276 journal = get_journal_since(cursor)
277 for domain in ["prometheus"]:
278 assert f"Setup of domain {domain} took" in journal, f"{domain} setup missing"
279
280 with subtest("Check custom components, custom lovelace modules, and blueprints get removed"):
281 cursor = get_journal_cursor()
282 hass.succeed("${system}/specialisation/removeCustomThings/bin/switch-to-configuration test")
283 hass.fail("grep -q 'mini-graph-card-bundle.js' '${configDir}/ui-lovelace.yaml'")
284 for integration in ("prometheus_sensor", "spook", "spook_inverse"):
285 hass.fail(f"test -f ${configDir}/custom_components/{integration}/manifest.json")
286 hass.fail("test -e '${configDir}/blueprints/automation/motion_light.yaml'")
287 hass.fail("test -e '${configDir}/blueprints/template/inverted_binary_sensor.yaml'")
288 wait_for_homeassistant(cursor)
289
290 with subtest("Check that no errors were logged"):
291 hass.fail("journalctl -u home-assistant -o cat | grep -q ERROR")
292
293 with subtest("Check systemd unit hardening"):
294 hass.log(hass.succeed("systemctl cat home-assistant.service"))
295 hass.log(hass.succeed("systemd-analyze security home-assistant.service"))
296 '';
297}