lux_reduction sensor: report reduction %, not retained factor

The 'Lux reduction' sensor published the retained brightness factor, so it
read 100% when nothing was being reduced (0% actual reduction) — the label
and value were inverses. Now it publishes the actual reduction:
100 - round(factor*100), so 0% = no dimming, higher = more cut, None when
the gate is inactive.

Updates the output-sensors spec scenarios to match (71 -> 29, 100 -> 0,
adds the gate-inactive None case) and adds a publish-level value test
(none existed — the prior scenarios were never enforced in code).
This commit is contained in:
Casey 2026-05-30 22:35:59 +02:00
commit 76f04237d7
3 changed files with 43 additions and 16 deletions

View file

@ -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

View file

@ -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`.

View file

@ -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
# ---------------------------------------------------------------------------