HA Declarative Entity Management
SkillDev toolsManages Nix-owned HA automations, scenes, scripts, and entity identity. Use for domain edits, ensureEnabled assertions, or orphan-sweep debugging.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the HA Declarative Entity Management skill
What this skill tells your AI
The instructions your AI receives, as published by edmundmiller/dotfiles in .pi/skills/hass-declarative/SKILL.md and read by ahel’s review.
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 |
Signals
- GitHub stars
- 80
- Forks
- 6
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
hass-declarative- Source
- github.com/edmundmiller/dotfiles