Archive add-runtime-ramp-width: 15/15 + sync main specs

This commit is contained in:
Casey 2026-06-07 22:30:58 +02:00
commit e6e6be31df
8 changed files with 383 additions and 37 deletions

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-07

View file

@ -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 3007200 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=<live seconds>)` 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 `<entry_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._

View file

@ -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 5120, 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.

View file

@ -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_<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 + W` and `sunset_entity W`
- **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 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)

View file

@ -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 `<entry.entry_id>_<suffix>`. 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 `<entry.entry_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)

View file

@ -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)
- **D1D6** design decisions 16
## 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 `<entry_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). [R1R3]
## 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]

View file

@ -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_<name>_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_<name>_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_<name>_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

View file

@ -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 `<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.
@ -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 `<entry.entry_id>_<suffix>`. 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 `<entry.entry_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)