From c3b948874ed7277a745f635415ccc2def416b312 Mon Sep 17 00:00:00 2001 From: Casey Date: Mon, 25 May 2026 16:15:16 +0200 Subject: [PATCH] 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-add-lux-target}/.openspec.yaml | 0 .../2026-05-25-add-lux-target}/design.md | 0 .../2026-05-25-add-lux-target}/proposal.md | 0 .../specs/lux-feedback/spec.md | 0 .../specs/options-flow/spec.md | 0 .../specs/output-sensors/spec.md | 0 .../2026-05-25-add-lux-target}/tasks.md | 0 openspec/specs/lux-feedback/spec.md | 144 ++++++++++++++++++ openspec/specs/options-flow/spec.md | 57 ++++++- openspec/specs/output-sensors/spec.md | 90 +++++++++-- 10 files changed, 277 insertions(+), 14 deletions(-) rename openspec/changes/{add-lux-target => archive/2026-05-25-add-lux-target}/.openspec.yaml (100%) rename openspec/changes/{add-lux-target => archive/2026-05-25-add-lux-target}/design.md (100%) rename openspec/changes/{add-lux-target => archive/2026-05-25-add-lux-target}/proposal.md (100%) rename openspec/changes/{add-lux-target => archive/2026-05-25-add-lux-target}/specs/lux-feedback/spec.md (100%) rename openspec/changes/{add-lux-target => archive/2026-05-25-add-lux-target}/specs/options-flow/spec.md (100%) rename openspec/changes/{add-lux-target => archive/2026-05-25-add-lux-target}/specs/output-sensors/spec.md (100%) rename openspec/changes/{add-lux-target => archive/2026-05-25-add-lux-target}/tasks.md (100%) create mode 100644 openspec/specs/lux-feedback/spec.md diff --git a/openspec/changes/add-lux-target/.openspec.yaml b/openspec/changes/archive/2026-05-25-add-lux-target/.openspec.yaml similarity index 100% rename from openspec/changes/add-lux-target/.openspec.yaml rename to openspec/changes/archive/2026-05-25-add-lux-target/.openspec.yaml diff --git a/openspec/changes/add-lux-target/design.md b/openspec/changes/archive/2026-05-25-add-lux-target/design.md similarity index 100% rename from openspec/changes/add-lux-target/design.md rename to openspec/changes/archive/2026-05-25-add-lux-target/design.md diff --git a/openspec/changes/add-lux-target/proposal.md b/openspec/changes/archive/2026-05-25-add-lux-target/proposal.md similarity index 100% rename from openspec/changes/add-lux-target/proposal.md rename to openspec/changes/archive/2026-05-25-add-lux-target/proposal.md diff --git a/openspec/changes/add-lux-target/specs/lux-feedback/spec.md b/openspec/changes/archive/2026-05-25-add-lux-target/specs/lux-feedback/spec.md similarity index 100% rename from openspec/changes/add-lux-target/specs/lux-feedback/spec.md rename to openspec/changes/archive/2026-05-25-add-lux-target/specs/lux-feedback/spec.md diff --git a/openspec/changes/add-lux-target/specs/options-flow/spec.md b/openspec/changes/archive/2026-05-25-add-lux-target/specs/options-flow/spec.md similarity index 100% rename from openspec/changes/add-lux-target/specs/options-flow/spec.md rename to openspec/changes/archive/2026-05-25-add-lux-target/specs/options-flow/spec.md diff --git a/openspec/changes/add-lux-target/specs/output-sensors/spec.md b/openspec/changes/archive/2026-05-25-add-lux-target/specs/output-sensors/spec.md similarity index 100% rename from openspec/changes/add-lux-target/specs/output-sensors/spec.md rename to openspec/changes/archive/2026-05-25-add-lux-target/specs/output-sensors/spec.md diff --git a/openspec/changes/add-lux-target/tasks.md b/openspec/changes/archive/2026-05-25-add-lux-target/tasks.md similarity index 100% rename from openspec/changes/add-lux-target/tasks.md rename to openspec/changes/archive/2026-05-25-add-lux-target/tasks.md diff --git a/openspec/specs/lux-feedback/spec.md b/openspec/specs/lux-feedback/spec.md new file mode 100644 index 00000000..0ee8b39f --- /dev/null +++ b/openspec/specs/lux-feedback/spec.md @@ -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) diff --git a/openspec/specs/options-flow/spec.md b/openspec/specs/options-flow/spec.md index 358548a4..e511778d 100644 --- a/openspec/specs/options-flow/spec.md +++ b/openspec/specs/options-flow/spec.md @@ -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 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: 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 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 + ### 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. diff --git a/openspec/specs/output-sensors/spec.md b/openspec/specs/output-sensors/spec.md index acce5f13..f43717d6 100644 --- a/openspec/specs/output-sensors/spec.md +++ b/openspec/specs/output-sensors/spec.md @@ -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 `_`. -#### 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