Adds four live-tunable `number` entities per AL profile (min/max brightness, min/max color temp) that own the runtime curve values. Slider changes take effect on the next curve tick — no integration reload. State persists across HA restart via `RestoreNumber`. Curve math now reads `min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp` from the four runtime entities via the entity registry, falling back to `entry.options` when an entity is unavailable. The options flow seeds its four range fields from the current entity state so the dialog matches reality. Also fixes entity friendly names via HA's `has_entity_name` composition: profile "Dining MVP" now reads as "Dining MVP", "Dining MVP Brightness", "Dining MVP Color" — short enough for HA's tightest cards. `unique_id`s are unchanged so existing entity_ids stay stable. Manifest bumped to 2.1.0-cdit.1 (minor, no breaking changes). 14 new tests in `tests/test_number_platform.py`; 108 passing overall. OpenSpec change archived once 9.x live-HA verification completes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
18 KiB
Context
After cdit-config-redesign, the four most-tuned values — min_brightness, max_brightness, min_color_temp, max_color_temp — live in the options flow's Daytime curve section. Editing them requires the full Settings → Devices & Services → Adaptive Lighting → Configure → Save click trail, and each save triggers a full integration reload via OptionsFlowWithReload (entities torn down, switches re-registered, ~1 s of disruption).
That's the wrong gesture for the right action. "The kitchen feels too warm tonight" should be a slider on the dashboard, not a dialog round-trip. This change promotes those four values to first-class HA number entities — sliders that show up on any Lovelace view, that scripts can read, that automations can write, that the user can voice-command via the HA Assist pipeline.
The integration's curve math currently reads entry.options[CONF_MIN_BRIGHTNESS] (etc.) on every tick. After this change, it reads hass.states.get("number.adaptive_lighting_<name>_min_brightness").state. The options flow still owns these fields (for setup ergonomics), but the runtime source of truth shifts to the entities.
Goals / Non-Goals
Goals:
- Four live-tunable entities per AL profile, slider mode, native HA selectors. Visible on the integration's device page and embeddable in any Lovelace card.
- Persistent across HA restart via
RestoreNumber(no separateStoreplumbing). - Curve math reads from a single runtime source — the entity state. Options flow re-seeds entities on save.
- Slider drag must NOT trigger an integration reload. Tuning is cheap; setup is heavy.
- Setup parity: a fresh AL config still has working defaults from
entry.options; first-time users do not need to discover the slider entities to get a working install. - Options-flow open seeds from current entity state, not the stale options snapshot — what the user sees in the dialog is what their lights are running.
Non-Goals:
- Promoting non-range setup-time fields (
interval,transition,initial_transition,adapt_delay,send_split_delay,ramp_half_width) to runtime entities. Setup-time concerns stay in the options flow. - A custom Lovelace card. Native HA number cards render the four entities fine; a dedicated card is a separate change (
add-lovelace-card, already stubbed). - Reverse coupling — making the options flow read from entities instead of
entry.optionsas its persistence backbone. Options flow remains options-flow; the entities are a runtime surface layered on top. - Service handlers for setting range values. The number entity's own
number.set_valueservice is enough.
Decisions
Decision 1: Single runtime source of truth — the number entity state
What we chose: The four number entities are the canonical place the curve math reads from at runtime. entry.options becomes the seed-on-create and last-save snapshot, but no longer the read path during curve evaluation.
Why: Avoids the dual-source-of-truth bug class. With one read path, there is no "which one wins when they disagree?" question. The options flow remains the place to set defaults; the entity is the place to tune live. Different gestures, different surfaces, one read.
Alternatives considered:
entry.optionscanonical, entity is a passthrough proxy. Rejected — every slider change has to dual-write to options, which triggersOptionsFlowWithReloadreload. ~1 s integration disruption per slider tick is unacceptable UX.- Custom
hass.dataruntime cache, written by both surfaces, read by curve math. Rejected — third layer just to bridge the gap between "entry.options is persistent" and "entity is interactive". RestoreNumber already gives us per-entity persistence.
Decision 2: RestoreNumber for persistence, no write-back to entry.options on slider change
What we chose: The four entities extend homeassistant.components.number.RestoreNumber. Each entity persists its state via HA's native restore mechanism. Slider changes do not call async_update_entry. entry.options is only updated when the user explicitly saves the options flow.
Why: Three wins. (1) Avoids the OptionsFlowWithReload reload-on-every-slider trap. (2) Uses the HA-idiomatic restore path — same machinery scenes and automations use for any number entity. (3) Decouples the persistent-config concept (options flow) from the live-tuning concept (entity); the two surfaces stay logically distinct, even if they happen to mirror each other most of the time.
Alternatives considered:
- Dual-write on slider change + suppress reload via a temporary listener detach. Rejected — peeks into
OptionsFlowWithReloadinternals; brittle across HA core upgrades. Storehelper keyed by entry_id with the four values. Rejected — extra abstraction layer for the same persistence guarantee RestoreNumber gives us for free.- No persistence at all — slider value resets to
entry.optionsdefaults on every HA restart. Rejected — punishes users who tune their install. Restart should not lose tuning.
Decision 3: Options-flow save pushes new values into the entities via reload-and-reseed
What we chose: When the user saves the options flow, entry.options updates as normal, OptionsFlowWithReload reloads the integration, and the number platform's async_setup_entry recreates the entities. On creation, each entity reads its initial value from entry.options[CONF_*] rather than from the RestoreNumber state. Result: just-saved values appear immediately on the sliders.
Why: Reuses the existing reload mechanism instead of inventing a new "push to entity" pathway. The hierarchy is deliberate: a save through the options flow is an explicit user gesture ("these are my new defaults"), so the just-saved value supersedes any pre-existing slider position. The RestoreNumber data is the fallback when there are no fresher options.
Alternatives considered:
- Skip reload on options save and push to entities directly via
entity.async_set_native_value(). Rejected — works for the four range fields but the other ~14 options fields still need reload to take effect, so reload happens regardless. Doing two save paths (reload-everything vs. push-this-entity) doubles the surface to test. - RestoreNumber state wins over
entry.optionson entity creation. Rejected — means the options flow Save button silently does nothing for the range fields, which is the opposite of what the user just asked for.
Decision 4: Options flow OPEN seeds the four range fields from the entity, not from entry.options
What we chose: When the options flow renders, the four range field defaults are read from hass.states.get(<entity_id>).state (cast to int), with a fallback to entry.options[CONF_*] if the entity is unavailable. The other ~14 fields continue to seed from entry.options as today.
Why: "What I see in the dialog matches what's running" is a stronger UX invariant than "what I see in the dialog matches what I last typed here." If the user spent a week tweaking the dashboard slider down to 65% and then opens the dialog, the dialog should show 65, not the 70 they typed at install time.
Alternatives considered:
- Seed from
entry.optionsalways. Rejected — divergence between dialog and reality is exactly the kind of "why does saving snap my slider back?" surprise this change is built to eliminate. - Seed from entity, but show both values in the dialog (e.g., "current: 65, last saved: 70"). Rejected — visual clutter for a corner case. The user can read the slider entity directly if they want to see history.
Decision 5: Entity naming pattern uses the profile slug
What we chose: Each entity's unique_id is <entry.entry_id>_<field> (e.g., <entry_id>_min_brightness), the device name is the profile name, and HA's default entity-id slug-from-friendly-name yields entities like number.adaptive_lighting_<profile>_min_brightness after slugification.
Why: Mirrors the convention cdit-config-redesign set for the three switches (switch.adaptive_lighting_<profile>_adapt_brightness etc.). Predictable for automation authors. No surprises.
Alternatives considered:
- Per-field unique_id without profile in the slug. Rejected — collides across multiple AL profiles.
- Short slug (
number.al_<profile>_min_b). Rejected — saves keystrokes, costs readability in dashboards.
Decision 6: Native selector configuration per field
What we chose:
| Field | min | max | step | unit | mode | icon |
|---|---|---|---|---|---|---|
min_brightness |
1 | 100 | 1 | % |
slider | mdi:brightness-3 |
max_brightness |
1 | 100 | 1 | % |
slider | mdi:brightness-7 |
min_color_temp |
1000 | 10000 | 100 | K |
slider | mdi:thermometer-low |
max_color_temp |
1000 | 10000 | 100 | K |
slider | mdi:thermometer-high |
Why: Same ranges and units as the options-flow NumberSelector config (R5 of options-flow spec). Icons pick a "low/high" pair so the slider rows visually pair up on the device page.
Alternatives considered:
box-or-slidermode for color temp (which has a 9000-unit range and 100-unit step). Rejected for now — pure slider mode is fine on desktop; mobile UX is the only place box-mode helps, and 90 steps is still draggable. Re-evaluate if user complains.- Same
mdi:brightness-percenticon for both brightness entities. Rejected — visually identical entities on the device page is a discoverability foot-gun.
Decision 7: Range validation lives in the selector, not in a custom validator
What we chose: Min/max bounds are enforced by NumberEntity.native_min_value / native_max_value (frontend rejects out-of-range inputs). The curve math does not re-validate — it trusts the state machine to hold a sane number.
Why: Aligns with cdit-config-redesign Decision 5 (native selectors over voluptuous wrappers). One layer of validation, frontend-enforced.
Alternatives considered:
- Belt-and-braces validation in curve math. Rejected — duplicates the frontend rule. If the state machine ever held a bogus value, the curve math would silently coerce; better to fail loud (let
int(...)raise) so we hear about real bugs.
Decision 8: Read path in curve math — hass.states.get, no caching
What we chose: AdaptiveSwitch._get_settings() (or wherever the curve evaluates) reads hass.states.get(self._range_entity_id("min_brightness")).state on every tick, casts to int, passes into SunLightSettings. No local attribute cache, no listener-on-change.
Why: The state machine read is a dict lookup. Curve evaluation happens every ~90 s (the interval setting). The 4 state-machine reads cost ~5 µs total. Caching saves nothing measurable and adds invalidation complexity ("which event resets the cache?").
Alternatives considered:
- Cache + state-change listener. Rejected — premature optimization. If profiling ever shows curve eval is hot, revisit.
- Pass
hassintoSunLightSettings.brightness_pct()and read inside. Rejected — pollutes the pure-math wrapper with HA state access. The switch class is the right boundary.
Decision 9: Entity unavailability falls back to entry.options
What we chose: If hass.states.get(<entity_id>) returns None or the state is unavailable / unknown, the curve math falls back to entry.options[CONF_*]. Logged at DEBUG.
Why: Belt-and-braces. The race window is small (entity created in same setup pass as the switch) but real. Falling back to the same value the entity would seed from anyway keeps the curve continuous through any transient hiccup.
Alternatives considered:
- Skip the curve update if entities are unavailable. Rejected — invisible to the user, looks like a freeze.
- Hard-fail loudly. Rejected — startup ordering is HA's responsibility, not the user's problem to debug.
Decision 10: Capability slug runtime-range-controls
What we chose: This change defines one new capability — runtime-range-controls — covering entity creation, write-through semantics, the curve-math read path, restore behavior, and option-flow seeding. It also modifies the existing options-flow capability (the Daytime curve section gains write-through behavior on save).
Why: Scoped tight, matches the pattern set by options-flow in cdit-config-redesign. Keeps spec scenarios testable in isolation.
Decision 11: All AL entities use HA's has_entity_name composition
What we chose: Every entity created by this integration sets _attr_has_entity_name = True and registers under a device record whose name is the profile's display name (i.e., entry.title, which mirrors entry.data[CONF_NAME]). The per-entity _attr_name carries only the entity's role:
| Entity class | _attr_name |
Resulting friendly name | Chars |
|---|---|---|---|
AdaptiveSwitch (master) |
None |
"Dining MVP" | 10 |
AdaptBrightnessSwitch |
"Brightness" |
"Dining MVP Brightness" | 21 |
AdaptColorSwitch |
"Color" |
"Dining MVP Color" | 16 |
AdaptiveRangeNumber(min_brightness) |
"Min brightness" |
"Dining MVP Min brightness" | 25 |
AdaptiveRangeNumber(max_brightness) |
"Max brightness" |
"Dining MVP Max brightness" | 25 |
AdaptiveRangeNumber(min_color_temp) |
"Min color temp" |
"Dining MVP Min color temp" | 25 |
AdaptiveRangeNumber(max_color_temp) |
"Max color temp" |
"Dining MVP Max color temp" | 25 |
Why: Today's friendly names ("Adaptive Lighting Adapt Brightness dining_mvp_lights") truncate to "Adaptive Lighting Adapt Br…" in HA's More-info card and overflow most Lovelace cards. The has_entity_name = True convention is the HA-idiomatic way to compose <device> <role> strings that read naturally and respect screen width. It also unifies presentation across the existing three switches and the four new number entities — a one-time fix instead of inheriting the problem.
Why drop the "Adapt" verb from the two toggles: Tightest fit on HA's narrowest cards (~28 char truncation point). The entity type (a toggle switch UI) already says "this is on/off"; the device context already says "this is adaptive lighting." Calling it just "Brightness" risks being read as a brightness value, but the toggle UI affordance and the integration icon disambiguate it in every HA surface where it renders. Worst case for clarity: an unfamiliar dashboard read; trade accepted for guaranteed fit and visual rhythm with the number-entity sliders.
Entity-id stability: unique_ids are unchanged by this decision, so the entity registry preserves existing entity_ids for any deployed install (automations and scripts referencing switch.adaptive_lighting_adapt_brightness_dining_mvp_lights keep working). Only the human-facing friendly name changes for existing installs. Fresh installs after this change ships get the cleaner entity_id slugs from the start.
Alternatives considered:
- Strip "Adaptive Lighting" prefix without adopting
has_entity_name. Rejected — patches one symptom (the prefix) without fixing the structural problem (per-entity hand-composed names). Next entity we add inherits the same pattern. - Rename
unique_ids to the cleaner pattern and let HA regenerate entity_ids. Rejected — breaks every user automation. Not worth the upside;has_entity_namegives us the readable friendly name without touching unique_ids. - Use the entry id (
abc123…) as the device name. Rejected — opaque to users. Profile name is the right anchor.
Risks / Trade-offs
- [RestoreNumber state is wiped if the user deletes and recreates the integration] → New install starts from
entry.optionsdefaults. Acceptable — that's the documented behavior for any HA entity. Mitigation: README note that "Settings → Devices → Adaptive Lighting → Configure" is the place to set defaults, "the sliders" are the place to tune live. - [Options-flow Save snaps live slider to the typed value] → Intentional (Decision 3). Mitigation: the section framer text in
strings.jsonsays "saving here resets the live sliders to these values." - [Stale options dialog if user opens it during a long-running automation that's adjusting the sliders] → The seed-on-open (Decision 4) reads the entity state at flow-open time; further automation changes after open are not reflected. Mitigation: this is the standard HA dialog-render contract; same behavior as every other config flow.
- [Curve math reads state machine on every tick, ~90 s interval] → Tiny perf cost; not a concern at human-scale tick rates. Mitigation: none needed; document in design.md (Decision 8).
- [Profile rename via UI breaks the entity unique_id slug] →
unique_idusesentry.entry_id, not the profile name, so the unique_id is stable across renames. Entity ID slug (entity_id) regenerates from the friendly name; HA preserves it via entity registry. Mitigation: covered by HA's own rename handling; no special code needed. - [Race: user moves slider while options-flow save is in flight] → Last-write-wins per the natural state-machine ordering: whichever async task lands last sets the value. Acceptable; the window is < 1 s. Mitigation: none.
Migration Plan
Single PR on the fork's main branch. The cdit-config-redesign change is already archived (this depends on its options-flow contract, not its delivery).
- Add the
numberplatform. - Ship the runtime-range-controls capability via the spec delta.
- Tag a release (
v2.1.0-cdit.1— minor bump; no breaking change to existing entries). - Existing config entries pick up four new entities on next HA restart. The entities seed from the entry's saved options the first time they appear. No user action required.
Rollback: revert the PR. The four new entities disappear from the entity registry; the curve math reverts to reading entry.options directly. No data loss.
Open Questions
None — all 11 decisions resolved.