diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 0cf670a5..c86504d8 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1475,11 +1475,12 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if current_lux is not None and self._target_lux > 0: ambient_lux: float | None = current_lux if current_lux > self._target_lux: - lux_reduction = round( - min(self._target_lux / current_lux, 1.0) * 100, - ) + factor = min(self._target_lux / current_lux, 1.0) + # Report the *reduction*, not the retained factor: 0 % means + # no dimming (curve passes through), 100 % means fully cut. + lux_reduction = 100 - round(factor * 100) else: - lux_reduction = 100 + lux_reduction = 0 else: ambient_lux = current_lux lux_reduction = None diff --git a/openspec/specs/output-sensors/spec.md b/openspec/specs/output-sensors/spec.md index f43717d6..ff5a5f61 100644 --- a/openspec/specs/output-sensors/spec.md +++ b/openspec/specs/output-sensors/spec.md @@ -175,31 +175,29 @@ The `ambient_lux` output sensor SHALL read the configured `lux_sensor` entity's - **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 +### Requirement: Lux reduction sensor exposes the applied reduction 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`. +The `lux_reduction` output sensor SHALL publish the brightness reduction the lux gate is applying, as a percentage, to `hass.data[DOMAIN][entry.entry_id]["outputs"]["lux_reduction"]`. The value SHALL be `100 − round(factor × 100)`, where `factor` is the retained brightness factor computed by the lux gate (`target_lux / current_lux`, capped at `1.0`). A value of `0` means no reduction (curve passes through); `50` means brightness was halved; values approaching `100` mean the lights are almost fully cut. When ambient lux is at or below target, the value SHALL be `0`. When the lux gate is inactive (no sensor configured, sensor unavailable, or `target_lux` is 0), the value SHALL be `None` (the sensor renders `unknown`). -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 +#### Scenario: Lux reduction shows the applied reduction - **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"` +- **THEN** `outputs["lux_reduction"]` SHALL be `29` (100 − 71, the retained 71.4% rounded) +- **AND** the `lux_reduction` sensor entity state SHALL be `"29"` -#### Scenario: No reduction shows 100% +#### Scenario: No reduction shows 0% - **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` +#### Scenario: Gate inactive shows unknown + +- **GIVEN** no `lux_sensor` is configured (or it is unavailable, or `target_lux` is 0) +- **WHEN** a curve evaluation tick completes +- **THEN** `outputs["lux_reduction"]` SHALL be `None` + ### 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`. diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py index c8aeb495..e967cfa7 100644 --- a/tests/test_sensor_platform.py +++ b/tests/test_sensor_platform.py @@ -20,6 +20,8 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.adaptive_lighting import sensor as sensor_module from custom_components.adaptive_lighting.const import ( + CONF_LUX_SENSOR, + CONF_TARGET_LUX, CONFIG_ENTRY_VERSION, DOMAIN, OUTPUT_SENSORS, @@ -194,6 +196,32 @@ async def test_curve_tick_publishes_outputs(hass) -> None: assert outputs["updated_at"] >= before +async def test_lux_reduction_reports_reduction_not_retained_factor(hass) -> None: + """lux_reduction is the percentage *reduced* (0 = none), not the kept factor.""" + entry = await _setup_entry( + hass, + options={CONF_LUX_SENSOR: "sensor.test_lux", CONF_TARGET_LUX: 500}, + ) + al_switch = hass.data[DOMAIN][entry.entry_id]["switch"] + al_switch._settings.update({"brightness_pct": 80, "color_temp_kelvin": 3000}) + hass.states.async_set("sun.sun", "above_horizon", {"elevation": 10.0}) + + # Above target -> dimming. factor = 500/700 = 0.714 -> reduction = 29 %. + hass.states.async_set("sensor.test_lux", "700") + al_switch._publish_outputs_and_wake_sensors() + assert hass.data[DOMAIN][entry.entry_id]["outputs"]["lux_reduction"] == 29 + + # At/below target -> no reduction -> 0 % (the at-rest value). + hass.states.async_set("sensor.test_lux", "300") + al_switch._publish_outputs_and_wake_sensors() + assert hass.data[DOMAIN][entry.entry_id]["outputs"]["lux_reduction"] == 0 + + # No usable reading -> gate inactive -> None (sensor renders unknown). + hass.states.async_set("sensor.test_lux", "unavailable") + al_switch._publish_outputs_and_wake_sensors() + assert hass.data[DOMAIN][entry.entry_id]["outputs"]["lux_reduction"] is None + + # --------------------------------------------------------------------------- # 4.7 — Firing the dispatcher signal updates the three sensors # ---------------------------------------------------------------------------