hass-declarative
HA Declarative Entity Management
All HA automations, scenes, and scripts are defined in Nix under
modules/services/hass/_domains/. A post-deploy sweep service removes
anything not in the declared set.
Architecture
_lib.nix ← shared helpers (ensureEnabled)
_domains/ ← domain files (Nix modules)
ambient.nix ← lighting schedules, plant lights
aranet.nix ← CO2 monitoring
conversation.nix ← voice intents
lighting.nix ← adaptive lighting, AL sleep mode
modes.nix ← DND, guest mode, everything_off script
sleep/default.nix ← bedtime flow, wake detection, 8Sleep sync
tv.nix ← TV idle timer, sleep timer
vacation.nix ← presence-based vacation mode
sweep-unmanaged.nix ← extracts declared IDs at build time
sweep-unmanaged.py ← runtime: removes orphans via WS API
_tests/eval-automations.nix ← structural assertions (flake check)
Entity Identity
How HA maps Nix config → entity registry:
| Domain | Nix declaration | Entity ID | Unique ID (registry) |
|---|---|---|---|
| automation | id = "winding_down"; |
automation.winding_down |
Same as id field |
| scene | name = "Good Morning"; |
scene.good_morning |
HA-generated UUID |
| script | script.everything_off = {...}; |
script.everything_off |
Same as attribute key |
Scene slugs: HA lowercases the name and replaces spaces/hyphens with underscores. Keep names ASCII-alphanumeric + spaces to avoid slug surprises.
Adding Entities
Automation
Add to the automation = lib.mkAfter (ensureEnabled [...]) list in the
appropriate domain file. Every automation must have a unique id field
— the sweep service uses it.
Always wrap with ensureEnabled (from _lib.nix) — it injects
initial_state = true so automations re-enable on HA restart. Without it,
HA silently persists "off" state from the entity registry and automations
stay disabled forever. The eval test catches missing wrappers at build time.
{ lib, ... }:
let
inherit (import ../_lib.nix) ensureEnabled;
in {
services.home-assistant.config.automation = lib.mkAfter (ensureEnabled [
{
alias = "Human-Readable Name";
id = "unique_snake_case_id";
description = "What it does";
trigger = { platform = "time"; at = "22:00:00"; };
condition = [];
action = [{ action = "scene.turn_on"; target.entity_id = "scene.foo"; }];
}
]);
}
Individual automations can override with initial_state = false if ever needed
(the // merge gives right-hand precedence), but we never want this in practice.
Scene
Add to scene = lib.mkAfter [...]. Scenes use name as their identity.
{
name = "My Scene";
icon = "mdi:icon-name";
entities = {
"light.some_light" = "on";
"switch.some_switch" = "off";
};
}
Script
Add to script = lib.mkAfter {...} (attrset, not list). The attribute
key becomes the entity_id.
my_script_key = {
alias = "Human Name";
icon = "mdi:icon";
sequence = [{ action = "light.turn_off"; target.entity_id = "light.foo"; }];
};
Or directly on config: script.my_key = {...}; (as in modes.nix).
Removing Entities
- Delete from the domain
.nixfile - Deploy (
hey nuc) hass-sweep-unmanagedservice auto-removes the orphan from HA's entity registry
No manual cleanup needed. Check sweep results:
hey nuc-service hass-sweep-unmanaged
ssh nuc "sudo journalctl -u hass-sweep-unmanaged --no-pager -n 30"
Sweep Service
sweep-unmanaged.nix creates a systemd oneshot that runs after HA starts.
Build time: Evaluates NixOS config to extract:
automation_ids— fromhaConfig.automation[].idscene_entity_ids— fromhaConfig.scene[].name→scene.<slug>script_entity_ids— fromhaConfig.scriptkeys →script.<key>
Writes these to a JSON manifest in the Nix store.
Runtime (sweep-unmanaged.py):
- Waits for HA to be ready (120s timeout)
- Connects via WebSocket, authenticates with
agent-automationJWT - Lists all entity registry entries
- For each
automation.*/scene.*/script.*not in the manifest:- Checks
platformto avoid removing integration-created entities - Removes from entity registry via
config/entity_registry/remove
- Checks
- Wipes UI YAML files (
automations.yaml,scenes.yaml,scripts.yaml)
Platform filtering (safety):
- Automations: only removes
platform = "automation"(YAML-sourced) - Scenes: only removes
platform = "homeassistant"(YAML-sourced) - Scripts: only removes
platform = "script"(YAML-sourced) - Integration-created entities (Ecobee scenes, etc.) are never touched
Eval Test Assertions
_tests/eval-automations.nix runs as nix flake check and pre-commit hook.
Tests structural properties:
- Every automation has
initial_state = true— catches missingensureEnabledwrappers - Required automations/scenes exist
- Time guards present on wake detection (the "4:47 AM fix")
- Good Morning has presence-aware conditions
- Winding Down resets awake booleans
Add assertions when adding automations with critical invariants.
initial_state enforcement
The initial_state assertion is global — it iterates all automations in the
final merged config. No per-automation opt-in needed. If you add an automation
anywhere without ensureEnabled, the build fails:
automation 'My Thing' missing initial_state = true (use ensureEnabled from _lib.nix)
Why this matters: HA's initial_state defaults to "restore from entity
registry." If an automation was ever toggled off in the UI (or the registry
entry drifts), it stays off across restarts — silently. With
configWritable = false, the UI toggle is especially dangerous since there's
no way to re-enable it without redeploying. ensureEnabled + the eval
assertion make this class of bug impossible.
Debugging
# Check what the sweep would do (dry-run)
ssh nuc "sudo systemctl cat hass-sweep-unmanaged" # see ExecStart paths
ssh nuc "sudo /path/to/python3 /path/to/sweep-unmanaged.py /path/to/manifest.json --dry-run"
# View the build-time manifest
nix eval --json '.#nixosConfigurations.nuc.config.services.home-assistant.config.automation' 2>/dev/null | python3 -c "import json,sys; print([a['id'] for a in json.load(sys.stdin) if a.get('id')])"
# Run eval assertions locally
nix build '.#checks.x86_64-linux.ha-automation-assertions' --dry-run
References
| File | Contents |
|---|---|
references/entity-lifecycle.md |
How HA stores entities internally, the .storage files, and what "ghost" entities are |