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

212 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## 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.