mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-17 01:04:05 +02:00
Implement add-lux-target: reduce-only ambient lux gate
Optional per-profile lux sensor binding that dims lights when daylight alone exceeds a user-set target (factor = target/current). Lights turn off entirely below min_brightness. Two config fields, live lux reading in the options flow, two conditional output sensors, sensor state subscription with 5pp significance guard. 26/26 tasks, 138 tests green.
This commit is contained in:
parent
d31d1928e5
commit
11fe23253c
18 changed files with 1047 additions and 19 deletions
2
openspec/changes/add-lux-target/.openspec.yaml
Normal file
2
openspec/changes/add-lux-target/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-05-24
|
||||
160
openspec/changes/add-lux-target/design.md
Normal file
160
openspec/changes/add-lux-target/design.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
## Context
|
||||
|
||||
The CDiT Adaptive Lighting fork computes brightness from a tanh sun-curve (`color_and_brightness.py`) and dispatches it to lights every `interval` seconds via the adapt loop in `switch.py`. The curve is purely time-based — it has no awareness of ambient light conditions. This design adds an optional lux-feedback gate that can only *reduce* brightness when a room is already bright enough from daylight. The gate sits between the curve output and the service-data assembly, touching a narrow slice of the adapt path.
|
||||
|
||||
Existing infrastructure this builds on:
|
||||
- **`OUTPUT_SENSORS`** in `const.py` — declarative list driving `sensor.py` entity creation. Adding entries here is the established pattern (see `add-output-sensors`).
|
||||
- **Sectioned options flow** in `config_flow.py` — collapsed sections, conditional field visibility (`send_split_delay` pattern), `EntitySelectorConfig` with `device_class` filtering.
|
||||
- **`VALIDATION_TUPLES`** in `const.py` — single source for field validation and YAML schema.
|
||||
- **Dispatcher signal** `SIGNAL_OUTPUTS_UPDATED` — already wakes output sensors on each curve tick.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Reduce energy waste when daylight already meets or exceeds the user's comfort threshold.
|
||||
- Keep configuration minimal: two fields (`lux_sensor`, `target_lux`), no new concepts.
|
||||
- Self-stabilising — no oscillation, no feedback spiral, no tuning required.
|
||||
- Zero-impact on profiles without a lux sensor configured.
|
||||
- Show the user their current lux reading in the config flow so they can set a sensible target.
|
||||
- Turn lights off entirely when their contribution would be negligible.
|
||||
|
||||
**Non-Goals:**
|
||||
- Full closed-loop illuminance control (boosting lights when room is too dark). The curve already handles that adequately; adding a boost path introduces the feedback spiral.
|
||||
- Per-light lux sensors. One sensor per AL profile is sufficient — rooms typically have one ambient-light characteristic.
|
||||
- Adjusting color temperature based on lux. Color temp is circadian, not energy-related.
|
||||
- Hysteresis or PID control. The `1/ratio` reduction is inherently smooth and self-stabilising; adding control-theory complexity is not warranted for the problem size.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Reduce-only gate, never boost
|
||||
|
||||
**Choice**: The lux gate can only multiply brightness by a factor in `(0, 1]`. When `current_lux ≤ target_lux`, the factor is 1.0 (pass-through).
|
||||
|
||||
**Rejected alternative**: Bidirectional offset (boost when too dark, reduce when too bright). This creates positive feedback — lights turn on, lux rises, integration reduces, lux drops, integration boosts. The reduce-only design avoids this entirely because reducing brightness can only lower lux, which relaxes the gate. Self-stabilising by construction.
|
||||
|
||||
### D2: Reduction function is `target_lux / current_lux`
|
||||
|
||||
**Choice**: `factor = target_lux / current_lux` when `current_lux > target_lux`, else `1.0`.
|
||||
|
||||
Properties:
|
||||
- At `current = 2 × target`, factor = 0.50 (halve brightness).
|
||||
- At `current = 10 × target`, factor = 0.10 (10% brightness).
|
||||
- Monotonically decreasing, smooth, no discontinuities.
|
||||
- Physically intuitive: if you have twice the light you need, halving the artificial contribution is the right response.
|
||||
|
||||
**Rejected alternatives**:
|
||||
- `1 / (1 + ln(ratio))` — gentler curve, but less energy-efficient and harder to reason about.
|
||||
- `1 / ratio²` — too aggressive; at 1.5× overshoot the lights are already at 44%.
|
||||
- Linear ramp with cutoff — discontinuity at the cutoff threshold.
|
||||
|
||||
### D3: Auto-off below `min_brightness`
|
||||
|
||||
**Choice**: When `curve_brightness × factor < min_brightness`, send `light.turn_off` instead of `light.turn_on` with a tiny brightness value. When lux drops back below target on the next interval tick, the normal adapt cycle turns lights back on.
|
||||
|
||||
**Rationale**: `min_brightness` already expresses "the lowest I ever want my lights." If daylight is so abundant that the artificial contribution would be below that floor, the lights are contributing effectively nothing — turning off is the logical conclusion and saves the most energy.
|
||||
|
||||
**No new setting needed**: reuses `min_brightness` as the threshold.
|
||||
|
||||
### D4: Two config fields, one new section
|
||||
|
||||
**Choice**:
|
||||
- `lux_sensor`: `EntitySelector(domain="sensor", device_class="illuminance")`. Optional, default `""` (empty string = feature disabled).
|
||||
- `target_lux`: `NumberSelector(min=1, max=10000, step=10, unit="lx", mode=BOX)`. Only shown when `lux_sensor` is populated (same conditional pattern as `send_split_delay` / `separate_turn_on_commands`).
|
||||
|
||||
Both live in a new "Ambient lux" section, collapsed by default. The section description uses `description_placeholders` to show the sensor's current reading when configured: `"Your sensor currently reads: {current_lux}"`.
|
||||
|
||||
**Rejected alternative**: putting lux fields in the existing "Daytime curve" section. These are conceptually separate — the curve sets intent, the lux gate adjusts for reality. A dedicated section with its own description keeps the mental model clean.
|
||||
|
||||
### D5: Live lux reading in config flow via `description_placeholders`
|
||||
|
||||
**Choice**: In `OptionsFlowHandler.async_step_init`, before calling `async_show_form`, read the configured lux sensor's state and pass it into `description_placeholders`:
|
||||
|
||||
```python
|
||||
lux_reading = "—"
|
||||
sensor_id = current.get(CONF_LUX_SENSOR)
|
||||
if sensor_id:
|
||||
state = self.hass.states.get(sensor_id)
|
||||
if state and state.state not in ("unavailable", "unknown"):
|
||||
lux_reading = f"{state.state} lx"
|
||||
```
|
||||
|
||||
Section description in `strings.json`: `"Save energy when daylight is bright enough. Your sensor currently reads: {current_lux}."` — where `{current_lux}` is filled by the placeholder. When no sensor is configured, the placeholder shows "—".
|
||||
|
||||
**Limitation**: On the very first configuration (sensor not yet saved), the reading won't appear until the user saves and reopens options. Acceptable — it's a one-time thing.
|
||||
|
||||
### D6: `lux_reduce` as a pure function in `color_and_brightness.py`
|
||||
|
||||
**Choice**: Add a single pure function:
|
||||
|
||||
```python
|
||||
def lux_reduce(
|
||||
curve_brightness: float,
|
||||
target_lux: int,
|
||||
current_lux: float,
|
||||
min_brightness: int,
|
||||
) -> float | None:
|
||||
"""Apply reduce-only lux gate to curve brightness.
|
||||
|
||||
Returns adjusted brightness, or None if lights should turn off.
|
||||
"""
|
||||
if current_lux <= target_lux:
|
||||
return curve_brightness
|
||||
adjusted = curve_brightness * (target_lux / current_lux)
|
||||
if adjusted < min_brightness:
|
||||
return None
|
||||
return adjusted
|
||||
```
|
||||
|
||||
Lives in `color_and_brightness.py` alongside the existing curve math. No class, no state — just a function that takes numbers and returns a number. Easy to unit-test in isolation.
|
||||
|
||||
**Rejected alternative**: method on `SunLightSettings`. That dataclass is intentionally HA-free (no sensor access). The lux reduction depends on live sensor state, so it belongs in the call chain between curve output and service-data assembly, not inside the curve math itself.
|
||||
|
||||
### D7: Two new output sensors: `ambient_lux` and `lux_reduction`
|
||||
|
||||
**Choice**: Add two entries to `OUTPUT_SENSORS` in `const.py`:
|
||||
|
||||
| key | name | unit | icon |
|
||||
|---|---|---|---|
|
||||
| `ambient_lux` | Ambient lux | lx | `mdi:brightness-5` |
|
||||
| `lux_reduction` | Lux reduction | % | `mdi:chart-line-variant` |
|
||||
|
||||
`ambient_lux` is a pass-through of the configured sensor's reading — value is in `outputs["ambient_lux"]`, updated each tick. `lux_reduction` is `factor × 100` (100 = no reduction, 50 = halved).
|
||||
|
||||
Both show `unavailable` when no `lux_sensor` is configured (the existing sensor platform already handles `None` values as `unavailable`).
|
||||
|
||||
**Rationale for `ambient_lux`**: mirrors the lux reading into the AL profile's entity set so a single dashboard card tells the full story — curve output, ambient conditions, and the reduction factor — without the user correlating separate entities.
|
||||
|
||||
### D8: Sensor state subscription for responsive adaptation
|
||||
|
||||
**Choice**: In `switch.py`, when `lux_sensor` is configured, register an `async_track_state_change_event` listener on the lux sensor entity. On state change, trigger `_update_attrs_and_maybe_adapt_lights` (the same path the interval timer uses). This makes the reduction responsive to rapid lux changes (cloud passing, blinds opening) rather than waiting up to `interval` seconds.
|
||||
|
||||
**Guard against churn**: only trigger re-adaptation if the lux change crosses the target threshold (was below, now above — or vice versa), OR if the change in lux would alter the reduction factor by more than 5 percentage points. This prevents thrashing on noisy sensors.
|
||||
|
||||
**Teardown**: the listener is removed in `async_will_remove_from_hass`, same pattern as the existing interval timer removal.
|
||||
|
||||
### D9: Turn-off and turn-back-on behaviour
|
||||
|
||||
**Choice**: When `lux_reduce` returns `None` (below `min_brightness`):
|
||||
- If the light is currently on, send `light.turn_off` with the profile's `transition` time for a graceful fade.
|
||||
- Set an internal flag `_lux_turned_off` per light entity.
|
||||
- On subsequent ticks, if `lux_reduce` returns a non-`None` value and `_lux_turned_off` is set, send `light.turn_on` with the adjusted brightness and `initial_transition`.
|
||||
- Clear the flag.
|
||||
|
||||
This ensures the integration only turns lights back on that *it* turned off due to lux — not lights the user manually turned off. The flag is reset on HA restart (lights default to curve behaviour, which is correct).
|
||||
|
||||
**Rejected alternative**: relying on the existing `intercept` mechanism to handle turn-on. Intercept fires on user-initiated `turn_on` calls, not integration-initiated ones. The flag approach is explicit and doesn't tangle with intercept logic.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Noisy sensors]** → Cheap lux sensors can fluctuate ±50 lx between reads. The 5%-factor guard in D8 mitigates this. If still problematic, users can add a template sensor with a moving average — but that's external to AL, keeping the integration simple.
|
||||
|
||||
**[Sensor lag]** → Some sensors report every 30–60 seconds. The interval timer still fires independently, so the worst case is one interval tick with stale lux data — the next tick corrects. Not a problem in practice.
|
||||
|
||||
**[User turns off lights manually, lux drops, integration turns them back on]** → The existing `intercept` and context-tracking logic in `switch.py` already handles this: manually-turned-off lights are excluded from adaptation until the user turns them on again. The lux gate doesn't change this — `_lux_turned_off` is a separate flag only for lux-initiated turn-offs.
|
||||
|
||||
**[Breaking change risk]** → None. Both fields are optional with empty/zero defaults. Existing config entries are unaffected. Minor version bump only.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Suggested default for `target_lux`**: 500 lx (typical office/living room comfort level per EN 12464-1) or 0 (disabled)? Leaning toward 0 (disabled) since the feature requires a sensor to be meaningful — a non-zero default without a sensor configured would be confusing.
|
||||
2. **Should `lux_reduce` round to the nearest 5% to reduce command churn?** The existing `skip_redundant_commands` logic may already cover this, but worth verifying during implementation.
|
||||
36
openspec/changes/add-lux-target/proposal.md
Normal file
36
openspec/changes/add-lux-target/proposal.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
## Why
|
||||
|
||||
Adaptive Lighting sets brightness from a sun-curve — it knows the *time of day* but not the *actual light level in the room*. A room with skylights may already be bathed in 600 lux at noon; cranking the ceiling LEDs to 100 % is wasteful and glaring. The curve is right about *intent* but blind to *ambient conditions*. Closing the loop with a lux sensor lets the integration reduce brightness when daylight alone is sufficient, saving energy without sacrificing comfort. Rooms without a sensor keep the existing open-loop behaviour unchanged.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Reduce-only lux gate**: when a lux sensor reads *above* the user's target, the integration proportionally dims the lights using `factor = target_lux / current_lux`. When the sensor reads at or below target, the curve stands — the gate never *boosts* brightness. This one-way design eliminates the classic feedback spiral (lights on → sensor reads higher → integration dims → too dark → integration boosts → repeat) because reducing brightness can only lower lux, which self-stabilises.
|
||||
- **Two new optional per-profile settings**:
|
||||
- `lux_sensor` — entity ID, pre-filtered in the UI to `domain=sensor, device_class=illuminance`.
|
||||
- `target_lux` — integer (lux). Only shown in the config flow when `lux_sensor` is populated.
|
||||
- **Auto-off below min_brightness**: if the lux reduction drives the adjusted brightness below the profile's existing `min_brightness`, the lights turn off entirely — they're contributing effectively nothing.
|
||||
- **Live lux reading in the config flow**: when a `lux_sensor` is already configured, the "Ambient lux" section description shows the sensor's current reading via `description_placeholders`, helping the user calibrate their target to their actual space.
|
||||
- **Graceful degradation**: if `lux_sensor` is unavailable, unknown, or not configured, the profile falls back to pure curve brightness — no error, no manual intervention needed.
|
||||
- **Two new output sensors**:
|
||||
- `ambient_lux` — pass-through of the configured lux sensor's current reading (unavailable when no sensor configured).
|
||||
- `lux_reduction` — the applied reduction factor as a percentage (100 % = no reduction, 50 % = halved). Unavailable when no sensor configured.
|
||||
- **Color temperature unchanged** — the lux gate only affects brightness. Color temp stays on the sun curve.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `lux-feedback`: reduce-only ambient-lux gate — sensor binding, `target/current` proportional reduction, auto-off below `min_brightness`, and graceful degradation to curve-only mode.
|
||||
|
||||
### Modified Capabilities
|
||||
- `options-flow`: adds the two new fields (`lux_sensor`, `target_lux`) to the config-flow UI and VALIDATION_TUPLES, in a new collapsed "Ambient lux" section with live-reading description placeholder.
|
||||
- `output-sensors`: adds `ambient_lux` and `lux_reduction` sensors to the existing output-sensor set.
|
||||
|
||||
## Impact
|
||||
|
||||
- **`const.py`**: two new `CONF_` / `DEFAULT_` constants; two new entries in `VALIDATION_TUPLES`; two new entries in `OUTPUT_SENSORS`.
|
||||
- **`config_flow.py`**: new "Ambient lux" section (collapsed) with entity selector filtered to `device_class=illuminance`, conditional `target_lux` number input, and `description_placeholders` for the live reading.
|
||||
- **`color_and_brightness.py`**: new pure function `lux_reduce(curve_brightness, target_lux, current_lux, min_brightness) → float | None` — returns adjusted brightness or `None` (meaning turn off).
|
||||
- **`switch.py`**: in the adapt path, read `lux_sensor` state and apply `lux_reduce` before assembling `service_data`. If result is `None`, send `light.turn_off` instead of `light.turn_on`. Subscribe to sensor state changes to trigger re-adaptation when lux shifts.
|
||||
- **`strings.json`**: new section, field labels, and description with `{current_lux}` placeholder.
|
||||
- **`manifest.json`**: minor version bump only (additive, non-breaking).
|
||||
- **Tests**: unit tests for `lux_reduce` math (including edge cases: ratio exactly 1.0, sensor unavailable, result below min_brightness) + integration tests for sensor-available, sensor-unavailable, and no-sensor-configured paths.
|
||||
140
openspec/changes/add-lux-target/specs/lux-feedback/spec.md
Normal file
140
openspec/changes/add-lux-target/specs/lux-feedback/spec.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Reduce-only lux gate dims lights when ambient lux exceeds target
|
||||
|
||||
When a profile has both `lux_sensor` and `target_lux` configured, the integration SHALL apply a reduction factor to the curve-computed brightness on every adapt cycle. The factor SHALL be `target_lux / current_lux` when `current_lux > target_lux`, and `1.0` otherwise. The gate SHALL NOT increase brightness above the curve value under any circumstances.
|
||||
|
||||
The adjusted brightness SHALL be `curve_brightness × factor`, clamped to `[min_brightness, curve_brightness]`.
|
||||
|
||||
Color temperature SHALL NOT be affected by the lux gate — it SHALL remain on the sun curve.
|
||||
|
||||
#### Scenario: Ambient lux above target reduces brightness
|
||||
|
||||
- **WHEN** the curve computes brightness at 85%
|
||||
- **AND** `target_lux` is 500
|
||||
- **AND** `lux_sensor` reads 700
|
||||
- **THEN** the integration SHALL send brightness of `85 × (500 / 700)` = 60.7%, rounded to the nearest integer (61%)
|
||||
|
||||
#### Scenario: Ambient lux at or below target passes curve through
|
||||
|
||||
- **WHEN** the curve computes brightness at 85%
|
||||
- **AND** `target_lux` is 500
|
||||
- **AND** `lux_sensor` reads 300
|
||||
- **THEN** the integration SHALL send brightness of 85% (unchanged)
|
||||
|
||||
#### Scenario: Reduction never boosts above curve
|
||||
|
||||
- **WHEN** the curve computes brightness at 40%
|
||||
- **AND** `target_lux` is 500
|
||||
- **AND** `lux_sensor` reads 200
|
||||
- **THEN** the integration SHALL send brightness of 40% (factor is 1.0, not 2.5)
|
||||
|
||||
#### Scenario: Exactly at target means no reduction
|
||||
|
||||
- **WHEN** `target_lux` is 500
|
||||
- **AND** `lux_sensor` reads exactly 500
|
||||
- **THEN** the factor SHALL be 1.0 and brightness SHALL equal the curve value
|
||||
|
||||
### Requirement: Lights turn off when lux reduction drives brightness below min_brightness
|
||||
|
||||
When the lux-adjusted brightness falls below the profile's `min_brightness`, the integration SHALL turn the light off instead of sending a negligible brightness value.
|
||||
|
||||
#### Scenario: Adjusted brightness below min_brightness turns light off
|
||||
|
||||
- **WHEN** the curve computes brightness at 80%
|
||||
- **AND** `min_brightness` is 5
|
||||
- **AND** `target_lux` is 500
|
||||
- **AND** `lux_sensor` reads 10000
|
||||
- **THEN** the adjusted brightness would be `80 × (500 / 10000)` = 4%, which is below `min_brightness` (5%)
|
||||
- **AND** the integration SHALL send `light.turn_off` with the profile's `transition` time
|
||||
|
||||
#### Scenario: Lights turn back on when lux drops below target
|
||||
|
||||
- **GIVEN** the integration previously turned a light off due to lux reduction
|
||||
- **WHEN** the next adapt cycle computes an adjusted brightness at or above `min_brightness`
|
||||
- **THEN** the integration SHALL send `light.turn_on` with the adjusted brightness and the profile's `initial_transition` time
|
||||
|
||||
#### Scenario: Only lux-turned-off lights are restored
|
||||
|
||||
- **GIVEN** a light was manually turned off by the user
|
||||
- **AND** the lux gate did not trigger the turn-off
|
||||
- **WHEN** the lux sensor drops below target on a subsequent tick
|
||||
- **THEN** the integration SHALL NOT turn that light back on
|
||||
- **AND** only lights with the internal `_lux_turned_off` flag SHALL be eligible for lux-initiated turn-on
|
||||
|
||||
### Requirement: Graceful degradation when lux sensor is unavailable or unconfigured
|
||||
|
||||
When `lux_sensor` is not configured (empty string), OR the configured sensor's state is `unavailable` or `unknown`, the integration SHALL use the curve brightness without any lux adjustment. No error SHALL be logged for unconfigured sensors. A `WARNING`-level log SHALL be emitted once when a previously-available sensor becomes unavailable.
|
||||
|
||||
#### Scenario: No lux sensor configured
|
||||
|
||||
- **WHEN** `lux_sensor` is empty (not configured)
|
||||
- **THEN** the integration SHALL skip the lux gate entirely
|
||||
- **AND** brightness SHALL equal the curve value
|
||||
- **AND** no error or warning SHALL be logged about lux
|
||||
|
||||
#### Scenario: Configured sensor becomes unavailable
|
||||
|
||||
- **GIVEN** `lux_sensor` is configured and was previously reporting a numeric value
|
||||
- **WHEN** the sensor state changes to `unavailable`
|
||||
- **THEN** the integration SHALL fall back to curve brightness
|
||||
- **AND** a `WARNING` log SHALL be emitted once indicating the lux sensor is unavailable
|
||||
|
||||
#### Scenario: Sensor returns non-numeric state
|
||||
|
||||
- **GIVEN** `lux_sensor` is configured
|
||||
- **WHEN** the sensor state is a non-numeric string (e.g. `"unknown"`)
|
||||
- **THEN** the integration SHALL treat it as unavailable and fall back to curve brightness
|
||||
|
||||
### Requirement: Lux sensor state changes trigger re-adaptation
|
||||
|
||||
When `lux_sensor` is configured, the integration SHALL register an `async_track_state_change_event` listener on the lux sensor entity. On significant state changes, the listener SHALL trigger `_update_attrs_and_maybe_adapt_lights`.
|
||||
|
||||
A state change is significant when the resulting reduction factor changes by more than 5 percentage points compared to the last applied factor, OR the change crosses the target threshold (was below, now above — or vice versa).
|
||||
|
||||
The listener SHALL be removed during `async_will_remove_from_hass`.
|
||||
|
||||
#### Scenario: Lux crossing target triggers immediate re-adaptation
|
||||
|
||||
- **GIVEN** the lux sensor was reading 400 (below target of 500)
|
||||
- **WHEN** the sensor reports 600 (above target)
|
||||
- **THEN** the integration SHALL trigger a re-adaptation within the same event loop cycle
|
||||
- **AND** the lights SHALL be dimmed according to the new factor
|
||||
|
||||
#### Scenario: Small lux fluctuation does not trigger re-adaptation
|
||||
|
||||
- **GIVEN** the lux sensor was reading 700 (factor = 500/700 = 71.4%)
|
||||
- **WHEN** the sensor reports 710 (factor = 500/710 = 70.4%)
|
||||
- **THEN** the change in factor is 1.0 percentage points, which is below the 5 pp threshold
|
||||
- **AND** the integration SHALL NOT trigger a re-adaptation
|
||||
|
||||
#### Scenario: Listener is cleaned up on unload
|
||||
|
||||
- **GIVEN** the integration registered a state listener on the lux sensor
|
||||
- **WHEN** the config entry is unloaded
|
||||
- **THEN** the listener SHALL be removed
|
||||
- **AND** subsequent sensor state changes SHALL NOT invoke the handler
|
||||
|
||||
### Requirement: `lux_reduce` is a pure function in `color_and_brightness.py`
|
||||
|
||||
The lux reduction logic SHALL be implemented as a standalone pure function `lux_reduce(curve_brightness, target_lux, current_lux, min_brightness)` in `color_and_brightness.py`. The function SHALL return a `float` (adjusted brightness) or `None` (turn off). It SHALL NOT access HA state, entity registries, or any global mutable state.
|
||||
|
||||
#### Scenario: Function returns None when below min_brightness
|
||||
|
||||
- **WHEN** `lux_reduce(80.0, 500, 10000, 5)` is called
|
||||
- **THEN** the return value SHALL be `None` (80 × 0.05 = 4.0, below min 5)
|
||||
|
||||
#### Scenario: Function returns adjusted brightness when above min
|
||||
|
||||
- **WHEN** `lux_reduce(85.0, 500, 700, 5)` is called
|
||||
- **THEN** the return value SHALL be approximately 60.7
|
||||
|
||||
#### Scenario: Function returns curve brightness when current ≤ target
|
||||
|
||||
- **WHEN** `lux_reduce(85.0, 500, 300, 5)` is called
|
||||
- **THEN** the return value SHALL be 85.0
|
||||
|
||||
#### Scenario: Function handles zero and negative current_lux safely
|
||||
|
||||
- **WHEN** `lux_reduce(85.0, 500, 0, 5)` is called
|
||||
- **THEN** the return value SHALL be 85.0 (treat zero/negative as "no data", pass through)
|
||||
109
openspec/changes/add-lux-target/specs/options-flow/spec.md
Normal file
109
openspec/changes/add-lux-target/specs/options-flow/spec.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Options dialog presents fields in named collapsible sections
|
||||
|
||||
The integration options dialog SHALL group its configurable fields into seven named sections, rendered using Home Assistant's `section()` schema helper. Section names and field membership SHALL match the layout below.
|
||||
|
||||
| Section | Default state | Fields |
|
||||
|---|---|---|
|
||||
| Targets | expanded | `lights` |
|
||||
| Daytime curve | expanded | `min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp`, `prefer_rgb_color` |
|
||||
| Sun schedule | expanded | `sunrise_entity`, `sunset_entity` |
|
||||
| Ambient lux | collapsed | `lux_sensor`, `target_lux` |
|
||||
| Light control | expanded | `intercept`, `multi_light_intercept` |
|
||||
| Advanced | collapsed | `interval`, `transition`, `initial_transition`, `adapt_delay`, `separate_turn_on_commands`, `send_split_delay`, `skip_redundant_commands` |
|
||||
| Diagnostics | collapsed | `include_config_in_attributes` |
|
||||
|
||||
#### Scenario: User opens options dialog on a UI-managed entry
|
||||
|
||||
- **WHEN** the user navigates to Settings → Devices & Services → Adaptive Lighting → Configure
|
||||
- **THEN** the form SHALL render seven sections in the order: Targets, Daytime curve, Sun schedule, Ambient lux, Light control, Advanced, Diagnostics
|
||||
- **AND** the Ambient lux, Advanced, and Diagnostics sections SHALL be rendered in their collapsed state
|
||||
- **AND** the Targets, Daytime curve, Sun schedule, and Light control sections SHALL be rendered expanded
|
||||
|
||||
#### Scenario: Each section contains only the fields specified for it
|
||||
|
||||
- **WHEN** the user expands any section in the options dialog
|
||||
- **THEN** the fields shown in that section SHALL exactly match the field list in the table above for that section
|
||||
- **AND** no field SHALL appear in more than one section
|
||||
|
||||
### Requirement: Conditional fields hide when their driver makes them irrelevant
|
||||
|
||||
Fields whose configuration is meaningful only under a specific value of another field ("driver") SHALL be omitted from the rendered schema when the driver value makes them inapplicable. When the driver value changes, the form SHALL be re-submitted to re-render with the updated field set.
|
||||
|
||||
The conditional pairs are:
|
||||
- `send_split_delay` is conditional on `separate_turn_on_commands` being `true`.
|
||||
- `target_lux` is conditional on `lux_sensor` being a non-empty string.
|
||||
|
||||
#### Scenario: send_split_delay hidden when transport mode disables it
|
||||
|
||||
- **WHEN** the user opens the options dialog with `separate_turn_on_commands` set to `false`
|
||||
- **THEN** the Advanced section SHALL NOT include the `send_split_delay` field
|
||||
|
||||
#### Scenario: send_split_delay revealed when transport mode enables it
|
||||
|
||||
- **WHEN** the user toggles `separate_turn_on_commands` to `true` and submits the form
|
||||
- **THEN** the options dialog SHALL re-render with `send_split_delay` present in the Advanced section
|
||||
- **AND** the field SHALL accept values in the range 0–10000 milliseconds
|
||||
|
||||
#### Scenario: target_lux hidden when no lux sensor is selected
|
||||
|
||||
- **WHEN** the user opens the options dialog with `lux_sensor` set to `""` (empty)
|
||||
- **THEN** the Ambient lux section SHALL show only the `lux_sensor` entity selector
|
||||
- **AND** `target_lux` SHALL NOT appear
|
||||
|
||||
#### Scenario: target_lux revealed when lux sensor is selected
|
||||
|
||||
- **WHEN** the user selects a `lux_sensor` entity and submits the form
|
||||
- **THEN** the options dialog SHALL re-render with `target_lux` present in the Ambient lux section
|
||||
- **AND** the field SHALL accept values in the range 1–10000 lux
|
||||
|
||||
### 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 selector mapping includes:
|
||||
|
||||
| Field type | Selector |
|
||||
|---|---|
|
||||
| Numeric range (brightness, color temp) | `NumberSelector` with explicit `min`, `max`, `step`, `unit_of_measurement`, `mode=SLIDER` |
|
||||
| Duration (seconds) | `NumberSelector` with `unit_of_measurement="s"`, `mode=BOX` |
|
||||
| Duration (milliseconds) | `NumberSelector` with `unit_of_measurement="ms"`, `mode=BOX` |
|
||||
| Boolean | `BooleanSelector` |
|
||||
| Entity (lights) | `EntitySelector` with `domain="light"`, `multiple=True` |
|
||||
| Entity (sun events) | `EntitySelector` with `domain="sensor"`, `device_class="timestamp"` |
|
||||
| Entity (lux sensor) | `EntitySelector` with `domain="sensor"`, `device_class="illuminance"` |
|
||||
| Lux target | `NumberSelector` with `min=1`, `max=10000`, `step=10`, `unit_of_measurement="lx"`, `mode=BOX` |
|
||||
|
||||
#### Scenario: Lux sensor selector filters to illuminance sensors only
|
||||
|
||||
- **WHEN** the user opens the entity picker for `lux_sensor`
|
||||
- **THEN** only entities with `domain == "sensor"` and `device_class == "illuminance"` SHALL appear in the picker
|
||||
- **AND** temperature sensors, humidity sensors, and other non-illuminance sensors SHALL NOT appear
|
||||
|
||||
#### Scenario: Target lux renders as a number box with lux unit
|
||||
|
||||
- **WHEN** the user expands the Ambient lux section with a lux sensor configured
|
||||
- **THEN** `target_lux` SHALL render as a numeric box input with range 1–10000, step 10
|
||||
- **AND** the field SHALL display the unit "lx"
|
||||
|
||||
### Requirement: Ambient lux section shows the sensor's current reading
|
||||
|
||||
When a `lux_sensor` is configured and its state is numeric, the Ambient lux section description SHALL include the sensor's current reading via `description_placeholders`. This helps the user calibrate their `target_lux` to their actual space.
|
||||
|
||||
#### Scenario: Current lux reading shown in section description
|
||||
|
||||
- **GIVEN** `lux_sensor` is set to `sensor.office_illuminance`
|
||||
- **AND** that sensor's current state is `"340"`
|
||||
- **WHEN** the user opens the options dialog
|
||||
- **THEN** the Ambient lux section description SHALL include the text "340 lx"
|
||||
|
||||
#### Scenario: No reading shown when sensor is not configured
|
||||
|
||||
- **GIVEN** `lux_sensor` is empty (not configured)
|
||||
- **WHEN** the user opens the options dialog
|
||||
- **THEN** the Ambient lux section description SHALL NOT include any lux reading number
|
||||
|
||||
#### Scenario: Fallback when sensor is unavailable
|
||||
|
||||
- **GIVEN** `lux_sensor` is configured but its state is `"unavailable"`
|
||||
- **WHEN** the user opens the options dialog
|
||||
- **THEN** the Ambient lux section description SHALL show a dash or "unavailable" in place of a numeric reading
|
||||
89
openspec/changes/add-lux-target/specs/output-sensors/spec.md
Normal file
89
openspec/changes/add-lux-target/specs/output-sensors/spec.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Each AL profile exposes three output sensor entities plus two conditional lux sensors
|
||||
|
||||
For each Adaptive Lighting config entry, the integration SHALL create the three existing output sensor entities plus two additional lux-related sensors when a `lux_sensor` is configured. All sensors SHALL be registered on the `sensor` platform during `async_setup_entry` and torn down during `async_unload_entry`. Each entity SHALL share the same device record (`(DOMAIN, entry.entry_id)`) as the profile's existing switches and number entities.
|
||||
|
||||
| Output | `unique_id` suffix | `_attr_name` | `native_unit_of_measurement` | `state_class` | icon | Condition |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Output brightness | `_output_brightness` | `"Output brightness"` | `"%"` | `MEASUREMENT` | `mdi:brightness-percent` | always |
|
||||
| Output color temperature | `_output_color_temp` | `"Output color temp"` | `"K"` | `MEASUREMENT` | `mdi:thermometer` | always |
|
||||
| Sun elevation | `_sun_elevation` | `"Sun elevation"` | `"°"` | `MEASUREMENT` | `mdi:weather-sunset` | always |
|
||||
| Ambient lux | `_ambient_lux` | `"Ambient lux"` | `"lx"` | `MEASUREMENT` | `mdi:brightness-5` | `lux_sensor` configured |
|
||||
| Lux reduction | `_lux_reduction` | `"Lux reduction"` | `"%"` | `MEASUREMENT` | `mdi:chart-line-variant` | `lux_sensor` configured |
|
||||
|
||||
The full `unique_id` SHALL be `<entry.entry_id>_<suffix>`.
|
||||
|
||||
#### Scenario: Profile with lux sensor produces five sensor entities
|
||||
|
||||
- **WHEN** the user creates an AL config entry with `lux_sensor` set to `sensor.office_illuminance`
|
||||
- **AND** `async_setup_entry` completes
|
||||
- **THEN** the entity registry SHALL contain five `sensor` entities owned by this entry
|
||||
- **AND** their unique_ids SHALL end with `_output_brightness`, `_output_color_temp`, `_sun_elevation`, `_ambient_lux`, and `_lux_reduction`
|
||||
|
||||
#### Scenario: Profile without lux sensor produces three sensor entities
|
||||
|
||||
- **WHEN** the user creates an AL config entry without a `lux_sensor` configured
|
||||
- **AND** `async_setup_entry` completes
|
||||
- **THEN** the entity registry SHALL contain three `sensor` entities owned by this entry
|
||||
- **AND** the `_ambient_lux` and `_lux_reduction` entities SHALL NOT be created
|
||||
|
||||
#### Scenario: Removing lux sensor removes the two conditional sensors
|
||||
|
||||
- **GIVEN** an AL profile has `lux_sensor` configured and all five sensors exist
|
||||
- **WHEN** the user removes `lux_sensor` (sets to empty) and saves options
|
||||
- **THEN** on reload, the `_ambient_lux` and `_lux_reduction` entities SHALL be removed from the entity registry
|
||||
- **AND** only the three unconditional sensors SHALL remain
|
||||
|
||||
### Requirement: Ambient lux sensor mirrors the configured lux sensor's reading
|
||||
|
||||
The `ambient_lux` output sensor SHALL read the configured `lux_sensor` entity's numeric state on each curve tick and publish the value to `hass.data[DOMAIN][entry.entry_id]["outputs"]["ambient_lux"]`. If the source sensor is unavailable or non-numeric, the value SHALL be `None` (rendered as `unknown` in HA).
|
||||
|
||||
#### Scenario: Ambient lux reflects source sensor
|
||||
|
||||
- **GIVEN** `lux_sensor` is `sensor.office_illuminance` with state `"340"`
|
||||
- **WHEN** a curve evaluation tick completes
|
||||
- **THEN** `outputs["ambient_lux"]` SHALL be `340.0`
|
||||
- **AND** the `ambient_lux` sensor entity state SHALL be `"340.0"`
|
||||
|
||||
#### Scenario: Source sensor unavailable results in unknown state
|
||||
|
||||
- **GIVEN** `lux_sensor` is configured but its state is `"unavailable"`
|
||||
- **WHEN** a curve evaluation tick completes
|
||||
- **THEN** `outputs["ambient_lux"]` SHALL be `None`
|
||||
- **AND** the `ambient_lux` sensor entity state SHALL be `unknown`
|
||||
|
||||
### Requirement: Lux reduction sensor exposes the applied factor as a percentage
|
||||
|
||||
The `lux_reduction` output sensor SHALL publish `factor × 100` to `hass.data[DOMAIN][entry.entry_id]["outputs"]["lux_reduction"]`, where `factor` is the value computed by the lux gate. A value of `100` means no reduction (curve passes through); `50` means brightness was halved. When the lux gate is inactive (sensor below target or unavailable), the value SHALL be `100`.
|
||||
|
||||
When the lights were turned off due to lux reduction (factor drove brightness below `min_brightness`), the value SHALL be `0`.
|
||||
|
||||
#### Scenario: Lux reduction shows the applied factor
|
||||
|
||||
- **GIVEN** `target_lux` is 500 and `lux_sensor` reads 700
|
||||
- **WHEN** a curve evaluation tick completes
|
||||
- **THEN** `outputs["lux_reduction"]` SHALL be `71` (rounded from 71.4)
|
||||
- **AND** the `lux_reduction` sensor entity state SHALL be `"71"`
|
||||
|
||||
#### Scenario: No reduction shows 100%
|
||||
|
||||
- **GIVEN** `target_lux` is 500 and `lux_sensor` reads 300
|
||||
- **WHEN** a curve evaluation tick completes
|
||||
- **THEN** `outputs["lux_reduction"]` SHALL be `100`
|
||||
|
||||
#### Scenario: Lights-off due to lux shows 0%
|
||||
|
||||
- **GIVEN** the lux gate drove brightness below `min_brightness`
|
||||
- **WHEN** a curve evaluation tick completes
|
||||
- **THEN** `outputs["lux_reduction"]` SHALL be `0`
|
||||
|
||||
### Requirement: Lux output sensors follow the same dispatcher pattern as existing sensors
|
||||
|
||||
The `ambient_lux` and `lux_reduction` sensors SHALL use the same `SIGNAL_OUTPUTS_UPDATED` dispatcher subscription as the three existing sensors. They SHALL NOT poll. `_attr_should_poll` SHALL be `False`. The dispatcher unsubscribe handle SHALL be tracked via `async_on_remove`.
|
||||
|
||||
#### Scenario: Lux sensors update on the same signal as existing sensors
|
||||
|
||||
- **GIVEN** the integration is loaded with `lux_sensor` configured
|
||||
- **WHEN** a curve evaluation completes and fires the dispatcher signal
|
||||
- **THEN** all five sensor entities SHALL execute their outputs-updated handler exactly once
|
||||
46
openspec/changes/add-lux-target/tasks.md
Normal file
46
openspec/changes/add-lux-target/tasks.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
## 1. Constants and config schema
|
||||
|
||||
- [x] 1.1 Add `CONF_LUX_SENSOR` / `DEFAULT_LUX_SENSOR` (`""`) and `CONF_TARGET_LUX` / `DEFAULT_TARGET_LUX` (`0`) to `const.py` with `DOCS` entries [R: options-flow selectors, D4]
|
||||
- [x] 1.2 Add both fields to `VALIDATION_TUPLES` — `lux_sensor` as `cv.entity_id`, `target_lux` as `int_between(0, 10000)` [R: options-flow selectors, D4]
|
||||
- [x] 1.3 Add `ambient_lux` and `lux_reduction` entries to `OUTPUT_SENSORS` in `const.py` [R: output-sensors entity table, D7]
|
||||
|
||||
## 2. Pure lux-reduction math
|
||||
|
||||
- [x] 2.1 Implement `lux_reduce(curve_brightness, target_lux, current_lux, min_brightness) → float | None` in `color_and_brightness.py` [R: lux-feedback pure function, D2, D6]
|
||||
- [x] 2.2 Unit tests for `lux_reduce`: above target, below target, exactly at target, below min_brightness → None, zero/negative current_lux → pass-through [R: lux-feedback pure function scenarios]
|
||||
|
||||
## 3. Config flow — Ambient lux section
|
||||
|
||||
- [x] 3.1 Add `_lux_sensor_selector()` factory returning `EntitySelector(domain="sensor", device_class="illuminance")` in `config_flow.py` [R: options-flow selectors, D4]
|
||||
- [x] 3.2 Add `_target_lux_selector()` factory returning `NumberSelector(min=1, max=10000, step=10, unit="lx", mode=BOX)` [R: options-flow selectors, D4]
|
||||
- [x] 3.3 Build the "Ambient lux" section in `_build_options_schema` — collapsed, `lux_sensor` always shown, `target_lux` conditional on `lux_sensor` being non-empty (same pattern as `send_split_delay`) [R: options-flow sections, options-flow conditionals, D4]
|
||||
- [x] 3.4 Read the lux sensor's current state in `async_step_init` and pass it to `async_show_form` via `description_placeholders={"current_lux": lux_reading}` [R: options-flow live reading, D5]
|
||||
- [x] 3.5 Add section, field labels, and description (with `{current_lux}` placeholder) to `strings.json` [R: options-flow live reading, D5]
|
||||
|
||||
## 4. Switch adapt path — lux gate integration
|
||||
|
||||
- [x] 4.1 In `switch.py` `_prepare_adaptation_data` (or its caller), read `lux_sensor` state from `self.hass.states.get()`, call `lux_reduce`, and replace `brightness_pct` in `self._settings` before service-data assembly [R: lux-feedback reduce-only gate, D1, D2, D6]
|
||||
- [x] 4.2 Handle `lux_reduce` returning `None`: send `light.turn_off` with `transition`, set per-light `_lux_turned_off` flag [R: lux-feedback auto-off, D3, D9]
|
||||
- [x] 4.3 Handle lux-off recovery: when `lux_reduce` returns non-`None` and `_lux_turned_off` is set, send `light.turn_on` with adjusted brightness and `initial_transition`, clear flag [R: lux-feedback auto-off recovery, D9]
|
||||
- [x] 4.4 Register `async_track_state_change_event` on `lux_sensor` when configured, with 5pp significance guard; teardown in `async_will_remove_from_hass` [R: lux-feedback sensor subscription, D8]
|
||||
|
||||
## 5. Output sensors — lux values
|
||||
|
||||
- [x] 5.1 Publish `ambient_lux` and `lux_reduction` to the `outputs` cache dict alongside existing keys on each curve tick [R: output-sensors ambient lux, output-sensors lux reduction, D7]
|
||||
- [x] 5.2 Conditionally create `_ambient_lux` and `_lux_reduction` sensor entities only when `lux_sensor` is configured; clean up entities on reconfigure when sensor is removed [R: output-sensors conditional creation]
|
||||
- [x] 5.3 Ensure both new sensors subscribe to `SIGNAL_OUTPUTS_UPDATED` via the existing dispatcher pattern [R: output-sensors dispatcher pattern]
|
||||
|
||||
## 6. Strings and manifest
|
||||
|
||||
- [x] 6.1 Add `entity.sensor.ambient_lux` and `entity.sensor.lux_reduction` name entries to `strings.json` [R: output-sensors entity table]
|
||||
- [x] 6.2 Bump minor version in `manifest.json` [D: non-breaking additive change]
|
||||
|
||||
## 7. Integration tests
|
||||
|
||||
- [x] 7.1 Test: profile without lux sensor — curve brightness unchanged, no lux output sensors created [R: lux-feedback graceful degradation, output-sensors conditional]
|
||||
- [x] 7.2 Test: profile with lux sensor above target — brightness reduced by correct factor [R: lux-feedback reduce-only gate]
|
||||
- [x] 7.3 Test: lux drives brightness below min_brightness — light turns off, turns back on when lux drops [R: lux-feedback auto-off and recovery, D9]
|
||||
- [x] 7.4 Test: lux sensor becomes unavailable — falls back to curve brightness, warning logged once [R: lux-feedback graceful degradation]
|
||||
- [x] 7.5 Test: lux sensor state change triggers re-adaptation (crossing target threshold) [R: lux-feedback sensor subscription, D8]
|
||||
- [x] 7.6 Test: config flow shows live lux reading in description placeholder [R: options-flow live reading, D5]
|
||||
- [x] 7.7 Test: conditional `target_lux` field visibility in options flow [R: options-flow conditionals]
|
||||
Loading…
Add table
Add a link
Reference in a new issue