adaptive-lighting/openspec/changes/cdit-config-redesign/design.md
Casey 80f99db16f Scaffold CDiT fork: planning artifacts and tooling
- openspec/ — three changes scoped:
  • cdit-config-redesign: full artifact set (proposal + design + specs +
    tasks), strict-validate green. Prunes 21 fields from the upstream
    options flow, switches sun timing to entity-driven sources, hardcodes
    a tanh curve, and breaks compat with upstream config entries.
  • add-runtime-range-controls: proposal stub for promoting the 4
    brightness/color-temp ranges to live number entities.
  • house-mode-modes: proposal stub for per-AL house-mode behavior matrix
    driving the runtime switches.
- .claude/ — opsx slash commands and openspec skill bundles for driving
  the artifact-driven workflow.
- CLAUDE.md — fork orientation, remote topology, dev commands, and the
  active change pointer.
2026-05-16 12:24:34 +02:00

18 KiB
Raw Blame History

Context

Upstream basnijholt/adaptive-lighting is a mature HA custom component (~10k LOC, 800+ stars). Its options flow is built by iterating over VALIDATION_TUPLES in const.py — 39 entries, each a (key, default, voluptuous_validator) tuple. The result is one flat vol.Schema rendered as a single tall form with no grouping, no conditional visibility, custom int_between validators (no slider UI), and silent no-op behavior when the entry was YAML-configured.

CDiT's install is one household, opinionated about scenes and house modes, and uses Sun2 for precise twilight events. Half of upstream's options are dead weight in that context (sleep mode, take-over-control, manual sun-time math). The other half is hard to find because nothing is grouped.

This change resets the dialog. It also removes the sleep-mode switch entity from the switch platform — sleep mode is dropped wholesale, not just hidden.

Goals / Non-Goals

Goals:

  • 18-field dialog that fits on one screen via collapsible sections.
  • Sun curve driven by HA entities, so Sun2 (or any other twilight source) plugs in via the existing entity selector.
  • Native HA selectors for everything (sliders, dropdowns, entity pickers) instead of voluptuous int_between wrappers.
  • Reload-on-save without manual async_unload_entry / async_setup_entry plumbing.
  • Honest message when the entry is YAML-managed (currently silently no-ops).
  • One-shot break: no migration code, manifest version bump, clear "recreate your entry" message.

Non-Goals:

  • Multi-step wizard. Editing dominates over setup in this integration's lifetime; sections beat steps.
  • Backwards compatibility with upstream config entries.
  • Translations beyond en.json. Other locales are out of scope; upstream's translations can be culled in a follow-up.
  • Replacing the simulator webapp at webapp/. It targets the upstream curve model and is decoupled from the HA integration. Out of scope.
  • Removing astral as a transitive dep. It's still imported elsewhere; pruning it is a follow-up.
  • Touching tests/ for unaffected features (color helpers, light state diffing, etc.). Only config-flow, sleep-mode, and take-over-control tests get rewritten.

Decisions

Decision 1: Collapsible sections via HA's section() helper, not a multi-step flow

What we chose: One async_step_init that returns a single schema with grouped sections built via the HA Frontend section() helper. All fields visible (or conditionally visible) on one screen.

Why: Setup happens once per AL config; editing happens dozens of times over the install's lifetime. Wizards optimize for first-run cost (guidance) at the expense of every subsequent edit (clicks). Sections give us guidance through grouping while keeping editing fast.

Alternatives considered:

  • Multi-step flow (async_step_basics → async_step_curve → ...). Rejected — every edit becomes a click-through. Also, conditional visibility across steps is messier than within a single section-grouped form.
  • One flat dialog with field-order reshuffling. Rejected — without visual grouping, ordering alone doesn't communicate which fields belong together.

Decision 2: Entity-driven sun timing, no astral in the curve path

What we chose: Two entity selectors (sunrise_entity, sunset_entity) of domain: sensor, device_class: timestamp. Defaults to built-in sensor.sun_next_rising / sensor.sun_next_setting. The curve math reads hass.states.get(<entity_id>) for the timestamps, not astral.

Why: Lets Sun2 (or any custom sun source) plug in without a code change. Matches HA convention ("everything is an entity"). Decouples us from astral's quirks at high latitudes (irrelevant to Germany, but it's free elegance). Single source of truth — your sensor.sun_next_rising is also what every other automation in the house reads.

