mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-28 12:54:22 +02:00
Implement add-runtime-range-controls
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>
This commit is contained in:
parent
c1a98a5c91
commit
d5d3ecc9d4
21 changed files with 1280 additions and 65 deletions
177
openspec/changes/add-runtime-range-controls/design.md
Normal file
177
openspec/changes/add-runtime-range-controls/design.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
## 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 separate `Store` plumbing).
|
||||
- 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.options` as 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_value` service 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.options` canonical, entity is a passthrough proxy.** Rejected — every slider change has to dual-write to options, which triggers `OptionsFlowWithReload` reload. ~1 s integration disruption per slider tick is unacceptable UX.
|
||||
- **Custom `hass.data` runtime 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 `OptionsFlowWithReload` internals; brittle across HA core upgrades.
|
||||
- **`Store` helper 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.options` defaults 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.options` on 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.options` always.** 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-slider` mode 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-percent` icon 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 `hass` into `SunLightSettings.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_id`s 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_id`s to the cleaner pattern and let HA regenerate entity_ids.** Rejected — breaks every user automation. Not worth the upside; `has_entity_name` gives 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.options` defaults. 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.json` says "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_id` uses `entry.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).
|
||||
|
||||
1. Add the `number` platform.
|
||||
2. Ship the runtime-range-controls capability via the spec delta.
|
||||
3. Tag a release (`v2.1.0-cdit.1` — minor bump; no breaking change to existing entries).
|
||||
4. 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.
|
||||
|
|
@ -20,11 +20,13 @@ This change promotes the four ranges to first-class runtime entities (HA's `numb
|
|||
- **Curve math reads from number entities**, not from `entry.options` directly. Single read path at evaluation time.
|
||||
- **Options flow keeps the four fields visible** in the Daytime curve section so users can still tune them from the config screen (especially first-time setup). Both surfaces stay in sync.
|
||||
|
||||
- **Entity-naming hygiene** (folded in because the new entities would inherit the same problem otherwise): switch the existing three switch entities and the four new number entities to HA's `has_entity_name = True` convention. Device name becomes the profile name (e.g., "Dining MVP"); entity names become short ("Brightness", "Color", "Min color temp"). Today's friendly names like "Adaptive Lighting Adapt Brightness dining_mvp_lights" truncate to "Adaptive Lighting Adapt Br…" in HA's More-info card; after this change they read as "Dining MVP Brightness" (21 chars, well inside the truncation window). `unique_id`s stay stable, so existing entity_ids and automations keep working.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `runtime-range-controls`: live-tunable brightness and color-temperature range entities, with bidirectional sync to the config entry options. Covers entity creation, write-through semantics, curve-math read path, and conflict resolution between the two surfaces.
|
||||
- `runtime-range-controls`: live-tunable brightness and color-temperature range entities, with bidirectional sync to the config entry options. Covers entity creation, write-through semantics, curve-math read path, and conflict resolution between the two surfaces. Also defines the `has_entity_name` naming convention applied to all AL entities (switches + number entities).
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Brightness and color temperature follow a synthetic tanh curve
|
||||
|
||||
For each config entry, the integration SHALL synthesize the daytime brightness and color-temperature values using a hyperbolic-tangent curve anchored at the timestamps from `sunrise_entity` and `sunset_entity`, with a hardcoded half-width of 30 minutes (`RAMP_HALF_WIDTH_SECONDS = 1800`) at each event. The brightness value at time `t` SHALL follow:
|
||||
|
||||
- `t ≤ t_sunrise − 1800s`: value = configured minimum
|
||||
- `t_sunrise − 1800s < t < t_sunrise + 1800s`: value = tanh-interpolated minimum → maximum
|
||||
- `t_sunrise + 1800s ≤ t ≤ t_sunset − 1800s`: value = configured maximum
|
||||
- `t_sunset − 1800s < t < t_sunset + 1800s`: value = tanh-interpolated maximum → minimum
|
||||
- `t ≥ t_sunset + 1800s`: value = configured minimum
|
||||
|
||||
The same curve shape SHALL be applied to color temperature using `min_color_temp` and `max_color_temp` as the curve bounds.
|
||||
|
||||
The four bound values (`min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp`) SHALL be read at evaluation time from the four runtime range entities defined in the `runtime-range-controls` capability, with fallback to `entry.options[CONF_*]` when an entity is unavailable. The curve evaluation SHALL NOT read these four values directly from `entry.options` during normal operation.
|
||||
|
||||
#### Scenario: Brightness is at minimum well before sunrise
|
||||
|
||||
- **WHEN** the current time is more than 30 minutes before the `sunrise_entity` timestamp
|
||||
- **THEN** the computed brightness SHALL equal the current value of `number.adaptive_lighting_<name>_min_brightness`
|
||||
|
||||
#### Scenario: Brightness is exactly at the midpoint at the sunrise event
|
||||
|
||||
- **WHEN** the current time equals the `sunrise_entity` timestamp
|
||||
- **THEN** the computed brightness SHALL equal `(current_min_brightness + current_max_brightness) / 2`
|
||||
- **WHERE** `current_min_brightness` and `current_max_brightness` are the current states of the corresponding number entities
|
||||
|
||||
#### Scenario: Brightness is at maximum during the day
|
||||
|
||||
- **WHEN** the current time is between `sunrise_entity + 30min` and `sunset_entity − 30min`
|
||||
- **THEN** the computed brightness SHALL equal the current value of `number.adaptive_lighting_<name>_max_brightness`
|
||||
|
||||
#### Scenario: Color temperature follows the same curve shape
|
||||
|
||||
- **WHEN** the current time is at any point on the curve
|
||||
- **THEN** the computed color temperature SHALL follow the same tanh interpolation between the current values of `number.adaptive_lighting_<name>_min_color_temp` and `_max_color_temp` as the brightness curve does between the two brightness entities
|
||||
|
||||
#### Scenario: Bound values are taken from runtime entities, not from entry.options
|
||||
|
||||
- **GIVEN** `entry.options[CONF_MIN_BRIGHTNESS]` is 5
|
||||
- **AND** `number.adaptive_lighting_<name>_min_brightness` is at 30
|
||||
- **WHEN** the curve is evaluated at a time before `sunrise_entity − 1800s`
|
||||
- **THEN** the computed brightness SHALL equal 30 (the entity state), not 5 (`entry.options`)
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Each AL profile exposes four runtime range entities
|
||||
|
||||
For each Adaptive Lighting config entry, the integration SHALL create exactly four `number` entities, one for each of `min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp`. The entities SHALL be registered on the `number` platform during `async_setup_entry` and torn down during `async_unload_entry`. Each entity SHALL share the same device record as the profile's three switches.
|
||||
|
||||
| Field | unique_id suffix | native_min | native_max | step | unit | mode |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `min_brightness` | `_min_brightness` | 1 | 100 | 1 | `%` | `SLIDER` |
|
||||
| `max_brightness` | `_max_brightness` | 1 | 100 | 1 | `%` | `SLIDER` |
|
||||
| `min_color_temp` | `_min_color_temp` | 1000 | 10000 | 100 | `K` | `SLIDER` |
|
||||
| `max_color_temp` | `_max_color_temp` | 1000 | 10000 | 100 | `K` | `SLIDER` |
|
||||
|
||||
The full unique_id SHALL be `<entry.entry_id>_<suffix>`. The device record SHALL be the same `(DOMAIN, entry.entry_id)` identifier used by the profile's switches.
|
||||
|
||||
#### Scenario: A new config entry produces four number entities
|
||||
|
||||
- **WHEN** the user creates a new Adaptive Lighting config entry
|
||||
- **AND** `async_setup_entry` completes
|
||||
- **THEN** the entity registry SHALL contain four `number` entities owned by this entry
|
||||
- **AND** their unique_ids SHALL end with `_min_brightness`, `_max_brightness`, `_min_color_temp`, `_max_color_temp` respectively
|
||||
- **AND** all four entities SHALL be attached to the same device as the profile's switches
|
||||
|
||||
#### Scenario: Number entity bounds match the options-flow selector bounds
|
||||
|
||||
- **WHEN** any of the four range number entities are inspected
|
||||
- **THEN** the brightness entities SHALL declare `native_min_value=1`, `native_max_value=100`, `native_step=1`, `native_unit_of_measurement="%"`, and `mode=NumberMode.SLIDER`
|
||||
- **AND** the color-temperature entities SHALL declare `native_min_value=1000`, `native_max_value=10000`, `native_step=100`, `native_unit_of_measurement="K"`, and `mode=NumberMode.SLIDER`
|
||||
|
||||
### Requirement: Entities persist their value across Home Assistant restarts
|
||||
|
||||
Each of the four range entities SHALL extend `homeassistant.components.number.RestoreNumber` so that its last known value is preserved across Home Assistant restarts without an explicit `Store` helper. On `async_added_to_hass`, each entity SHALL prefer in order: (1) the value present in `entry.options[CONF_*]` if that value is newer than the restored value (which is the case immediately after an options-flow save), (2) the restored value from `RestoreNumber.async_get_last_number_data()`, (3) the value present in `entry.options[CONF_*]` as the first-creation fallback.
|
||||
|
||||
#### Scenario: Slider value survives an HA restart
|
||||
|
||||
- **GIVEN** the user has moved `number.adaptive_lighting_<name>_min_brightness` to 30 via the dashboard
|
||||
- **WHEN** Home Assistant is restarted
|
||||
- **AND** the integration re-runs `async_setup_entry`
|
||||
- **THEN** the entity's state SHALL be 30 once it finishes loading
|
||||
|
||||
#### Scenario: Options-flow save value wins over restored value
|
||||
|
||||
- **GIVEN** the user has previously moved the slider to 30 (restored state)
|
||||
- **WHEN** the user opens the options flow, sets `min_brightness` to 50, and saves
|
||||
- **AND** the integration reloads
|
||||
- **THEN** the entity's state SHALL be 50, not 30
|
||||
|
||||
### Requirement: Curve math reads runtime ranges from the number entities
|
||||
|
||||
The brightness and color-temperature curve evaluation SHALL read its four bound values (`min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp`) by calling `hass.states.get(<entity_id>).state` for the corresponding number entity, casting to `int`, on every curve evaluation. The curve evaluation SHALL NOT read these four values from `entry.options` during normal operation.
|
||||
|
||||
When `hass.states.get(<entity_id>)` returns `None` or the state is `unavailable` or `unknown`, the curve evaluation SHALL fall back to the value in `entry.options[CONF_*]` and SHALL log the fallback at `DEBUG` level.
|
||||
|
||||
#### Scenario: Slider change takes effect on next curve tick
|
||||
|
||||
- **GIVEN** the integration is running with `number.adaptive_lighting_<name>_max_brightness` at 100
|
||||
- **WHEN** the user moves the slider to 70
|
||||
- **AND** the next curve evaluation tick fires
|
||||
- **THEN** the brightness curve SHALL be computed with `value_max = 70`
|
||||
|
||||
#### Scenario: Entity-unavailable fallback uses entry.options
|
||||
|
||||
- **GIVEN** the four number entities are not yet available (e.g., during early setup race)
|
||||
- **WHEN** the curve evaluation runs
|
||||
- **THEN** the curve SHALL be computed using the values from `entry.options[CONF_*]`
|
||||
- **AND** a `DEBUG` log entry SHALL be emitted naming the missing entity
|
||||
|
||||
### Requirement: Slider changes do not trigger an integration reload
|
||||
|
||||
Moving a slider on any of the four range entities SHALL NOT call `hass.config_entries.async_update_entry` for the owning config entry. The entity's new value SHALL take effect on the next curve evaluation tick without any reload of the integration, the device, or other entities.
|
||||
|
||||
#### Scenario: Slider drag does not reload the integration
|
||||
|
||||
- **GIVEN** the integration is loaded
|
||||
- **WHEN** the user moves `number.adaptive_lighting_<name>_min_brightness` from 10 to 20 via the dashboard
|
||||
- **THEN** `async_unload_entry` SHALL NOT be invoked
|
||||
- **AND** `async_setup_entry` SHALL NOT be invoked
|
||||
- **AND** the profile's switch entity IDs SHALL remain unchanged
|
||||
- **AND** the entity's state SHALL update to 20
|
||||
|
||||
### Requirement: Options-flow save propagates new range values to the entities
|
||||
|
||||
When the user saves the options flow with new values for any of the four range fields, the resulting integration reload SHALL cause the four entities to be recreated with the just-saved values as their initial state. Once the reload completes, each entity's state SHALL match the value submitted in the options flow.
|
||||
|
||||
#### Scenario: Saving updated ranges in the options flow updates the sliders
|
||||
|
||||
- **GIVEN** `number.adaptive_lighting_<name>_min_brightness` is currently at 30
|
||||
- **WHEN** the user opens the options flow, sets `min_brightness` to 55, and saves
|
||||
- **THEN** the integration SHALL reload
|
||||
- **AND** after the reload, the entity state SHALL be 55
|
||||
|
||||
### Requirement: Options-flow open seeds range fields from current entity state
|
||||
|
||||
When the options dialog is rendered, the default values shown for the four range fields (`min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp`) SHALL be read from the corresponding number entity's current state via `hass.states.get(<entity_id>).state` cast to `int`. The dialog SHALL NOT seed these four fields from `entry.options[CONF_*]` when entities exist and are available. If an entity is unavailable, the dialog SHALL fall back to `entry.options[CONF_*]` for that field.
|
||||
|
||||
The other ~14 fields in the options dialog SHALL continue to seed from `entry.options` as defined by the `options-flow` capability.
|
||||
|
||||
#### Scenario: Open options after live tuning shows live values
|
||||
|
||||
- **GIVEN** the user has moved `number.adaptive_lighting_<name>_max_brightness` from 100 (default) to 80 via the dashboard
|
||||
- **WHEN** the user opens the options flow
|
||||
- **THEN** the `max_brightness` field in the Daytime curve section SHALL show 80 as its default
|
||||
- **AND** the other fields in the dialog SHALL show their `entry.options` values
|
||||
|
||||
### Requirement: All AL entities use HA's `has_entity_name` composition
|
||||
|
||||
Every entity created by this integration — the three switches (`AdaptiveSwitch`, `AdaptBrightnessSwitch`, `AdaptColorSwitch`) and the four range number entities — SHALL set `_attr_has_entity_name = True` and SHALL register under a device whose `name` matches the profile's display name (`entry.title`). The per-entity `_attr_name` SHALL carry only the entity's role, not the integration name or the profile name. The master switch (`AdaptiveSwitch`) SHALL set `_attr_name = None` so HA renders its friendly name as the device name alone.
|
||||
|
||||
The resulting friendly names SHALL follow this table for a profile named `Dining MVP`:
|
||||
|
||||
| Entity | `_attr_name` | Friendly name |
|
||||
|---|---|---|
|
||||
| Master switch | `None` | `Dining MVP` |
|
||||
| Adapt-brightness switch | `"Brightness"` | `Dining MVP Brightness` |
|
||||
| Adapt-color switch | `"Color"` | `Dining MVP Color` |
|
||||
| Min brightness number | `"Min brightness"` | `Dining MVP Min brightness` |
|
||||
| Max brightness number | `"Max brightness"` | `Dining MVP Max brightness` |
|
||||
| Min color temp number | `"Min color temp"` | `Dining MVP Min color temp` |
|
||||
| Max color temp number | `"Max color temp"` | `Dining MVP Max color temp` |
|
||||
|
||||
Existing `unique_id`s SHALL remain unchanged; the entity registry SHALL preserve existing `entity_id`s for any deployed install.
|
||||
|
||||
#### Scenario: Friendly names compose from device name + entity role
|
||||
|
||||
- **GIVEN** an AL profile is configured with display name "Dining MVP"
|
||||
- **WHEN** the integration is loaded
|
||||
- **THEN** the master switch's friendly name SHALL be exactly "Dining MVP"
|
||||
- **AND** the adapt-brightness switch's friendly name SHALL be exactly "Dining MVP Brightness"
|
||||
- **AND** the adapt-color switch's friendly name SHALL be exactly "Dining MVP Color"
|
||||
- **AND** the four range number entities' friendly names SHALL be "Dining MVP Min brightness", "Dining MVP Max brightness", "Dining MVP Min color temp", "Dining MVP Max color temp"
|
||||
|
||||
#### Scenario: Existing entity_ids survive the rename
|
||||
|
||||
- **GIVEN** an entity registry contains a pre-existing `switch.adaptive_lighting_adapt_brightness_dining_mvp_lights` owned by this integration
|
||||
- **WHEN** the integration is upgraded to a version that ships this `has_entity_name` change
|
||||
- **AND** HA reloads the config entry
|
||||
- **THEN** the entity's `entity_id` SHALL remain `switch.adaptive_lighting_adapt_brightness_dining_mvp_lights` (preserved by the registry via stable `unique_id`)
|
||||
- **AND** only the entity's friendly name SHALL update to follow the new composition
|
||||
87
openspec/changes/add-runtime-range-controls/tasks.md
Normal file
87
openspec/changes/add-runtime-range-controls/tasks.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<!--
|
||||
Annotations:
|
||||
R1–R7 = ADDED Requirements in specs/runtime-range-controls/spec.md
|
||||
R7 = "All AL entities use has_entity_name composition"
|
||||
MR4 = MODIFIED Requirement in specs/options-flow/spec.md (curve reads bounds from entities)
|
||||
D1–D11 = Decisions in design.md (D11 = entity-naming hygiene)
|
||||
polish = Quality/UX tasks captured during design review, not spec-driven
|
||||
|
||||
Build order: group 1 is platform foundation. Group 2 implements the entity class. Groups 3-5 wire reads, writes, and seeding. Group 6 retrofits has_entity_name on existing switches. Group 7 is tests, group 8 docs.
|
||||
-->
|
||||
|
||||
## 1. Platform foundation — `number.py` skeleton and `const.py` constants
|
||||
|
||||
- [x] 1.1 Add `Platform.NUMBER` to the `PLATFORMS` list in `__init__.py` so HA forwards `async_setup_entry` to the new platform. [R1]
|
||||
- [x] 1.2 In `const.py`, add a mapping `RANGE_ENTITIES` (or four explicit constants) covering the four entities: `unique_id` suffix, friendly-name slug, `native_min`, `native_max`, `step`, `unit`, `icon`. One source for both platform setup and tests. [R1, D5, D6]
|
||||
- [x] 1.3 Create `custom_components/adaptive_lighting/number.py` with an `async_setup_entry(hass, config_entry, async_add_entities)` that instantiates four entities (one per row in `RANGE_ENTITIES`) and calls `async_add_entities(entities)`. [R1]
|
||||
- [x] 1.4 Update the existing `device_info` block (or shared helper) so the new entities attach to the same `(DOMAIN, entry.entry_id)` device as the three switches. [R1]
|
||||
|
||||
## 2. Entity class — `RestoreNumber` subclass with seed logic
|
||||
|
||||
- [x] 2.1 Define `AdaptiveRangeNumber(RestoreNumber)` in `number.py` with `_attr_has_entity_name = True`, `_attr_mode = NumberMode.SLIDER`, and `_attr_should_poll = False`. Constructor takes `(entry, field_key, native_min, native_max, step, unit, icon)`. [R1, R7, D6, D11]
|
||||
- [x] 2.2 Implement `unique_id` property as `f"{entry.entry_id}_{field_key}"`. [R1, D5]
|
||||
- [x] 2.3 Set `_attr_name` on each instance to the role label per the D11 table: "Min brightness", "Max brightness", "Min color temp", "Max color temp". The device's name carries the profile context — HA composes the full friendly name automatically. [R7, D11]
|
||||
- [x] 2.4 Implement `async_added_to_hass` with the three-tier seed precedence: (a) prefer `entry.options[CONF_*]` if its value is newer than the restored state (compare via `RestoreNumber.async_get_last_number_data().native_value` against options), (b) restored state, (c) `entry.options[CONF_*]` as the first-creation fallback. [R2, D2, D3]
|
||||
- [x] 2.5 Implement `async_set_native_value(value)` to set `_attr_native_value`, call `async_write_ha_state()`, and return. The method SHALL NOT call `hass.config_entries.async_update_entry` (no write-through to options). [R4, D1, D2]
|
||||
|
||||
## 3. Curve math — read bounds from entities, fallback to options
|
||||
|
||||
- [x] 3.1 Add a helper `_get_runtime_range(hass, entry, field_key)` (in `switch.py` or a shared helper module) that does `state = hass.states.get(f"number.adaptive_lighting_{slugify(entry.title)}_{field_key}")` → returns `int(state.state)` if state is set and not unavailable, else `int(entry.options[CONF_*])`, logging the fallback at DEBUG. [R3, D8, D9]
|
||||
- [x] 3.2 In `AdaptiveSwitch._get_settings()` (or wherever `SunLightSettings` is constructed), replace the four `entry.options[CONF_*]` reads for `min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp` with calls to `_get_runtime_range(...)`. [R3, MR4, D1, D8]
|
||||
- [x] 3.3 Verify no other reads of these four CONF keys remain in the curve evaluation path (`brightness_pct`, `color_temp_kelvin`, `brightness_and_color`, `sun_position`). Other reads (e.g., the options-flow schema seeding) are intentionally untouched here. [R3, MR4]
|
||||
|
||||
## 4. Options flow — seed the 4 range fields from entity state
|
||||
|
||||
- [x] 4.1 Modify `_build_options_schema(current, ...)` in `config_flow.py` to accept the four range entity states (or read them inline via `hass.states.get(...)`). When an entity state is available, use it as the field default; otherwise fall back to the matching `entry.options[CONF_*]` value. [R6, D4]
|
||||
- [x] 4.2 In `async_step_init`, compute the four `current_*` values once (using the helper from 4.1), then pass them into the schema builder. [R6, D4]
|
||||
- [x] 4.3 Confirm the other ~14 fields still seed from `entry.options` unchanged. [R6]
|
||||
|
||||
## 5. Wire-up — `async_setup_entry` sequencing
|
||||
|
||||
- [x] 5.1 Confirm the order `async_setup_entry` calls `async_forward_entry_setups(entry, PLATFORMS)` is unchanged — both `switch` and `number` platforms set up in parallel. [R1]
|
||||
- [x] 5.2 Verify the entity-unavailable fallback (Decision 9) actually fires during the brief race window where the switch starts evaluating before the number platform has registered all four entities. This is the natural state during a fresh `async_setup_entry`; the curve math should not crash. [R3, D9]
|
||||
|
||||
## 6. Entity-naming hygiene — retrofit the three existing switches
|
||||
|
||||
- [x] 6.1 In `switch.py`, set `_attr_has_entity_name = True` on `AdaptiveSwitch`, `AdaptColorSwitch`, and `AdaptBrightnessSwitch`. [R7, D11]
|
||||
- [x] 6.2 Set `_attr_name` per the D11 table: `AdaptiveSwitch._attr_name = None` (master takes device name), `AdaptBrightnessSwitch._attr_name = "Brightness"`, `AdaptColorSwitch._attr_name = "Color"`. Delete any code that hand-composes "Adaptive Lighting …" into the friendly name. [R7, D11]
|
||||
- [x] 6.3 Confirm the shared `device_info` block sets `name = entry.title` (or `entry.data[CONF_NAME]`, whichever is the user-facing string). This is the anchor for the composed friendly names. [R7, D11]
|
||||
- [x] 6.4 Verify `unique_id`s are NOT changed by this group — only `_attr_name` and `_attr_has_entity_name`. The entity registry must keep existing entity_ids stable. [R7, D11]
|
||||
- [ ] 6.5 Manual check on live HA after deploy: pre-existing entity_ids unchanged (no duplicates, no broken automations), friendly names now read as "Dining MVP Brightness" / "Dining MVP Color" instead of "Adaptive Lighting Adapt Brightness dining_mvp_lights". [R7, D11]
|
||||
|
||||
## 7. Tests — `tests/test_number_platform.py`
|
||||
|
||||
- [x] 7.1 New test file `tests/test_number_platform.py` with autouse PHACC fixture from `conftest.py`. [R1]
|
||||
- [x] 7.2 Add test: creating a new config entry registers exactly four `number` entities owned by the entry. Assert the suffixes are `_min_brightness`, `_max_brightness`, `_min_color_temp`, `_max_color_temp`. [R1]
|
||||
- [x] 7.3 Add test: each of the four entities is attached to the same device as the profile's switches. [R1]
|
||||
- [x] 7.4 Add test: brightness entities expose `native_min_value=1`, `native_max_value=100`, `native_step=1`, `native_unit_of_measurement="%"`, `mode=NumberMode.SLIDER`. Color-temp entities expose `1000`/`10000`/`100`/`"K"`/`SLIDER`. [R1, D6]
|
||||
- [x] 7.5 Add test: `async_set_native_value` updates `state` but does not call `hass.config_entries.async_update_entry` (use a mock spy). [R4, D2]
|
||||
- [x] 7.6 Add test: slider change does not invoke `async_unload_entry` / `async_setup_entry`. [R4]
|
||||
- [x] 7.7 Add test: simulating an HA restart — pre-seed `RestoreNumber` state to 30 for `min_brightness`, set up the entry, assert entity state is 30 (not the default 5 from `entry.options`). [R2]
|
||||
- [x] 7.8 Add test: options-flow save with new range values triggers a reload, and after the reload the entity state reflects the just-saved values (not the previously-restored values). [R5, D3]
|
||||
- [x] 7.9 Add test: opening the options flow seeds the four range fields from `hass.states.get(<entity_id>).state`, not from `entry.options`. Set the entity to 80, leave options at 100, assert the flow's schema default is 80. [R6, D4]
|
||||
- [x] 7.10 Add test: opening the options flow when an entity is unavailable falls back to `entry.options[CONF_*]`. [R6, D9]
|
||||
- [x] 7.11 Add test: curve math reads runtime values — set `number.adaptive_lighting_<name>_max_brightness` to 70, set `entry.options[CONF_MAX_BRIGHTNESS]` to 100, run a curve evaluation at "peak day," assert the returned brightness is 70. [R3, MR4]
|
||||
- [x] 7.12 Add test: curve math fallback — make the entity `unavailable`, set `entry.options[CONF_MAX_BRIGHTNESS]` to 90, evaluate at peak day, assert brightness is 90 and a DEBUG log line names the missing entity. [R3, D9]
|
||||
|
||||
## 8. Translations and docs
|
||||
|
||||
- [x] 8.1 Add `entity.number.min_brightness.name`, `_max_brightness`, `_min_color_temp`, `_max_color_temp` keys to `strings.json` with plain-language labels ("Min brightness," "Max brightness," etc.). [R1, polish]
|
||||
- [x] 8.2 Mirror the additions in `translations/en.json`. Other locales out of scope (covered by `complete-i18n-translations` follow-up). [R1, polish]
|
||||
- [x] 8.3 Add an `entity.number.<key>.unit_of_measurement` mapping if HA's frontend requires it for slider display (verify against current HA — likely auto-derived from `native_unit_of_measurement`). [R1]
|
||||
- [x] 8.4 Add a short section to `README.md` under "What's new in 2.1" naming the four entities, explaining the slider-vs-options-flow split ("sliders tune live; options-flow sets defaults; saving the dialog resets the sliders to the saved values"), and showing a one-line Lovelace YAML snippet (`type: entities` with the four range entities). [R5, R6, D3, polish]
|
||||
- [x] 8.5 Append a `2.1.0-cdit.1` entry to `CHANGELOG.md` listing: 4 new entities per profile, RestoreNumber persistence, curve math now reads from entities, options-flow open seeds from entities, no reload on slider change. [polish]
|
||||
|
||||
## 9. Manual verification on live HA
|
||||
|
||||
- [ ] 9.1 Deploy to `homeassistant.onca-blenny.ts.net` via HACS. Verify the four `number.adaptive_lighting_*` entities appear under each of the 6 profiles' devices. [R1]
|
||||
- [ ] 9.2 Move a slider on one profile via the dashboard. Verify (a) no integration reload occurs (check Integration page → no "reloading" banner; entity IDs unchanged), (b) the next curve tick uses the new value (watch the master switch's `brightness_pct` attribute over ~90 s). [R3, R4]
|
||||
- [ ] 9.3 Open the options flow on the same profile. Verify the four range fields show the just-moved slider values, not the original setup defaults. [R6]
|
||||
- [ ] 9.4 Save the options flow with different values. Verify the sliders snap to the new values after reload. [R5, D3]
|
||||
- [ ] 9.5 Restart HA. Verify the slider values persist (RestoreNumber works). [R2]
|
||||
|
||||
## 10. Validation gate
|
||||
|
||||
- [x] 10.1 `openspec validate add-runtime-range-controls --strict` returns green. [polish]
|
||||
- [x] 10.2 `uv run pytest tests/test_number_platform.py` passes. Existing tests stay green (`uv run pytest`). [polish]
|
||||
- [x] 10.3 `./scripts/lint` clean. [polish]
|
||||
|
|
@ -196,4 +196,3 @@ On `async_setup_entry`, the integration SHALL scan the entity registry for entit
|
|||
- **WHEN** the integration runs the sleep-switch cleanup
|
||||
- **AND** another integration owns a similarly named entity (e.g., a user-created `switch.adaptive_lighting_sleep_mode_demo` template switch)
|
||||
- **THEN** that foreign entity SHALL NOT be removed from the entity registry
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue