Exterior Lights Renderer — Design (Phase 1)¶
Date: 2026-07-13 Status: Proposed Scope: Unify all exterior (outdoor) lighting under one area-driven renderer keyed to civil dusk / civil dawn, retiring the two per-fixture renderers.
Problem / Motivation¶
Outdoor lighting is currently split across two independent renderers with hand-maintained entity lists and fixed-clock/edge quirks:
script.string_lights_render(§9) — ownsswitch.string_lights: on at sunset+30m, off wheninput_boolean.sleeping.script.front_exterior_render(§10) — ownslight.front_porch_light/light.front_flood_light(sunset→midnight) andlight.front_watch_light(dusk→dawn), driven bysunevent triggers.
Two problems: (1) adding an outdoor light means editing a renderer's hardcoded list; (2) the sun event triggers fire a beat before sun.sun flips state, which turned the watch light off at the exact sunset edge (fixed 2026-07-12 by switching to sun.sun state triggers — see that commit).
Goal: one renderer that commands every light in the exterior areas (auto-including lights added later), on a civil dusk → civil dawn schedule, with the backyard turning off at bedtime.
Scope¶
Areas (subset of the exterior floor)¶
The exterior floor contains front_exterior, side_exterior, backyard, and garage + crawl_space. The last two are indoor/utility and are excluded. The renderer targets exactly:
front_exteriorside_exterior(currently empty — placeholder for future lights)backyard("rear exterior")
Entity selection (auto + tiny allowlist)¶
Per area, the target set is computed at runtime (not hardcoded):
- Every
light.*entity in the area — auto-includes future bulbs / Inovelli dimmers (they all expose alight.entity). This is the "future lights in the area are picked up automatically" goal. - Plus an explicit switch allowlist of switch-based light loads — currently just
switch.string_lights(backyard). Adding a future switch-light = one entry.
This deliberately excludes the ~21 Inovelli config sub-switches (*_aux_switch_scenes, *_firmware_progress_led, *_invert_switch, …) and the plug status LED — none are real lights, and they can't be distinguished from light loads by area+domain alone. switch.back_yard_smart_plug and switch.tp_link_smart_plug_8a4f_plug_2 are excluded (not confirmed lights).
Mixed light+switch lists are commanded via homeassistant.turn_on / homeassistant.turn_off, which dispatch per entity's domain.
(All selection/dedup templates below were render-tested live against the instance on 2026-07-13.)
Behavior (Phase 1)¶
Two logical groups, one renderer:
| Group | Members | On | Off |
|---|---|---|---|
| Security | light.* in front_exterior + side_exterior (today: porch, watch, flood) | civil dusk | civil dawn (all night) |
| Backyard | light.* in backyard + switch.string_lights | civil dusk | when you go to bed |
- Civil dusk / dawn =
sun.sunelevationcrossing −6° (civil twilight). Renderer reads the live elevation, so it is correct at any HA-start time and spans midnight naturally. - "Go to bed" =
input_boolean.sleepingturningon(canonical house-asleep flag; set by Good Night — auto on phone-charging + bedroom presence 10pm–4am, or manual). - No wake-relight: once you've gone to bed, the backyard stays off for the rest of the night even if you wake before civil dawn. Enforced by a latch (below).
The backyard bedtime latch¶
New helper input_boolean.backyard_bedtime_latch (in configuration.yaml, no initial: so it persists across restarts):
- Set
onwheninput_boolean.sleeping→on. - Cleared
offat the civil-dusk edge (elevation crosses −6° downward).
Backyard "should be on" = civil_night AND not sleeping AND not latch. So: on at dusk (latch just cleared) → off at bedtime (sleeping on / latch set) → stays off through a pre-dawn wake (latch still set) → back on at the next civil dusk (latch cleared).
Architecture¶
script.exterior_lights_render (new, scripts.yaml, mode: restart)¶
Computes the two target sets and each group's desired state, then commands only the entities in the wrong state (state-dedup via expand() + selectattr('state', …)), so the periodic tick is cheap and self-healing:
- variables:
civil_night: "{{ (state_attr('sun.sun','elevation') | float(90)) <= -6 }}"
block_backyard: "{{ is_state('input_boolean.sleeping','on') or is_state('input_boolean.backyard_bedtime_latch','on') }}"
security: "{{ (['front_exterior','side_exterior'] | map('area_entities') | sum(start=[])) | select('match','^light\\.') | list }}"
backyard: "{{ (area_entities('backyard') | select('match','^light\\.') | list) + ['switch.string_lights'] }}"
# Security → dusk..dawn ; Backyard → dusk..bedtime. For each group, turn on the wrong-state
# members if desired-on, else turn off the wrong-state members. Guard empty lists.
- Desired security =
civil_night. Desired backyard =civil_night and not block_backyard. - Turn-on set =
expand(group) | rejectattr('state','eq','on') | map(attribute='entity_id') | list(includesoff/unavailable→ a returning flaky switch self-heals on the next tick). - Turn-off set =
expand(group) | selectattr('state','eq','on') | map(attribute='entity_id') | list. - Skip the service call when the set is empty (avoids "no entities" errors, e.g. empty
side_exterior).
automation.exterior_lights_render_triggers (new, automations.yaml, mode: queued)¶
Triggers (with ids), maintains the latch, then renders:
numeric_statesun.sunelevationbelow −6 (iddusk) → clear latch, render.numeric_statesun.sunelevationabove −6 (iddawn) → render.stateinput_boolean.sleepingto on (idsleep_on) → set latch, render.stateinput_boolean.sleepingto off → render.time_patternevery 30 min (idtick) → render (self-heal against Inovelliunavailableflaps). Gated (#89) byinput_boolean.exterior_lights_self_heal(on by default, noinitial:so the choice persists across restarts via restore_state): when off, anif … stopbails theticktrigger only, so the poll can be disabled from the dashboard without affecting the scheduled dusk/dawn/sleeping/start renders. Set the helper on once after the first deploy (it starts off on first-ever creation).homeassistantstart→ render.
Latch management is a choose on trigger.id (set on sleep_on, clear on dusk), then the self-heal gate, before calling script.exterior_lights_render.
Retired¶
script.string_lights_render+automation.string_lights_render_triggers(§9).script.front_exterior_render+automation.front_exterior_render_triggers(§10).
Behavior changes vs. today (accepted; "nuance later")¶
- String lights: no longer off-when-asleep via a bare
sleepingcheck — now backyard-group, on civil-dusk → bedtime (functionally similar, but latched against pre-dawn relight). - Porch + flood: lose their midnight cutoff — now dusk → dawn (all night, as security).
- Timing: on/off shifts from sunset to civil dusk (~20–30 min later) and the morning edge from sunrise to civil dawn.
scene.outdoor_lights_on/offand thewe_are_leaving"leave outdoor fixtures alone" note: the renderer now owns these lights and re-asserts every ~30 min, so those manual scenes get overridden within half an hour. Phase 1 treats them as vestigial; a manual-override flag (like the ambiance lamps'*_manual) is the clean Phase 2 way to restore ad-hoc control.
Deferred (Phase 2, not in this spec)¶
- Manual-override input_boolean so
scene.outdoor_lights_on/off/ party mode can hold the lights. - Any per-light refinements (e.g. flood on motion, brightness/scene by phase).
- Re-evaluating whether front/side should also dim or reduce late at night.
Testing¶
- Tier 0 yamllint; Tier 1
check_config(validates the new script/automation + numeric_state/attribute triggers). - Tier 2/3 pytest: unique ids/aliases; after adding the new script/automation + the
input_boolean.backyard_bedtime_latchhelper, runmake snapshotso the entity-ref test passes. - Live render-test (already done for the selection/dedup/elevation templates) + verify
homeassistant.turn_ondispatches a mixed light+switch list at implementation time. - Manual acceptance: force
sun.suncontext isn't possible, but verify the render script by calling it directly and checking each group's on/off set matchescivil_night/block_backyard; confirm the latch sets onsleeping→onand clears at the dusk trigger.
Risks / Notes¶
- Template runtime:
check_configvalidates schema only; the area-expansion,select('match',…), andexpand()+selectattrdedup were render-tested live (2026-07-13). Re-test after any edit. numeric_stateonsun.sunelevation:sun.sunupdates its attributes roughly every 1–2 min, so the dusk/dawn edges fire within ~a minute of the true −6° crossing — fine for exterior lights.- Inovelli
unavailableflapping (root cause of the 2026-07-12 watch-light bug) is mitigated by the 30-min tick + state-dedup: a switch that dropped offline gets re-commanded once it returns. - CI: no new
!includepackage file (script →scripts.yaml, automation →automations.yaml, helper →configuration.yaml), so noci.ymlHA_FILES/regex change is needed. - Docs to update on implementation:
docs/lighting.md,docs/automations.md(§9/§10 → new section),CLAUDE.mdautomation index, and the retired renderers' references.
Follow-up — manual override (2026-07-21)¶
The Phase-2 "manual-override flag" noted throughout this design was implemented. input_boolean.exterior_lights_manual (previously inert) is now honored by automation.exterior_lights_render_triggers: while it is on, the renderer stands down on every trigger except the civil dusk/dawn edges, which clear the flag and reclaim the schedule (so a manual on/off sticks until the next day/night boundary; script.wake_house also clears it). Three template switches — switch.outside_lights, switch.backyard_lights, switch.front_flood_lights — set the flag and replaced scene.outdoor_lights_on/off (removed), delivering #90. Full design: docs/superpowers/specs/2026-07-21-outside-lights-alexa-switches-design.md.
Superseded detail (2026-07-24): the −6° civil-dusk/dawn constant described above is no longer hardcoded. The on/off boundary is now
input_number.exterior_lights_elevation, a dashboard-tunable sun elevation defaulting to 0° (sunset) — −6° came on too late. Both edges read the same helper, and thenumeric_state-on-elevation-attribute mechanism is unchanged. See2026-07-24-exterior-lights-dark-threshold-design.md.