Alternatives considered:

  • Keep astral and offer a "sun source" toggle. Rejected — code path bifurcation, two implementations to test, defeats the simplification goal.
  • Hardcode sun.sun and drop the selector. Rejected — explicitly precludes Sun2, which is the whole reason to refactor sun timing.

Decision 3: Hardcoded tanh curve with 30-minute half-width

What we chose: brightness_mode field is deleted. Curve is always tanh. The half-width (brightness_mode_time_dark / _time_light upstream) is hardcoded as a single constant RAMP_HALF_WIDTH_SECONDS = 1800 in const.py.

Why: Three modes (default / linear / tanh) were a research-era artifact; tanh is the eye-friendly choice for any household use case. The half-width sweet spot (30 min) is well-studied for circadian-friendly transitions and looks reasonable from solstice (sunrise 04:30) to equinox (sunrise 06:00). Removing the knob removes a UX maze.

Alternatives considered:

  • Keep brightness_mode as a hidden constant, expose half-width. Rejected — same maintenance cost as keeping both, with no real win.
  • Expose half-width as one Advanced slider. Rejected (initially considered). One less knob is genuinely better than one customizable knob — advanced fields rot when unused.

Decision 4: Strict break, no migration code

What we chose: Bump manifest.json major version (1.x → 2.0.0-cdit.1). On upgrade, HA fails to load old config entries with a "this version is incompatible" toast. User recreates the entry manually.

Why: Single-household fork. The cost of one manual re-setup is ~5 minutes. Migration code that strips removed keys and silently translates old values would be ~50 LOC, would carry across the next two breaks, and is the kind of code that hides edge-case bugs.

Alternatives considered:

  • Silent strip on async_setup_entry. Rejected — looks magical, hides which fields the user thought were configured.
  • async_migrate_entry with explicit version bump. Rejected — correct for a public fork, overkill for one user. Re-evaluate if anyone outside CDiT installs this.

Decision 5: Native HA selectors instead of voluptuous int_between

What we chose: NumberSelector with min, max, step, unit_of_measurement, mode=NumberSelectorMode.SLIDER (or BOX for fine-grained); EntitySelector with domain/device_class filters; BooleanSelector; DurationSelector for time intervals. All in homeassistant.helpers.selector.

Why: Selectors render as proper UI (sliders, dropdowns, color pickers, entity pickers). int_between(1, 100) validates correctly but renders as a plain text field — same UX whether the range is brightness, color temp, or seconds. Selectors are the direction HA core is moving; staying on voluptuous custom validators means re-implementing this every minor HA release.

Alternatives considered:

  • Keep voluptuous wrappers, restyle via custom frontend card. Rejected — explicitly pulls us into frontend territory, which is out of scope for this change.

Decision 6: OptionsFlowWithReload instead of manual reload

What we chose: Subclass homeassistant.config_entries.OptionsFlowWithReload (added to HA core in 2024.10). Override async_step_init only. No async_unload_entry / async_setup_entry reload plumbing.

Why: The documented, supported path. Manual reload has subtle bugs around entity registry stale state and listener leakage. OptionsFlowWithReload handles them by tearing down and rebuilding the entry atomically.

Alternatives considered:

  • Stay on OptionsFlow and call hass.config_entries.async_reload(...) manually after save. Rejected — duplicates HA framework code, easy to forget on edge paths.

Decision 7: Delete the sleep mode switch entity, don't hide it

What we chose: Remove the sleep-mode switch class from switch.py entirely. Each AL config now creates 3 switches (master, adapt_brightness, adapt_color), not 4. Sleep-mode state machine is also stripped from the master switch class.

Why: Keeping unused entities pollutes the device page, accumulates state, and creates ambiguity ("will this wake up if I flip it?"). Hiding from UI but keeping the code path means the state machine still runs, just invisibly — worst of both worlds.

Alternatives considered:

  • Keep the entity, hide from UI. Rejected — see above.
  • Keep the class, gate behind a feature flag. Rejected — dead code with a flag is still dead code.

Decision 8: Delete take-over-control, don't gate it

What we chose: Remove take_over_control, take_over_control_mode, detect_non_ha_changes, autoreset_control, only_once, adapt_only_on_bare_turn_on. Strip the take-over-control state machine from switch.py.

