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).
This commit is contained in:
Casey 2026-05-25 16:15:16 +02:00
commit c3b948874e
10 changed files with 277 additions and 14 deletions

View file

@ -0,0 +1,144 @@
# 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)

View file

@ -5,13 +5,14 @@ TBD - created by archiving change cdit-config-redesign. Update Purpose after arc
## Requirements
### Requirement: Options dialog presents fields in named collapsible sections
The integration options dialog SHALL group its 18 configurable fields into five named sections plus a Diagnostics subsection, rendered using Home Assistant's `section()` schema helper. Section names and field membership SHALL match the layout below.
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` |
@ -19,8 +20,8 @@ The integration options dialog SHALL group its 18 configurable fields into five
#### 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 six sections in the order: Targets, Daytime curve, Sun schedule, Light control, Advanced, Diagnostics
- **AND** the Advanced and Diagnostics sections SHALL be rendered in their collapsed state
- **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
@ -35,6 +36,7 @@ Fields whose configuration is meaningful only under a specific value of another
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
@ -47,6 +49,18 @@ The conditional pairs are:
- **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: Sun event timing is read from configurable HA entities
The integration SHALL read sunrise and sunset event timestamps from two user-configured HA sensor entities exposed in the options dialog as `sunrise_entity` and `sunset_entity`. Both fields SHALL use an entity selector strictly typed to `domain: sensor` and `device_class: timestamp`. The integration SHALL NOT compute sun events from `astral` or any other internal sun-position library when both entities are configured.
@ -123,6 +137,8 @@ Every field in the options dialog SHALL be rendered using a class from `homeassi
| 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: Brightness ranges render as sliders
@ -141,6 +157,41 @@ Every field in the options dialog SHALL be rendered using a class from `homeassi
- **WHEN** the user opens the options dialog
- **THEN** every boolean field (`prefer_rgb_color`, `intercept`, `multi_light_intercept`, `separate_turn_on_commands`, `skip_redundant_commands`, `include_config_in_attributes`) SHALL render as a toggle switch control
#### 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
### Requirement: Saving options reloads the integration via OptionsFlowWithReload
The options flow class SHALL extend `homeassistant.config_entries.OptionsFlowWithReload`. Saving changes through the options dialog SHALL trigger an integration reload without the integration manually calling `hass.config_entries.async_reload()`. Custom `async_unload_entry` plumbing for reload purposes SHALL NOT exist in the integration.

View file

@ -4,29 +4,44 @@
Per-profile read-only `sensor` entities that expose the curve's current target brightness/color-temperature outputs and the actual solar elevation as graphable HA `MEASUREMENT`-class values. Sensors are pure readers of a runtime cache the master switch publishes to after each curve tick; they do not recompute the curve. The integration's existing master-switch attributes (`brightness_pct`, `color_temp_kelvin`, synthetic `sun_position` in [-1, +1]) are unaffected — the sensors are an additive, graphable surface over the same data plus `sun.sun.elevation`.
## Requirements
### Requirement: Each AL profile exposes three output sensor entities
### Requirement: Each AL profile exposes three output sensor entities plus two conditional lux sensors
For each Adaptive Lighting config entry, the integration SHALL create exactly three `sensor` entities exposing the curve's computed outputs. The entities 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.
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` | `device_class` | icon |
| Output | `unique_id` suffix | `_attr_name` | `native_unit_of_measurement` | `state_class` | icon | Condition |
|---|---|---|---|---|---|---|
| Output brightness | `_output_brightness` | `"Output brightness"` | `"%"` | `MEASUREMENT` | (none) | `mdi:brightness-percent` |
| Output color temperature | `_output_color_temp` | `"Output color temp"` | `"K"` | `MEASUREMENT` | (none) | `mdi:thermometer` |
| Sun elevation | `_sun_elevation` | `"Sun elevation"` | `"°"` | `MEASUREMENT` | (none) | `mdi:weather-sunset` |
| 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: A new config entry produces three sensor entities
#### Scenario: Profile with lux sensor produces five sensor entities
- **WHEN** the user creates a new Adaptive Lighting config entry
- **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** their unique_ids SHALL end with `_output_brightness`, `_output_color_temp`, and `_sun_elevation` respectively
- **AND** all three sensors SHALL be attached to the same device as the profile's switches and number entities
- **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
#### Scenario: Sensor metadata matches the design table
- **WHEN** any of the three sensors is inspected via the entity registry
- **WHEN** any of the three unconditional sensors is inspected via the entity registry
- **THEN** `output_brightness` SHALL declare `native_unit_of_measurement="%"`, `state_class=SensorStateClass.MEASUREMENT`, no `device_class`
- **AND** `output_color_temp` SHALL declare `native_unit_of_measurement="K"`, `state_class=SensorStateClass.MEASUREMENT`, no `device_class`
- **AND** `sun_elevation` SHALL declare `native_unit_of_measurement="°"`, `state_class=SensorStateClass.MEASUREMENT`, no `device_class`
@ -141,3 +156,56 @@ The chosen role labels SHALL NOT collide with any existing entity's `_attr_name`
- **THEN** no two entities SHALL share the same friendly name
- **AND** specifically the adapt-brightness switch ("Dining MVP Brightness") and the output-brightness sensor ("Dining MVP Output brightness") SHALL be distinguishable strings
- **AND** the output-color-temp sensor ("Dining MVP Output color temp") SHALL be distinguishable from the "Min color temp" and "Max color temp" number entities
### 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