adaptive-lighting/openspec/specs/lux-feedback/spec.md
Casey c3b948874e Archive add-lux-target: 26/26 + sync main specs
New lux-feedback spec, updated options-flow (7 sections, lux conditionals,
live reading), updated output-sensors (conditional ambient_lux + lux_reduction).
2026-05-25 16:15:16 +02:00

144 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# lux-feedback Specification
## Purpose
Reduce-only ambient-lux gate that dims lights when daylight alone exceeds a user-set target. Uses a proportional `target/current` factor, turns lights off below `min_brightness`, and degrades gracefully when the sensor is unavailable or unconfigured. The gate only reduces brightness — it never boosts above the sun curve.
## 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)