Skip to content

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) — owns switch.string_lights: on at sunset+30m, off when input_boolean.sleeping.
  • script.front_exterior_render (§10) — owns light.front_porch_light / light.front_flood_light (sunset→midnight) and light.front_watch_light (dusk→dawn), driven by sun event 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_exterior
  • side_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):

  1. Every light.* entity in the area — auto-includes future bulbs / Inovelli dimmers (they all expose a light. entity). This is the "future lights in the area are picked up automatically" goal.
  2. 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.sun elevation crossing −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.sleeping turning on (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 on when input_boolean.sleepingon.
  • Cleared off at 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 (includes off/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_state sun.sun elevation below −6 (id dusk) → clear latch, render.
  • numeric_state sun.sun elevation above −6 (id dawn) → render.
  • state input_boolean.sleeping to on (id sleep_on) → set latch, render.
  • state input_boolean.sleeping to off → render.
  • time_pattern every 30 min (id tick) → render (self-heal against Inovelli unavailable flaps). Gated (#89) by input_boolean.exterior_lights_self_heal (on by default, no initial: so the choice persists across restarts via restore_state): when off, an if … stop bails the tick trigger 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).
  • homeassistant start → 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 sleeping check — 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/off and the we_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_latch helper, run make snapshot so the entity-ref test passes.
  • Live render-test (already done for the selection/dedup/elevation templates) + verify homeassistant.turn_on dispatches a mixed light+switch list at implementation time.
  • Manual acceptance: force sun.sun context isn't possible, but verify the render script by calling it directly and checking each group's on/off set matches civil_night / block_backyard; confirm the latch sets on sleeping→on and clears at the dusk trigger.

Risks / Notes

  • Template runtime: check_config validates schema only; the area-expansion, select('match',…), and expand()+selectattr dedup were render-tested live (2026-07-13). Re-test after any edit.
  • numeric_state on sun.sun elevation: sun.sun updates 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 unavailable flapping (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 !include package file (script → scripts.yaml, automation → automations.yaml, helper → configuration.yaml), so no ci.yml HA_FILES/regex change is needed.
  • Docs to update on implementation: docs/lighting.md, docs/automations.md (§9/§10 → new section), CLAUDE.md automation 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 the numeric_state-on-elevation-attribute mechanism is unchanged. See 2026-07-24-exterior-lights-dark-threshold-design.md.