From e6e6be31df07484a462505ebf672236d39f4ba6b Mon Sep 17 00:00:00 2001 From: Casey Date: Sun, 7 Jun 2026 22:30:58 +0200 Subject: [PATCH] Archive add-runtime-ramp-width: 15/15 + sync main specs --- .../.openspec.yaml | 2 + .../design.md | 68 +++++++++++ .../proposal.md | 35 ++++++ .../specs/options-flow/spec.md | 52 +++++++++ .../specs/runtime-range-controls/spec.md | 90 +++++++++++++++ .../tasks.md | 37 ++++++ openspec/specs/options-flow/spec.md | 28 +++-- openspec/specs/runtime-range-controls/spec.md | 108 +++++++++++++----- 8 files changed, 383 insertions(+), 37 deletions(-) create mode 100644 openspec/changes/archive/2026-06-07-add-runtime-ramp-width/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-07-add-runtime-ramp-width/design.md create mode 100644 openspec/changes/archive/2026-06-07-add-runtime-ramp-width/proposal.md create mode 100644 openspec/changes/archive/2026-06-07-add-runtime-ramp-width/specs/options-flow/spec.md create mode 100644 openspec/changes/archive/2026-06-07-add-runtime-ramp-width/specs/runtime-range-controls/spec.md create mode 100644 openspec/changes/archive/2026-06-07-add-runtime-ramp-width/tasks.md diff --git a/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/.openspec.yaml b/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/.openspec.yaml new file mode 100644 index 00000000..11967fcb --- /dev/null +++ b/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-07 diff --git a/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/design.md b/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/design.md new file mode 100644 index 00000000..a86120e0 --- /dev/null +++ b/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/design.md @@ -0,0 +1,68 @@ +# add-runtime-ramp-width — design + +## Context + +`cdit-config-redesign` decision 3 hardcoded the tanh ramp half-width (`RAMP_HALF_WIDTH_SECONDS = 1800`) and explicitly rejected exposing it — "one less knob is genuinely better than one customizable knob." The 2026-06-07 seasonality exploration revised the picture: seasonal pacing (longer summer dusks) is a real want, the agreed home for seasonal *logic* is Node-RED via runtime entities ([[project-al-seasonality-decision]] memory), and ramp width is the only curve parameter without a runtime entity. The `runtime-range-controls` capability (implemented, archived 2026-05-17) established the full pattern: `number` platform, `RestoreNumber`, registry lookup by stable unique_id, per-tick reads with fallback, no reload on slider change. + +Since then, `anchor_sun_events()` (added in the 2026-06-07 day-anchoring fix) also consumes the half-width — its post-sunset ramp-tail window must match the curve's width or wide evening ramps get truncated when the `next_*` sensors flip to tomorrow. + +## Goals / Non-Goals + +**Goals:** +- One ramp half-width number entity per profile, live-effective on the next curve tick. +- Fully drivable from Node-RED (seasonal flows) and Lovelace, like the four range entities. +- The day-anchoring tail window always equals the curve's active width. + +**Non-Goals:** +- Per-event asymmetric widths (separate sunrise/sunset). No customer: summer-morning ramps run while the household sleeps. +- Decoupling CT from brightness curve shape (spec R4 coupling stands; separate tripwire). +- Any seasonal logic in the integration (day-length lerps etc.) — that lives in Node-RED. +- A config-flow field for the width. +- Winter-morning clock anchoring (separate tripwire). + +## Decisions + +### Decision 1: One width, both ramps + +A single value parameterizes both the sunrise and sunset tanh windows, exactly as the constant does today (`SunLightSettings.ramp_half_width_seconds` is already one field). Splitting would double the entity surface for a window nobody is awake to see, and Node-RED can't meaningfully drive a morning width either. **Rejected:** `sunrise_ramp_width` / `sunset_ramp_width` pair — revisit only if a real morning use case appears; adding a second entity later is backward-compatible. + +### Decision 2: Runtime entity only, constant fallback — no options-flow field + +The entity is the *only* surface for this knob. Decision 3's rationale ("advanced fields rot in config dialogs") still holds for the dialog; what changed is the existence of a better surface. Consequences that diverge from the four range entities: + +- Restore precedence is simply: restored value → `DEFAULT_RAMP_HALF_WIDTH_MIN` (30). There is no `entry.options` tier because no option exists. +- The options-flow seeding/propagation requirements (seed-from-entity on open, recreate-with-saved-value on save) do **not** apply. +- Unavailable-entity fallback in the curve path is the constant, logged at `DEBUG`, mirroring `_get_runtime_range` shape. + +**Rejected:** adding a parallel `CONF_RAMP_HALF_WIDTH` option field — would reopen the dialog-bloat question decision 3 settled, and adds a second source of truth for zero benefit (NR doesn't read options). + +### Decision 3: Entity unit is minutes; curve consumes seconds + +`native_min=5`, `native_max=120`, `step=1`, `unit="min"`, `SLIDER`. Humans and Node-RED flows think in minutes; the curve math takes seconds. Conversion (`× 60`, int) happens at the single read site in `switch.py`. Bounds rationale: below ~5 min the adapt interval makes the ramp visibly steppy; above 120 min the two ramps would consume 4 h of curve and start crowding short winter days (Dec day length ≈ 7.8 h leaves a 3.8 h plateau at max width — still sane, which is why 120 is the cap and not less). **Rejected:** exposing seconds (matches code but hostile defaults UX: slider 300–7200 step 60); exposing *total* transition duration (2×) — breaks the ± semantics every existing artifact uses. + +### Decision 4: Reuse the registry-lookup pattern, dedicated read helper + +A `_get_runtime_ramp_width_seconds()` (or equivalent) on the switch follows `_get_runtime_range`'s structure — entity-registry lookup by `f"{entry_id}_ramp_half_width"`, state cast, fallback — but returns seconds and falls back to the constant rather than a config snapshot. `sun_light_settings` (rebuilt per tick) feeds it into `SunLightSettings.ramp_half_width_seconds`; the dataclass and curve math need **zero** changes (already parameterized). **Rejected:** generalizing `_get_runtime_range` with optional fallback/conversion params — two call patterns through one function obscures both. + +### Decision 5: `_today_sun_events` uses the live width for anchoring + +`anchor_sun_events(..., half_width=)` replaces the constant. If the curve ramps for 60 min after sunset but the anchor window is still 30, the down-ramp snaps to minimum halfway through — the exact bug class fixed on 2026-06-07. One value must flow to both consumers within a tick. Implementation note: read the width once per evaluation and pass the same number to both the curve settings and the anchor call; do not perform two independent entity reads that could race a slider write mid-tick. + +### Decision 6: Naming and identity + +Follows the `has_entity_name` composition requirement: `_attr_name = "Ramp half-width"`, `suggested_object_id = "ramp_half_width"`, unique_id `_ramp_half_width`, same device as the switches. Friendly name for profile "Dining MVP": `Dining MVP Ramp half-width`. Entity description/tooltip notes the total transition is 2× this value. **Rejected:** "Transition" naming — ambiguous against HA's per-service-call `transition` attribute, which this integration also uses. + +## Risks / Trade-offs + +- [Wide width on short winter days squeezes the max plateau] → capped at 120 min; at 52°N winter day length (~7.8 h) the plateau is still ≥ 3.8 h. The curve math degrades gracefully (plateau branch simply never matches) even if bounds change later. +- [Two consumers of one value drifting (curve vs anchor)] → Decision 5: single read per tick, passed to both; regression test asserts a widened ramp completes past the sensor flip. +- [NR writes a float (e.g. 42.5)] → step=1 hints the UI; read path casts via `int(float(state))` like the range reads; sub-minute precision is meaningless here. +- [Restored stale width surprises after long downtime] → acceptable; identical semantics to the four range entities, and the default is one service call away. + +## Migration Plan + +Additive. Existing config entries load unchanged; the new entity appears with default 30 (= prior constant) so behavior is identical until someone moves it. Minor version bump (`2.5.0-cdit.1` — new entity surface), normal HACS pre-release flow. Rollback = downgrade; the orphaned registry entry for the number is harmless and HA prunes it on next entry reload. + +## Open Questions + +_None — all decisions settled during the 2026-06-07 exploration._ diff --git a/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/proposal.md b/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/proposal.md new file mode 100644 index 00000000..68574467 --- /dev/null +++ b/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/proposal.md @@ -0,0 +1,35 @@ +# add-runtime-ramp-width + +## Why + +The curve's ramp half-width is hardcoded at 30 minutes (`RAMP_HALF_WIDTH_SECONDS = 1800`, design decision 3 of `cdit-config-redesign`). The 2026-06-07 seasonality exploration concluded that seasonal adaptation (slower summer dusks, brisker winter transitions) belongs in Node-RED via runtime entities — and ramp width is the one curve parameter Node-RED cannot reach today. Exposing it as a fifth runtime number completes the orchestration surface without adding any seasonal logic to the integration. + +## What Changes + +- **Each AL profile gains a fifth `number` entity**: ramp half-width, in minutes (default 30, bounds 5–120, step 1, slider). One value drives **both** the sunrise and sunset ramps — no per-event asymmetry (summer-morning ramps happen while the household sleeps; there is no customer for a split). +- **No new options-flow field.** Decision 3's "no knob in the config dialog" stands; the knob exists only as an entity, intended to be driven seasonally from Node-RED (or tuned ad hoc from a dashboard). Fallback when the entity is unavailable is the `RAMP_HALF_WIDTH_SECONDS` constant — not `entry.options`, because no option exists. +- **Curve evaluation reads the live value per tick**, converted minutes → seconds, through the same registry-lookup mechanism as the four range entities. +- **Day-anchoring stays consistent with the curve**: `anchor_sun_events()` (the post-sunset ramp-tail window) receives the same live half-width instead of the constant, so a widened evening ramp still completes after the sun sensors flip to tomorrow. + +## Capabilities + +### New Capabilities + +_None — this extends an existing capability._ + +### Modified Capabilities + +- `runtime-range-controls`: the "exactly four runtime range entities" requirement becomes five; the new entity has constant-fallback semantics (no `entry.options` mirror) and is exempt from the options-flow seeding/propagation requirements that apply to the four range fields. +- `options-flow`: requirement R4's "hardcoded half-width of 30 minutes (`RAMP_HALF_WIDTH_SECONDS = 1800`)" becomes "runtime half-width from the profile's ramp-width entity, defaulting to 30 minutes". + +## Impact + +- **`custom_components/adaptive_lighting/number.py`** — fifth entity class/descriptor; minutes unit; restore semantics identical to the existing four minus the options seeding. +- **`custom_components/adaptive_lighting/const.py`** — `CONF_RAMP_HALF_WIDTH`-style field key + default; `RAMP_HALF_WIDTH_SECONDS` retained as the fallback default. +- **`custom_components/adaptive_lighting/switch.py`** — `sun_light_settings` rebuild reads the live half-width (minutes → seconds); `_today_sun_events` passes the same live value to `anchor_sun_events`. +- **`tests/test_number_platform.py`** — entity creation/bounds/restore for the fifth entity; constant fallback when unavailable. +- **`tests/test_color_and_brightness.py`** — unaffected (pure math already parameterized by `half_width`). +- **No manifest-breaking change** — additive entity, existing config entries load unchanged. Patch-level version bump. +- **No new dependencies.** + +**Sequencing**: builds directly on the implemented `runtime-range-controls` capability; no other active changes exist. diff --git a/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/specs/options-flow/spec.md b/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/specs/options-flow/spec.md new file mode 100644 index 00000000..517dc44d --- /dev/null +++ b/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/specs/options-flow/spec.md @@ -0,0 +1,52 @@ +# options-flow — delta for add-runtime-ramp-width + +## 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 half-width `W` at each event. `W` SHALL be read at evaluation time from the profile's ramp half-width runtime entity as defined in the `runtime-range-controls` capability (default 30 minutes, falling back to `RAMP_HALF_WIDTH_SECONDS = 1800` when the entity is unavailable). The brightness value at time `t` SHALL follow: + +- `t ≤ t_sunrise − W`: value = configured minimum +- `t_sunrise − W < t < t_sunrise + W`: value = tanh-interpolated minimum → maximum +- `t_sunrise + W ≤ t ≤ t_sunset − W`: value = configured maximum +- `t_sunset − W < t < t_sunset + W`: value = tanh-interpolated maximum → minimum +- `t ≥ t_sunset + W`: 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. Both channels SHALL use the same `W` within a single evaluation. + +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 `W` before the `sunrise_entity` timestamp +- **THEN** the computed brightness SHALL equal the current value of `number.adaptive_lighting__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 + W` and `sunset_entity − W` +- **THEN** the computed brightness SHALL equal the current value of `number.adaptive_lighting__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__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__min_brightness` is at 30 +- **WHEN** the curve is evaluated at a time before `sunrise_entity − W` +- **THEN** the computed brightness SHALL equal 30 (the entity state), not 5 (`entry.options`) + +#### Scenario: Curve width follows the ramp half-width entity + +- **GIVEN** the profile's ramp half-width entity is at 60 +- **WHEN** the curve is evaluated 45 minutes before the `sunset_entity` timestamp +- **THEN** the computed brightness SHALL lie strictly between the configured minimum and maximum (inside the widened down-ramp) +- **AND** with the entity at 30 the same instant would have produced the configured maximum (outside the default-width ramp) diff --git a/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/specs/runtime-range-controls/spec.md b/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/specs/runtime-range-controls/spec.md new file mode 100644 index 00000000..3442ee8a --- /dev/null +++ b/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/specs/runtime-range-controls/spec.md @@ -0,0 +1,90 @@ +# runtime-range-controls — delta for add-runtime-ramp-width + +## RENAMED Requirements + +- FROM: `### Requirement: Each AL profile exposes four runtime range entities` +- TO: `### Requirement: Each AL profile exposes five runtime curve entities` + +## MODIFIED Requirements + +### Requirement: Each AL profile exposes five runtime curve entities + +For each Adaptive Lighting config entry, the integration SHALL create exactly five `number` entities: one for each of `min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp`, and one for the curve's ramp half-width. 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` | +| `ramp_half_width` | `_ramp_half_width` | 5 | 120 | 1 | `min` | `SLIDER` | + +The full unique_id SHALL be `_`. The device record SHALL be the same `(DOMAIN, entry.entry_id)` identifier used by the profile's switches. + +The ramp half-width entity SHALL set `_attr_name = "Ramp half-width"` and `suggested_object_id = "ramp_half_width"`, composing under `has_entity_name` exactly like the four range entities (friendly name for a profile named `Dining MVP`: `Dining MVP Ramp half-width`). + +#### Scenario: A new config entry produces five number entities + +- **WHEN** the user creates a new Adaptive Lighting config entry +- **AND** `async_setup_entry` completes +- **THEN** the entity registry SHALL contain five `number` entities owned by this entry +- **AND** their unique_ids SHALL end with `_min_brightness`, `_max_brightness`, `_min_color_temp`, `_max_color_temp`, `_ramp_half_width` respectively +- **AND** all five 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` + +#### Scenario: Ramp half-width entity declares minute bounds + +- **WHEN** the ramp half-width number entity is inspected +- **THEN** it SHALL declare `native_min_value=5`, `native_max_value=120`, `native_step=1`, `native_unit_of_measurement="min"`, and `mode=NumberMode.SLIDER` + +## ADDED Requirements + +### Requirement: Ramp half-width entity drives the curve transition width + +The ramp half-width entity SHALL be the only user-facing surface for the curve's ramp half-width; the integration SHALL NOT add a corresponding options-flow field, and the entity SHALL be exempt from the options-flow seeding and propagation requirements that govern the four range entities (it has no `entry.options` mirror). + +The entity SHALL extend `RestoreNumber`. On `async_added_to_hass` it SHALL prefer, in order: (1) the restored value from `RestoreNumber.async_get_last_number_data()`, (2) the default of 30 minutes (equal to the prior `RAMP_HALF_WIDTH_SECONDS` constant of 1800 seconds). + +On every curve evaluation tick, the integration SHALL read the entity's state via the entity-registry lookup pattern (unique_id `_ramp_half_width`), convert minutes to seconds, and use the result as `ramp_half_width_seconds` for that tick's curve computation. The value SHALL be read once per tick and the same number SHALL be supplied to both the curve evaluation and the sun-event day-anchoring (`anchor_sun_events`'s `half_width` parameter). When the entity is missing, `unavailable`, or `unknown`, the integration SHALL fall back to `RAMP_HALF_WIDTH_SECONDS` and SHALL log the fallback at `DEBUG` level. + +Moving the slider SHALL NOT trigger an integration reload, matching the no-reload requirement of the four range entities. + +#### Scenario: New entity defaults to 30 minutes + +- **WHEN** a profile is created and the integration loads +- **THEN** the ramp half-width entity's state SHALL be 30 +- **AND** the curve SHALL behave identically to the previous hardcoded 1800-second ramp + +#### Scenario: Slider change takes effect on the next curve tick + +- **GIVEN** the ramp half-width entity is at 30 +- **WHEN** the user (or a Node-RED flow) sets it to 60 +- **AND** the next curve evaluation tick fires +- **THEN** the brightness and color-temperature ramps SHALL each span sunrise ± 3600 s and sunset ± 3600 s +- **AND** `async_unload_entry` SHALL NOT be invoked + +#### Scenario: Unavailable entity falls back to the constant + +- **GIVEN** the ramp half-width entity is not yet available (e.g., early setup race) +- **WHEN** the curve evaluation runs +- **THEN** the curve SHALL be computed with `ramp_half_width_seconds = 1800` +- **AND** a `DEBUG` log entry SHALL be emitted naming the missing entity + +#### Scenario: Value survives an HA restart + +- **GIVEN** the user has set the ramp half-width entity to 75 +- **WHEN** Home Assistant is restarted +- **THEN** the entity's state SHALL be 75 once it finishes loading + +#### Scenario: Widened evening ramp completes past the sun-sensor flip + +- **GIVEN** the ramp half-width entity is at 60 +- **AND** the configured sunset entity has flipped to tomorrow's event (as `sensor.sun_next_setting` does at the sunset moment) +- **WHEN** the curve is evaluated 45 minutes after today's sunset +- **THEN** the day-anchoring SHALL still resolve the pair to today's events +- **AND** the computed brightness SHALL lie strictly between the profile's minimum and maximum (the down-ramp is still in progress, not snapped to minimum) diff --git a/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/tasks.md b/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/tasks.md new file mode 100644 index 00000000..81f0b98d --- /dev/null +++ b/openspec/changes/archive/2026-06-07-add-runtime-ramp-width/tasks.md @@ -0,0 +1,37 @@ +# add-runtime-ramp-width — tasks + +Annotation legend — requirements in this change's spec deltas, decisions in `design.md`: +- **R1** runtime-range-controls / "Each AL profile exposes five runtime curve entities" (MODIFIED) +- **R2** runtime-range-controls / "Ramp half-width entity drives the curve transition width" (ADDED) +- **R3** options-flow / "Brightness and color temperature follow a synthetic tanh curve" (MODIFIED) +- **D1–D6** design decisions 1–6 + +## 1. Constants + +- [x] 1.1 Add the ramp half-width field key constant (e.g. `CONF_RAMP_HALF_WIDTH = "ramp_half_width"`) and `DEFAULT_RAMP_HALF_WIDTH_MIN = 30` to `const.py`; keep `RAMP_HALF_WIDTH_SECONDS = 1800` as the curve-path fallback. Do NOT add the field to `VALIDATION_TUPLES` (no options-flow surface). [R2, D2] + +## 2. Number platform + +- [x] 2.1 Add the fifth entity to `number.py` following the existing descriptor/class pattern: unique_id `_ramp_half_width`, `native_min_value=5`, `native_max_value=120`, `native_step=1`, `native_unit_of_measurement="min"`, `mode=SLIDER`, same device record as the switches. [R1, D3] +- [x] 2.2 Naming: `_attr_has_entity_name = True`, `_attr_name = "Ramp half-width"`, `suggested_object_id = "ramp_half_width"`; entity description notes total transition = 2× the value. [R1, D6] +- [x] 2.3 Restore semantics: `RestoreNumber`; on `async_added_to_hass` prefer restored value, else default 30. No `entry.options` tier, no write-back, no reload on change. Exclude this entity from any options-seeding code paths that iterate the four range fields. [R2, D2] + +## 3. Curve read path + +- [x] 3.1 Add `_get_runtime_ramp_width_seconds()` to `AdaptiveSwitch` in `switch.py`: entity-registry lookup by unique_id, state cast via `int(float(state)) * 60`, fallback to `RAMP_HALF_WIDTH_SECONDS` with a `DEBUG` log naming the missing entity. Dedicated helper — do not generalize `_get_runtime_range`. [R2, D4] +- [x] 3.2 Wire `sun_light_settings` (per-tick rebuild) to feed the live value into `SunLightSettings.ramp_half_width_seconds`. No changes to `color_and_brightness.py` (already parameterized). [R3, D4] +- [x] 3.3 Read the width once per evaluation and pass that same value to both the curve settings and `anchor_sun_events(..., half_width=...)` in `_today_sun_events` (replacing the `RAMP_HALF_WIDTH_SECONDS` constant there). [R2, D5] + +## 4. Tests + +- [x] 4.1 `test_number_platform.py`: new entry creates five number entities; fifth has minute bounds (5/120/1/"min"/SLIDER), correct unique_id suffix, and shares the switches' device. [R1] +- [x] 4.2 `test_number_platform.py`: fifth entity defaults to 30 on first creation; restored value wins after a simulated restart (mirror the existing restore tests). [R2] +- [x] 4.3 `test_number_platform.py` (or switch-level test): with the entity set to 60, the next curve evaluation uses 3600 s half-width; with the entity unavailable, evaluation falls back to 1800 s and logs at DEBUG; slider change does not reload the entry. [R2, R3] +- [x] 4.4 Regression test for anchor consistency: width 60, both `next_*` sensors flipped to tomorrow, evaluation 45 min after today's sunset → pair anchors to today and brightness is strictly between min and max (uses `anchor_sun_events` with the live width). [R2, D5] +- [x] 4.5 Full suite green: `uv run pytest` (all existing tests must pass unmodified — default behavior is unchanged at width 30). [R1–R3] + +## 5. Release + +- [x] 5.1 `./scripts/lint` clean. [—] +- [x] 5.2 Bump `manifest.json` to `2.5.0-cdit.1` (additive entity surface → minor). [D2] +- [x] 5.3 Note the new entity + NR-seasonality intent in `README.md`'s fork-features section (one short paragraph). [D2] diff --git a/openspec/specs/options-flow/spec.md b/openspec/specs/options-flow/spec.md index e511778d..ee2c0c46 100644 --- a/openspec/specs/options-flow/spec.md +++ b/openspec/specs/options-flow/spec.md @@ -85,21 +85,21 @@ The integration SHALL read sunrise and sunset event timestamps from two user-con ### 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: +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 half-width `W` at each event. `W` SHALL be read at evaluation time from the profile's ramp half-width runtime entity as defined in the `runtime-range-controls` capability (default 30 minutes, falling back to `RAMP_HALF_WIDTH_SECONDS = 1800` when the entity is unavailable). 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 +- `t ≤ t_sunrise − W`: value = configured minimum +- `t_sunrise − W < t < t_sunrise + W`: value = tanh-interpolated minimum → maximum +- `t_sunrise + W ≤ t ≤ t_sunset − W`: value = configured maximum +- `t_sunset − W < t < t_sunset + W`: value = tanh-interpolated maximum → minimum +- `t ≥ t_sunset + W`: 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 same curve shape SHALL be applied to color temperature using `min_color_temp` and `max_color_temp` as the curve bounds. Both channels SHALL use the same `W` within a single evaluation. 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 +- **WHEN** the current time is more than `W` before the `sunrise_entity` timestamp - **THEN** the computed brightness SHALL equal the current value of `number.adaptive_lighting__min_brightness` #### Scenario: Brightness is exactly at the midpoint at the sunrise event @@ -110,7 +110,7 @@ The four bound values (`min_brightness`, `max_brightness`, `min_color_temp`, `ma #### Scenario: Brightness is at maximum during the day -- **WHEN** the current time is between `sunrise_entity + 30min` and `sunset_entity − 30min` +- **WHEN** the current time is between `sunrise_entity + W` and `sunset_entity − W` - **THEN** the computed brightness SHALL equal the current value of `number.adaptive_lighting__max_brightness` #### Scenario: Color temperature follows the same curve shape @@ -122,9 +122,16 @@ The four bound values (`min_brightness`, `max_brightness`, `min_color_temp`, `ma - **GIVEN** `entry.options[CONF_MIN_BRIGHTNESS]` is 5 - **AND** `number.adaptive_lighting__min_brightness` is at 30 -- **WHEN** the curve is evaluated at a time before `sunrise_entity − 1800s` +- **WHEN** the curve is evaluated at a time before `sunrise_entity − W` - **THEN** the computed brightness SHALL equal 30 (the entity state), not 5 (`entry.options`) +#### Scenario: Curve width follows the ramp half-width entity + +- **GIVEN** the profile's ramp half-width entity is at 60 +- **WHEN** the curve is evaluated 45 minutes before the `sunset_entity` timestamp +- **THEN** the computed brightness SHALL lie strictly between the configured minimum and maximum (inside the widened down-ramp) +- **AND** with the entity at 30 the same instant would have produced the configured maximum (outside the default-width ramp) + ### Requirement: All configurable fields use native HA selectors Every field in the options dialog SHALL be rendered using a class from `homeassistant.helpers.selector`. The integration SHALL NOT use bare voluptuous primitive types (such as `vol.Coerce(int)` or custom `int_between`) as schema values for user-facing fields. Each field type SHALL be backed by the selector specified below. @@ -257,3 +264,4 @@ 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 + diff --git a/openspec/specs/runtime-range-controls/spec.md b/openspec/specs/runtime-range-controls/spec.md index e0bca606..b025ccf0 100644 --- a/openspec/specs/runtime-range-controls/spec.md +++ b/openspec/specs/runtime-range-controls/spec.md @@ -3,33 +3,6 @@ ## Purpose TBD - created by archiving change add-runtime-range-controls. Update Purpose after archive. ## 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 `_`. 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. @@ -142,3 +115,84 @@ Existing `unique_id`s SHALL remain unchanged; the entity registry SHALL preserve - **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 + +### Requirement: Each AL profile exposes five runtime curve entities + +For each Adaptive Lighting config entry, the integration SHALL create exactly five `number` entities: one for each of `min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp`, and one for the curve's ramp half-width. 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` | +| `ramp_half_width` | `_ramp_half_width` | 5 | 120 | 1 | `min` | `SLIDER` | + +The full unique_id SHALL be `_`. The device record SHALL be the same `(DOMAIN, entry.entry_id)` identifier used by the profile's switches. + +The ramp half-width entity SHALL set `_attr_name = "Ramp half-width"` and `suggested_object_id = "ramp_half_width"`, composing under `has_entity_name` exactly like the four range entities (friendly name for a profile named `Dining MVP`: `Dining MVP Ramp half-width`). + +#### Scenario: A new config entry produces five number entities + +- **WHEN** the user creates a new Adaptive Lighting config entry +- **AND** `async_setup_entry` completes +- **THEN** the entity registry SHALL contain five `number` entities owned by this entry +- **AND** their unique_ids SHALL end with `_min_brightness`, `_max_brightness`, `_min_color_temp`, `_max_color_temp`, `_ramp_half_width` respectively +- **AND** all five 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` + +#### Scenario: Ramp half-width entity declares minute bounds + +- **WHEN** the ramp half-width number entity is inspected +- **THEN** it SHALL declare `native_min_value=5`, `native_max_value=120`, `native_step=1`, `native_unit_of_measurement="min"`, and `mode=NumberMode.SLIDER` + +### Requirement: Ramp half-width entity drives the curve transition width + +The ramp half-width entity SHALL be the only user-facing surface for the curve's ramp half-width; the integration SHALL NOT add a corresponding options-flow field, and the entity SHALL be exempt from the options-flow seeding and propagation requirements that govern the four range entities (it has no `entry.options` mirror). + +The entity SHALL extend `RestoreNumber`. On `async_added_to_hass` it SHALL prefer, in order: (1) the restored value from `RestoreNumber.async_get_last_number_data()`, (2) the default of 30 minutes (equal to the prior `RAMP_HALF_WIDTH_SECONDS` constant of 1800 seconds). + +On every curve evaluation tick, the integration SHALL read the entity's state via the entity-registry lookup pattern (unique_id `_ramp_half_width`), convert minutes to seconds, and use the result as `ramp_half_width_seconds` for that tick's curve computation. The value SHALL be read once per tick and the same number SHALL be supplied to both the curve evaluation and the sun-event day-anchoring (`anchor_sun_events`'s `half_width` parameter). When the entity is missing, `unavailable`, or `unknown`, the integration SHALL fall back to `RAMP_HALF_WIDTH_SECONDS` and SHALL log the fallback at `DEBUG` level. + +Moving the slider SHALL NOT trigger an integration reload, matching the no-reload requirement of the four range entities. + +#### Scenario: New entity defaults to 30 minutes + +- **WHEN** a profile is created and the integration loads +- **THEN** the ramp half-width entity's state SHALL be 30 +- **AND** the curve SHALL behave identically to the previous hardcoded 1800-second ramp + +#### Scenario: Slider change takes effect on the next curve tick + +- **GIVEN** the ramp half-width entity is at 30 +- **WHEN** the user (or a Node-RED flow) sets it to 60 +- **AND** the next curve evaluation tick fires +- **THEN** the brightness and color-temperature ramps SHALL each span sunrise ± 3600 s and sunset ± 3600 s +- **AND** `async_unload_entry` SHALL NOT be invoked + +#### Scenario: Unavailable entity falls back to the constant + +- **GIVEN** the ramp half-width entity is not yet available (e.g., early setup race) +- **WHEN** the curve evaluation runs +- **THEN** the curve SHALL be computed with `ramp_half_width_seconds = 1800` +- **AND** a `DEBUG` log entry SHALL be emitted naming the missing entity + +#### Scenario: Value survives an HA restart + +- **GIVEN** the user has set the ramp half-width entity to 75 +- **WHEN** Home Assistant is restarted +- **THEN** the entity's state SHALL be 75 once it finishes loading + +#### Scenario: Widened evening ramp completes past the sun-sensor flip + +- **GIVEN** the ramp half-width entity is at 60 +- **AND** the configured sunset entity has flipped to tomorrow's event (as `sensor.sun_next_setting` does at the sunset moment) +- **WHEN** the curve is evaluated 45 minutes after today's sunset +- **THEN** the day-anchoring SHALL still resolve the pair to today's events +- **AND** the computed brightness SHALL lie strictly between the profile's minimum and maximum (the down-ramp is still in progress, not snapped to minimum) +