Exterior Lights — Tunable Dark Threshold¶
Date: 2026-07-24 Status: Design — approved for planning Scope: Replace the hardcoded −6° (civil dusk/dawn) on/off boundary in the exterior-lights renderer with a dashboard-tunable sun-elevation helper, defaulting to 0° (sunset). Touches script.exterior_lights_render, automation.exterior_lights_render_triggers, and one new input_number.
Motivation¶
All outdoor lights currently switch on when sun.sun's elevation crosses −6° (civil dusk) and off when it crosses back above. In practice −6° is too late — by civil twilight it is already quite dark outside, so the lights come up well after they are wanted.
Moving the boundary up (less negative) brings them on earlier. At 0° — the sun touching the horizon, i.e. sunset — the lights arrive roughly 20–30 minutes earlier, while there is still usable light.
Rather than hardcode a new constant and re-guess, the boundary becomes a helper so it can be dialled in over a few evenings without a code change.
What is deliberately preserved¶
The trigger mechanism does not change. Both edges stay numeric_state triggers on sun.sun's elevation attribute. This is load-bearing: HA has no native dusk event, and a numeric_state on the elevation attribute fires on the settled value. The earlier implementation used sun sunset/sunrise events, which raced with the state update and left the watch light stuck off (2026-07-12). Only the compared value changes — never the mechanism.
The helper¶
exterior_lights_elevation:
name: "Exterior Lights Dark Threshold"
icon: mdi:weather-sunset-down
min: -6
max: 6
step: 0.5
unit_of_measurement: "°"
mode: box
No initial: — the value persists across restarts (restore_state), matching precool_high_threshold, sofi_heartbeat_threshold, sitting_lamp_dark_lux, sofi_bulb_bri_pct.
The min: -6 floor is chosen deliberately. With no stored value HA seeds a helper at its minimum, so the first boot after deploy reproduces exactly today's behaviour (−6° civil dusk). Nothing changes until the value is raised — there is no window where an unset helper produces surprising lighting. Set it to 0 once after deploy.
Reference points: 0 = sunset · −3 = mid-civil-twilight · −6 = civil dusk (today) · positive = before the sun fully sets.
Symmetry¶
Dusk and dawn read the same helper, so the rule is simply "exterior lights are on whenever the sun is below X°." At the default of 0° that means on at sunset, off at sunrise.
Implementation¶
Triggers — automation.exterior_lights_render_triggers¶
numeric_state's above/below accept an entity id whose state is numeric, so the helper wires in without templating:
- trigger: numeric_state
entity_id: sun.sun
attribute: elevation
below: input_number.exterior_lights_elevation
id: dusk
- trigger: numeric_state
entity_id: sun.sun
attribute: elevation
above: input_number.exterior_lights_elevation
id: dawn
Plus one new trigger so a dashboard tweak applies immediately instead of waiting up to 30 minutes for the self-heal tick:
The threshold id falls straight through to the render call. It deliberately does not touch the backyard bedtime latch or the manual-override flag — only a genuine dusk/dawn edge does that.
Renderer — script.exterior_lights_render¶
dark_below: "{{ states('input_number.exterior_lights_elevation') | float(-6) }}"
is_dark: "{{ (state_attr('sun.sun','elevation') | float(90)) <= dark_below }}"
float(-6)fallback: anunknown/unavailablehelper falls back to today's shipped behaviour rather than to0, which Jinja would otherwise coerce from an empty state and which would leave the lights on all day.float(90)on the elevation is retained: an unreadable sun elevation is treated as daytime, so a sensor failure fails safe (lights off) rather than burning them 24/7.
The variable civil_night is renamed to is_dark throughout. "Civil night" is now a misnomer — the boundary is tunable and defaults to sunset, which is not civil twilight. Both consumers (the security branch and the backyard branch) are updated.
Unchanged¶
- Backyard "off at bedtime" behaviour,
input_boolean.backyard_bedtime_latch, and the latch maintenance ondusk/sleep_on/ HA start. - The manual override (
input_boolean.exterior_lights_manual) and its reclaim-only-at-dusk/dawn semantics. - The 30-minute self-heal tick and its
exterior_lights_self_healgate. - Area-driven target computation (front/side = security, backyard +
switch.string_lights). - The state-dedup that commands only wrong-state members.
Explicitly out of scope¶
The office salt lamp (§3) keeps its own −6° civil-dusk schedule. It is an indoor lamp on a separate rule and is not part of the exterior renderer. After this change the two schedules intentionally diverge: exterior lights at 0°, salt lamp at −6°. That is deliberate, not drift.
Risks and edges¶
- Entity-referenced
below:/above:must be schema-valid. CI's Tier 1check_configgates this — if HA rejects the form, the deploy job never runs. Runtime behaviour is confirmed post-deploy by the functional test below. - Editing the threshold exactly as the sun crosses the new boundary can fire a genuine
dusk/dawn, which clears the manual-override flag. Vanishingly rare, self-correcting, and arguably correct (the sun really is at the boundary at that moment). - Raising the threshold lengthens the on-window at both ends, increasing runtime for the front/side security lights. Accepted — that is the point.
Verification plan¶
check_config validates schema, not behaviour, so the boundary logic gets a real test.
make test— structural + entity-ref tiers, plus new tests asserting both edges reference the helper (not a literal), that the renderer compares against it, and that athresholdtrigger exists.- Live render-test the
is_darkexpression via/api/templateacross thresholds spanning the range, confirming the comparison flips where expected. - Live functional test: temporarily set the helper above the sun's current elevation so "dark" becomes true in daylight, confirm the exterior lights actually come on, then restore the value and confirm they go back off. This proves the entity-referenced trigger fires at runtime without waiting for real dusk.
- Observe a real dusk on the first evening after deploy and confirm the on-time moved earlier by roughly 20–30 minutes.
Docs to update¶
| File | What |
|---|---|
CLAUDE.md §9 | Civil dusk/dawn → tunable threshold; add the helper to Key Entities |
docs/lighting.md | Full Lighting Schedule rows for exterior on/off |
tests/fixtures/entities_allow.txt | Helper does not exist in the registry until HA restarts |
docs/superpowers/specs/2026-07-13-exterior-lights-renderer-design.md | Pointer noting the −6° constant is superseded |