Skip to content

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 — 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:

    - trigger: state
      entity_id: input_number.exterior_lights_elevation
      id: threshold

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: an unknown/unavailable helper falls back to today's shipped behaviour rather than to 0, 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 on dusk / 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_heal gate.
  • 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 1 check_config gates 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.

  1. 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 a threshold trigger exists.
  2. Live render-test the is_dark expression via /api/template across thresholds spanning the range, confirming the comparison flips where expected.
  3. 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.
  4. 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