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:
Casey 2026-05-25 16:11:52 +02:00
commit 11fe23253c
18 changed files with 1047 additions and 19 deletions

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-24

View 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 3060 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.

View 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.

View 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)

View 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 010000 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 110000 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 110000, 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

View 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

View 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]