Why: Manual-override semantics live at the scene/automation layer in CDiT's house. The take-over-control code threads through every adapter call in switch.py — making it conditional via a feature flag doesn't reduce complexity, just relocates it. Delete is the only honest option.

Alternatives considered:

  • Gate behind enable_legacy_overrides flag. Rejected — preserves the code, defeats the simplification goal, accumulates "advanced settings" that nobody ever turns on.
  • Keep intercept and multi_light_intercept — these are kept. They're transport behavior (hooking light.turn_on to merge adaptive values), not manual-override semantics. Different concern.

Decision 9: Conditional visibility costs one submit cycle

What we chose: Fields with drivers (send_split_delay driven by separate_turn_on_commands; sun-time min/max pairs driven by whether the sun-entity is set) are conditionally included in the schema. When the driver changes, the user submits the form and HA re-renders with the new fields.

Why: HA's config flow can't re-render a single step dynamically when a field changes within that step. The supported path is to submit and let the next render reflect the new state. This costs one extra submit when a driver changes — accepted because driver changes are rare (you set separate_turn_on_commands once and forget).

Alternatives considered:

  • Always show all fields. Rejected — the whole point is to hide noise.
  • Split into multiple steps when a driver changes. Rejected — back to the wizard problem.

Decision 10: Capability slug is options-flow, scoped tight

What we chose: This change defines one capability, options-flow, covering the integration's setup/reconfigure UI. Future changes get their own capabilities (runtime-controls for the number entities, house-mode-binding for the per-mode matrix).

Why: Keeps each spec testable and each requirement focused. A single integration-configuration umbrella would collect every config-related requirement across multiple changes — hard to read, hard to maintain.

Decision 11: Sun curve math — max brightness lives strictly between the two entities

What we chose: Given t_sunrise and t_sunset from the entities, the brightness curve is:

       t < t_sunrise - 1800s :  brightness = min
       t in [t_sunrise - 1800s, t_sunrise + 1800s] : tanh ramp from min → max
       t in [t_sunrise + 1800s, t_sunset - 1800s]  : brightness = max
       t in [t_sunset - 1800s, t_sunset + 1800s]   : tanh ramp from max → min
       t > t_sunset + 1800s :   brightness = min

Same shape applied to color temperature, scaled between min_color_temp and max_color_temp.

Why: Symmetric, deterministic, easy to test. The user's pick of entity is the semantic anchor — pick sun2_dawn and full brightness arrives 30 min after civil dawn.

Alternatives considered:

  • Treat the entities as ramp midpoints. Rejected — less intuitive; "when does the day start" should map to "when does max brightness start," not "when am I halfway to max."
  • Sun-elevation-style curve interpolated between events. Rejected — adds astral-style math complexity without proportional benefit. The user picked entity-driven explicitly to avoid that.

Decision 12: Auto-remove leftover sleep-mode switch entities on first load

What we chose: In async_setup_entry, scan the entity registry for any switch.adaptive_lighting_sleep_mode_* entries tied to this integration's config entry, remove them via entity_registry.async_remove(entity_id), and log each removal at INFO level.

Why: Without this, upgrading users see orphan switches in Settings → Entities forever. The cleanup is bounded (only entities this integration created, identifiable by unique_id prefix), idempotent (runs every setup, no-op after the first), and small (~10 LOC). The log line gives auditability.

Alternatives considered:

  • Log + leave for manual cleanup. Rejected — pollutes the device page; CDiT install has half a dozen AL configs, that's half a dozen orphans.
  • One-time cleanup script. Rejected — extra step the user has to run.

Decision 13: Pin homeassistant minimum version to 2025.1.0

What we chose: manifest.json declares "homeassistant": "2025.1.0" as the minimum supported HA version.

Why: Lets us use the latest stable selector API (NumberSelector with BOX_OR_SLIDER mode, EntitySelector with multi-domain filtering), section() helper with no rendering quirks, and OptionsFlowWithReload with all 2024.x bug fixes applied. Excludes pre-2025.1 installs intentionally — running HA more than ~6 months behind is its own problem.

Alternatives considered:

  • Pin 2024.10. Rejected (initially recommended) — gives back the headroom for no clear benefit; user explicitly chose to go bolder.
  • No pin ("homeassistant" absent). Rejected — silently runs on incompatible versions, fails confusingly when section() doesn't render right.

Decision 14: Sun-entity selectors are strictly typed

What we chose: Both sunrise_entity and sunset_entity use EntitySelector(EntitySelectorConfig(domain="sensor", device_class="timestamp")). Only sensors with device_class: timestamp show up in the picker.

Why: Covers all real use cases (built-in sensor.sun_next_rising, Sun2's sensor.sun2_*, any custom timestamp sensor) while preventing foot-guns — input_datetime entities don't auto-update for next sunrise, and picking one would silently produce wrong curves. Strict typing makes the wrong choice impossible.

Alternatives considered:

  • Loose typing (any datetime entity). Rejected — invites the input_datetime foot-gun.
  • Strict + escape hatch constant. Rejected — no real use case for loose mode; adding the knob now is YAGNI.

Decision 15: RAMP_HALF_WIDTH_SECONDS lives in const.py

What we chose: Define RAMP_HALF_WIDTH_SECONDS = 1800 in const.py near the other tuning defaults, with an inline comment explaining the 30-minute choice (eye-friendly circadian transition, robust across solstice-to-equinox sunrise drift).

Why: const.py is the canonical "knobs you might tweak" location in HA custom components. Discoverable to anyone reading the integration. The comment captures the rationale so future-CDiT doesn't relitigate.

Alternatives considered:

  • __init__.py near curve math. Rejected — cohesive but harder to find when you want to tune.
  • Buried in config_flow.py. Rejected — treats the constant as flow-private, but the curve math is the actual consumer.

Risks / Trade-offs

  • [Sun2 isn't installed in HA] → Defaults work fine — built-in sensor.sun_next_* is always present. Mitigation: README mentions Sun2 as a recommended-but-not-required HACS install.
  • [sensor.sun_next_rising flips to tomorrow's date the moment sunrise passes] → The curve math must handle this: after t_sunrise has passed, read sun.sun.last_changed/historical state, or anchor today's curve from t_sunset and the implicit "today is between yesterday's sunset and tomorrow's sunrise." Mitigation: write a small helper today_sun_events() that returns (today_sunrise_dt, today_sunset_dt) based on current time vs entity values; specs/tasks will cover this explicitly.
  • [Removed sleep-mode switch entity lingers in HA's entity registry after upgrade] → Old switch.adaptive_lighting_sleep_mode_<name> entries remain in core.entity_registry until manually deleted. Mitigation: document in CHANGELOG; provide a one-line hass.config_entries.async_remove(...) snippet for cleanup, or accept manual deletion via UI.
  • [Strict break inconveniences any non-CDiT installer] → Anyone forking the CDiT fork to use upstream config entries will hit the break. Mitigation: explicit "this fork is opinionated; existing AL entries will not load" notice in README and on the fork's GitHub description.
  • [HA section() API is relatively young (added 2024.5)] → Risk of styling/render quirks on older HA versions. Mitigation: bump manifest.json homeassistant minimum version to ≥ 2024.10 (when OptionsFlowWithReload also landed, so we're pinning both together).
  • [The synthetic tanh curve drifts from astronomical reality near solstices] → Won't perfectly match astral-computed elevation. Mitigation: this is by design — the user picked Sun2 entities for accuracy at the endpoints; smooth dimming between them is what matters, not physical accuracy.
  • [include_config_in_attributes buried in Diagnostics could trip debug-time users] → Mitigation: section is collapsed by default but not hidden; surfaces on expand. Mention in README's "Debugging" subsection.

Migration Plan

This change is delivered as a single PR on the fork's main branch. No phased rollout; CDiT-dev is the only consumer.

  1. Land the PR.
  2. Tag the release (v2.0.0-cdit.1).
  3. On the running HA install:
    • Snapshot current AL config (Settings → Integrations → Adaptive Lighting → ⋮ → Download Diagnostics, or just screenshot the options dialog).
    • Update via HACS or manual copy of custom_components/adaptive_lighting/.
    • Restart HA.
    • Old config entry shows as "failed to load."
    • Delete the failed entry.
    • Recreate via Settings → Add Integration → Adaptive Lighting, copying the snapshot's values into the new section layout.
  4. Manually delete leftover switch.adaptive_lighting_sleep_mode_<name> entries via Settings → Devices → Entities (filter by integration, sort by "missing").

Rollback: git revert the PR merge commit. Old upstream config entries do not auto-recover — you'd reinstall upstream AL from HACS and re-create the entry there.

Open Questions

None — all resolved and folded into Decisions 12–15.