Implement add-lux-target: reduce-only ambient lux gate

Optional per-profile lux sensor binding that dims lights when daylight
alone exceeds a user-set target (factor = target/current). Lights turn
off entirely below min_brightness. Two config fields, live lux reading
in the options flow, two conditional output sensors, sensor state
subscription with 5pp significance guard. 26/26 tasks, 138 tests green.
This commit is contained in:
Casey 2026-05-25 16:11:52 +02:00
commit 11fe23253c
18 changed files with 1047 additions and 19 deletions

View file

@ -14,6 +14,7 @@ from homeassistant.helpers import entity_registry as er
from .const import (
_DOMAIN_SCHEMA, # pyright: ignore[reportPrivateUsage]
ATTR_ADAPTIVE_LIGHTING_MANAGER,
CONF_LUX_SENSOR,
CONF_NAME,
CONFIG_ENTRY_VERSION,
DOMAIN,
@ -96,6 +97,33 @@ def _remove_orphan_sleep_entities(
registry.async_remove(entity_id)
_LUX_CONDITIONAL_SUFFIXES = ("_ambient_lux", "_lux_reduction")
def _remove_orphan_lux_sensors(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> None:
"""Remove lux output sensors when lux_sensor is no longer configured."""
has_lux = bool(
config_entry.options.get(CONF_LUX_SENSOR)
or config_entry.data.get(CONF_LUX_SENSOR)
)
if has_lux:
return
registry = er.async_get(hass)
for entry in list(registry.entities.values()):
if (
entry.config_entry_id == config_entry.entry_id
and any(entry.unique_id.endswith(s) for s in _LUX_CONDITIONAL_SUFFIXES)
):
_LOGGER.info(
"Removing orphan lux sensor %s (lux_sensor no longer configured).",
entry.entity_id,
)
registry.async_remove(entry.entity_id)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Reject older config-entry versions with a friendly recreate message.
@ -121,6 +149,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
with `OptionsFlowWithReload`, so we deliberately do NOT register one.
"""
_remove_orphan_sleep_entities(hass, config_entry)
_remove_orphan_lux_sensors(hass, config_entry)
data = hass.data.setdefault(DOMAIN, {})

View file

@ -236,3 +236,23 @@ def lerp(x: float, x1: float, x2: float, y1: float, y2: float) -> float:
def clamp(value: float, minimum: float, maximum: float) -> float:
"""Clamp value between minimum and maximum."""
return max(minimum, min(value, maximum))
def lux_reduce(
curve_brightness: float,
target_lux: int,
current_lux: float,
min_brightness: int,
) -> float | None:
"""Apply reduce-only lux gate to curve brightness.
Returns adjusted brightness, or None if lights should turn off.
"""
if current_lux <= 0 or target_lux <= 0:
return curve_brightness
if current_lux <= target_lux:
return curve_brightness
adjusted = curve_brightness * (target_lux / current_lux)
if adjusted < min_brightness:
return None
return adjusted

View file

@ -40,6 +40,7 @@ from .const import (
CONF_INTERCEPT,
CONF_INTERVAL,
CONF_LIGHTS,
CONF_LUX_SENSOR,
CONF_MAX_BRIGHTNESS,
CONF_MAX_COLOR_TEMP,
CONF_MIN_BRIGHTNESS,
@ -51,11 +52,13 @@ from .const import (
CONF_SKIP_REDUNDANT_COMMANDS,
CONF_SUNRISE_ENTITY,
CONF_SUNSET_ENTITY,
CONF_TARGET_LUX,
CONF_TRANSITION,
CONFIG_ENTRY_VERSION,
DEFAULT_INITIAL_TRANSITION,
DEFAULT_INTERCEPT,
DEFAULT_INTERVAL,
DEFAULT_LUX_SENSOR,
DEFAULT_MAX_BRIGHTNESS,
DEFAULT_MAX_COLOR_TEMP,
DEFAULT_MIN_BRIGHTNESS,
@ -67,6 +70,7 @@ from .const import (
DEFAULT_SKIP_REDUNDANT_COMMANDS,
DEFAULT_SUNRISE_ENTITY,
DEFAULT_SUNSET_ENTITY,
DEFAULT_TARGET_LUX,
DEFAULT_TRANSITION,
DOMAIN,
RANGE_ENTITIES,
@ -79,6 +83,7 @@ _LOGGER = logging.getLogger(__name__)
SECTION_TARGETS = "targets"
SECTION_DAYTIME = "daytime_curve"
SECTION_SUN = "sun_schedule"
SECTION_AMBIENT_LUX = "ambient_lux"
SECTION_LIGHT_CONTROL = "light_control"
SECTION_ADVANCED = "advanced"
SECTION_DIAGNOSTICS = "diagnostics"
@ -145,6 +150,24 @@ def _sun_event_selector() -> EntitySelector:
)
def _lux_sensor_selector() -> EntitySelector:
return EntitySelector(
EntitySelectorConfig(domain="sensor", device_class="illuminance"),
)
def _target_lux_selector() -> NumberSelector:
return NumberSelector(
NumberSelectorConfig(
min=1,
max=10000,
step=10,
unit_of_measurement="lx",
mode=NumberSelectorMode.BOX,
),
)
# --- Schema builder ---
@ -152,6 +175,7 @@ def _build_options_schema(
current: dict[str, Any],
*,
show_send_split_delay: bool,
show_target_lux: bool,
) -> vol.Schema:
"""Build the sectioned options schema from the entry's current values."""
targets_section = section(
@ -213,6 +237,25 @@ def _build_options_schema(
{"collapsed": False},
)
ambient_lux_schema: dict[Any, Any] = {
vol.Optional(
CONF_LUX_SENSOR,
default=current.get(CONF_LUX_SENSOR, DEFAULT_LUX_SENSOR),
): _lux_sensor_selector(),
}
if show_target_lux:
ambient_lux_schema[
vol.Optional(
CONF_TARGET_LUX,
default=current.get(CONF_TARGET_LUX, DEFAULT_TARGET_LUX),
)
] = _target_lux_selector()
ambient_lux_section = section(
vol.Schema(ambient_lux_schema),
{"collapsed": True},
)
light_control_section = section(
vol.Schema(
{
@ -296,6 +339,7 @@ def _build_options_schema(
vol.Required(SECTION_TARGETS): targets_section,
vol.Required(SECTION_DAYTIME): daytime_section,
vol.Required(SECTION_SUN): sun_section,
vol.Required(SECTION_AMBIENT_LUX): ambient_lux_section,
vol.Required(SECTION_LIGHT_CONTROL): light_control_section,
vol.Required(SECTION_ADVANCED): advanced_section,
vol.Required(SECTION_DIAGNOSTICS): diagnostics_section,
@ -422,6 +466,16 @@ class OptionsFlowHandler(OptionsFlowWithReload):
if not errors:
return self.async_create_entry(title="", data=flat)
lux_sensor_id = current.get(CONF_LUX_SENSOR, DEFAULT_LUX_SENSOR)
lux_reading = ""
if lux_sensor_id:
lux_state = self.hass.states.get(lux_sensor_id)
if lux_state and lux_state.state not in ("unavailable", "unknown"):
try:
lux_reading = f"{float(lux_state.state):.0f} lx"
except (TypeError, ValueError):
pass
return self.async_show_form(
step_id="init",
data_schema=_build_options_schema(
@ -432,6 +486,8 @@ class OptionsFlowHandler(OptionsFlowWithReload):
DEFAULT_SEPARATE_TURN_ON_COMMANDS,
),
),
show_target_lux=bool(lux_sensor_id),
),
description_placeholders={"current_lux": lux_reading},
errors=errors,
)

View file

@ -131,6 +131,18 @@ DOCS[CONF_INTERCEPT] = (
"of waiting for the next scheduled update."
)
CONF_LUX_SENSOR, DEFAULT_LUX_SENSOR = "lux_sensor", ""
DOCS[CONF_LUX_SENSOR] = (
"An illuminance sensor used to dim lights when ambient light exceeds the target. "
"Leave empty to use the sun curve alone."
)
CONF_TARGET_LUX, DEFAULT_TARGET_LUX = "target_lux", 0
DOCS[CONF_TARGET_LUX] = (
"Desired illuminance in lux. When the sensor reads above this value, "
"lights dim proportionally. 0 disables lux-based dimming."
)
CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT = (
"multi_light_intercept",
True,
@ -220,6 +232,20 @@ OUTPUT_SENSORS: list[dict[str, Any]] = [
"unit": "°",
"icon": "mdi:weather-sunset",
},
{
"key": "ambient_lux",
"name": "Ambient lux",
"unit": "lx",
"icon": "mdi:brightness-5",
"conditional": True,
},
{
"key": "lux_reduction",
"name": "Lux reduction",
"unit": "%",
"icon": "mdi:chart-line-variant",
"conditional": True,
},
]
# Dispatcher signal used by the master switch to wake the output sensors
@ -279,6 +305,8 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [
(CONF_INTERCEPT, DEFAULT_INTERCEPT, bool),
(CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT, bool),
(CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES, bool),
(CONF_LUX_SENSOR, DEFAULT_LUX_SENSOR, cv.string),
(CONF_TARGET_LUX, DEFAULT_TARGET_LUX, int_between(0, 10000)),
]

View file

@ -9,5 +9,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/CaseyRo/adaptive-lighting/issues",
"requirements": ["ulid-transform"],
"version": "2.2.0-cdit.1"
"version": "2.3.0-cdit.1"
}

View file

@ -24,7 +24,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
from .const import DOMAIN, OUTPUT_SENSORS, SIGNAL_OUTPUTS_UPDATED
from .const import CONF_LUX_SENSOR, DOMAIN, OUTPUT_SENSORS, SIGNAL_OUTPUTS_UPDATED
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
@ -39,7 +39,11 @@ async def async_setup_entry(
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the three output sensors for this config entry."""
"""Create the output sensors for this config entry."""
has_lux = bool(
config_entry.options.get(CONF_LUX_SENSOR)
or config_entry.data.get(CONF_LUX_SENSOR)
)
entities = [
AdaptiveOutputSensor(
hass=hass,
@ -50,6 +54,7 @@ async def async_setup_entry(
icon=row["icon"],
)
for row in OUTPUT_SENSORS
if not row.get("conditional") or has_lux
]
async_add_entities(entities)

View file

@ -66,6 +66,18 @@
"sunset_entity": "Any sensor that returns the next sunset timestamp."
}
},
"ambient_lux": {
"name": "Ambient lux",
"description": "Save energy when daylight is bright enough. Your sensor currently reads: {current_lux}.",
"data": {
"lux_sensor": "Lux sensor",
"target_lux": "Target illuminance (lux)"
},
"data_description": {
"lux_sensor": "An illuminance sensor near these lights. Leave empty to use the sun curve alone.",
"target_lux": "Lights dim when ambient light exceeds this value. Below min brightness they turn off entirely."
}
},
"light_control": {
"name": "Light control",
"description": "How this integration talks to your lights when you turn them on by hand or by voice.",
@ -268,6 +280,12 @@
},
"sun_elevation": {
"name": "Sun elevation"
},
"ambient_lux": {
"name": "Ambient lux"
},
"lux_reduction": {
"name": "Lux reduction"
}
}
}

View file

@ -61,6 +61,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.helpers.event import (
EventStateChangedData,
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.helpers.restore_state import RestoreEntity
@ -78,7 +79,7 @@ from .adaptation_utils import (
has_effect_attribute,
prepare_adaptation_data,
)
from .color_and_brightness import SunLightSettings
from .color_and_brightness import SunLightSettings, lux_reduce
from .const import (
ADAPT_BRIGHTNESS_SWITCH,
ADAPT_COLOR_SWITCH,
@ -91,6 +92,7 @@ from .const import (
CONF_INTERCEPT,
CONF_INTERVAL,
CONF_LIGHTS,
CONF_LUX_SENSOR,
CONF_MAX_BRIGHTNESS,
CONF_MAX_COLOR_TEMP,
CONF_MIN_BRIGHTNESS,
@ -102,6 +104,7 @@ from .const import (
CONF_SKIP_REDUNDANT_COMMANDS,
CONF_SUNRISE_ENTITY,
CONF_SUNSET_ENTITY,
CONF_TARGET_LUX,
CONF_TRANSITION,
CONF_TURN_ON_LIGHTS,
CONF_USE_DEFAULTS,
@ -841,6 +844,13 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._multi_light_intercept = False
self._sunrise_entity = data[CONF_SUNRISE_ENTITY]
self._sunset_entity = data[CONF_SUNSET_ENTITY]
self._lux_sensor: str = data.get(CONF_LUX_SENSOR, "")
self._target_lux: int = data.get(CONF_TARGET_LUX, 0)
self._lux_turned_off: set[str] = getattr(self, "_lux_turned_off", set())
self._last_lux_factor: float = 1.0
self._remove_lux_listener: CALLBACK_TYPE | None = getattr(
self, "_remove_lux_listener", None
)
self._expand_light_groups()
# Snapshot the four range values for the fallback path; the
@ -1000,6 +1010,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Remove the listeners upon removing the component."""
self._remove_listeners()
if self._remove_lux_listener is not None:
self._remove_lux_listener()
self._remove_lux_listener = None
def _expand_light_groups(self, hass: HomeAssistant | None = None) -> None:
hass = hass or self.hass
@ -1016,6 +1029,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
assert not self.remove_listeners
self._update_time_interval_listener()
self._setup_lux_sensor_listener()
self._expand_light_groups()
def _update_time_interval_listener(self) -> None:
@ -1044,6 +1058,61 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
interval=adaptation_interval,
)
def _setup_lux_sensor_listener(self) -> None:
"""Register a state listener on the lux sensor if configured."""
if self._remove_lux_listener is not None:
self._remove_lux_listener()
self._remove_lux_listener = None
if not self._lux_sensor or not self._target_lux:
return
@callback
def _lux_state_changed(event: Event[EventStateChangedData]) -> None:
new_state = event.data["new_state"]
if new_state is None or new_state.state in ("unavailable", "unknown"):
return
try:
current_lux = float(new_state.state)
except (TypeError, ValueError):
return
if self._target_lux <= 0 or current_lux <= 0:
return
new_factor = (
min(self._target_lux / current_lux, 1.0)
if current_lux > self._target_lux
else 1.0
)
old_factor = self._last_lux_factor
crossed_threshold = (old_factor >= 1.0) != (new_factor >= 1.0)
factor_delta = abs(new_factor - old_factor) * 100
if crossed_threshold or factor_delta > 5:
context = self.create_context("lux_change")
self.hass.async_create_task(
self._update_attrs_and_maybe_adapt_lights(
context=context, force=True,
),
)
self._remove_lux_listener = async_track_state_change_event(
self.hass,
[self._lux_sensor],
_lux_state_changed,
)
def _read_lux_sensor(self) -> float | None:
"""Read the configured lux sensor's current numeric value."""
if not self._lux_sensor:
return None
state = self.hass.states.get(self._lux_sensor)
if state is None or state.state in ("unavailable", "unknown"):
return None
try:
value = float(state.state)
return value if value > 0 else None
except (TypeError, ValueError):
return None
def _call_on_remove_callbacks(self) -> None:
"""Call callbacks registered by async_on_remove."""
# This is called when the integration is removed from HA
@ -1186,6 +1255,39 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
t_sunset,
)
# Lux gate: reduce brightness when ambient light exceeds target.
current_lux = self._read_lux_sensor()
if (
current_lux is not None
and self._target_lux > 0
and current_lux > self._target_lux
):
min_b = self._get_runtime_range("min_brightness")
adjusted = lux_reduce(
self._settings["brightness_pct"],
self._target_lux,
current_lux,
min_b,
)
if adjusted is None:
self._lux_turned_off.add(light)
self._last_lux_factor = 0.0
context = context or self.create_context("adapt_lights")
await self.hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: light, ATTR_TRANSITION: transition or 0},
context=context,
)
return None
self._last_lux_factor = self._target_lux / current_lux
self._settings["brightness_pct"] = adjusted
else:
if current_lux is not None and self._target_lux > 0:
self._last_lux_factor = 1.0
if light in self._lux_turned_off:
self._lux_turned_off.discard(light)
# Build service data.
service_data: dict[str, Any] = {ATTR_ENTITY_ID: light}
features = _supported_features(self.hass, light)
@ -1358,10 +1460,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
Writes ``hass.data[DOMAIN][entry_id]["outputs"]`` from the
just-populated ``self._settings`` plus the current ``sun.sun``
elevation, then fires a per-entry dispatcher signal so the three
elevation, then fires a per-entry dispatcher signal so the
``sensor.<profile>_*`` entities update their state on the same
tick rhythm as the switch itself. See
``add-output-sensors/design.md`` decisions 2, 3, and 8.
tick rhythm as the switch itself.
"""
sun_state = self.hass.states.get("sun.sun")
sun_elevation = (
@ -1369,11 +1470,27 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
)
brightness = self._settings.get("brightness_pct")
color_temp = self._settings.get("color_temp_kelvin")
current_lux = self._read_lux_sensor()
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,
)
else:
lux_reduction = 100
else:
ambient_lux = current_lux
lux_reduction = None
entry_data = self.hass.data[DOMAIN].setdefault(self._config_entry.entry_id, {})
entry_data["outputs"] = {
"output_brightness": int(brightness) if brightness is not None else None,
"output_color_temp": int(color_temp) if color_temp is not None else None,
"sun_elevation": sun_elevation,
"ambient_lux": ambient_lux,
"lux_reduction": lux_reduction,
"updated_at": dt_util.utcnow(),
}
async_dispatcher_send(
@ -1416,6 +1533,29 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
if lights is None:
lights = self.lights
# Lux-off recovery: if lux dropped back below target, turn lights
# back on that we previously turned off due to ambient brightness.
if self._lux_turned_off and self._target_lux > 0:
current_lux = self._read_lux_sensor()
if current_lux is not None and current_lux <= self._target_lux:
recovering = [l for l in self._lux_turned_off if l in lights]
self._lux_turned_off -= set(recovering)
for light_id in recovering:
_LOGGER.debug(
"%s: Lux dropped below target, turning '%s' back on",
self._name,
light_id,
)
await self.hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: light_id,
ATTR_TRANSITION: self.initial_transition,
},
context=context,
)
on_lights = [light for light in lights if is_on(self.hass, light)]
if force:

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-24

View file

@ -0,0 +1,160 @@
## Context
The CDiT Adaptive Lighting fork computes brightness from a tanh sun-curve (`color_and_brightness.py`) and dispatches it to lights every `interval` seconds via the adapt loop in `switch.py`. The curve is purely time-based — it has no awareness of ambient light conditions. This design adds an optional lux-feedback gate that can only *reduce* brightness when a room is already bright enough from daylight. The gate sits between the curve output and the service-data assembly, touching a narrow slice of the adapt path.
Existing infrastructure this builds on:
- **`OUTPUT_SENSORS`** in `const.py` — declarative list driving `sensor.py` entity creation. Adding entries here is the established pattern (see `add-output-sensors`).
- **Sectioned options flow** in `config_flow.py` — collapsed sections, conditional field visibility (`send_split_delay` pattern), `EntitySelectorConfig` with `device_class` filtering.
- **`VALIDATION_TUPLES`** in `const.py` — single source for field validation and YAML schema.
- **Dispatcher signal** `SIGNAL_OUTPUTS_UPDATED` — already wakes output sensors on each curve tick.
## Goals / Non-Goals
**Goals:**
- Reduce energy waste when daylight already meets or exceeds the user's comfort threshold.
- Keep configuration minimal: two fields (`lux_sensor`, `target_lux`), no new concepts.
- Self-stabilising — no oscillation, no feedback spiral, no tuning required.
- Zero-impact on profiles without a lux sensor configured.
- Show the user their current lux reading in the config flow so they can set a sensible target.
- Turn lights off entirely when their contribution would be negligible.
**Non-Goals:**
- Full closed-loop illuminance control (boosting lights when room is too dark). The curve already handles that adequately; adding a boost path introduces the feedback spiral.
- Per-light lux sensors. One sensor per AL profile is sufficient — rooms typically have one ambient-light characteristic.
- Adjusting color temperature based on lux. Color temp is circadian, not energy-related.
- Hysteresis or PID control. The `1/ratio` reduction is inherently smooth and self-stabilising; adding control-theory complexity is not warranted for the problem size.
## Decisions
### D1: Reduce-only gate, never boost
**Choice**: The lux gate can only multiply brightness by a factor in `(0, 1]`. When `current_lux ≤ target_lux`, the factor is 1.0 (pass-through).
**Rejected alternative**: Bidirectional offset (boost when too dark, reduce when too bright). This creates positive feedback — lights turn on, lux rises, integration reduces, lux drops, integration boosts. The reduce-only design avoids this entirely because reducing brightness can only lower lux, which relaxes the gate. Self-stabilising by construction.
### D2: Reduction function is `target_lux / current_lux`
**Choice**: `factor = target_lux / current_lux` when `current_lux > target_lux`, else `1.0`.
Properties:
- At `current = 2 × target`, factor = 0.50 (halve brightness).
- At `current = 10 × target`, factor = 0.10 (10% brightness).
- Monotonically decreasing, smooth, no discontinuities.
- Physically intuitive: if you have twice the light you need, halving the artificial contribution is the right response.
**Rejected alternatives**:
- `1 / (1 + ln(ratio))` — gentler curve, but less energy-efficient and harder to reason about.
- `1 / ratio²` — too aggressive; at 1.5× overshoot the lights are already at 44%.
- Linear ramp with cutoff — discontinuity at the cutoff threshold.
### D3: Auto-off below `min_brightness`
**Choice**: When `curve_brightness × factor < min_brightness`, send `light.turn_off` instead of `light.turn_on` with a tiny brightness value. When lux drops back below target on the next interval tick, the normal adapt cycle turns lights back on.
**Rationale**: `min_brightness` already expresses "the lowest I ever want my lights." If daylight is so abundant that the artificial contribution would be below that floor, the lights are contributing effectively nothing — turning off is the logical conclusion and saves the most energy.
**No new setting needed**: reuses `min_brightness` as the threshold.
### D4: Two config fields, one new section
**Choice**:
- `lux_sensor`: `EntitySelector(domain="sensor", device_class="illuminance")`. Optional, default `""` (empty string = feature disabled).
- `target_lux`: `NumberSelector(min=1, max=10000, step=10, unit="lx", mode=BOX)`. Only shown when `lux_sensor` is populated (same conditional pattern as `send_split_delay` / `separate_turn_on_commands`).
Both live in a new "Ambient lux" section, collapsed by default. The section description uses `description_placeholders` to show the sensor's current reading when configured: `"Your sensor currently reads: {current_lux}"`.
**Rejected alternative**: putting lux fields in the existing "Daytime curve" section. These are conceptually separate — the curve sets intent, the lux gate adjusts for reality. A dedicated section with its own description keeps the mental model clean.
### D5: Live lux reading in config flow via `description_placeholders`
**Choice**: In `OptionsFlowHandler.async_step_init`, before calling `async_show_form`, read the configured lux sensor's state and pass it into `description_placeholders`:
```python
lux_reading = "—"
sensor_id = current.get(CONF_LUX_SENSOR)
if sensor_id:
state = self.hass.states.get(sensor_id)
if state and state.state not in ("unavailable", "unknown"):
lux_reading = f"{state.state} lx"
```
Section description in `strings.json`: `"Save energy when daylight is bright enough. Your sensor currently reads: {current_lux}."` — where `{current_lux}` is filled by the placeholder. When no sensor is configured, the placeholder shows "—".
**Limitation**: On the very first configuration (sensor not yet saved), the reading won't appear until the user saves and reopens options. Acceptable — it's a one-time thing.
### D6: `lux_reduce` as a pure function in `color_and_brightness.py`
**Choice**: Add a single pure function:
```python
def lux_reduce(
curve_brightness: float,
target_lux: int,
current_lux: float,
min_brightness: int,
) -> float | None:
"""Apply reduce-only lux gate to curve brightness.
Returns adjusted brightness, or None if lights should turn off.
"""
if current_lux <= target_lux:
return curve_brightness
adjusted = curve_brightness * (target_lux / current_lux)
if adjusted < min_brightness:
return None
return adjusted
```
Lives in `color_and_brightness.py` alongside the existing curve math. No class, no state — just a function that takes numbers and returns a number. Easy to unit-test in isolation.
**Rejected alternative**: method on `SunLightSettings`. That dataclass is intentionally HA-free (no sensor access). The lux reduction depends on live sensor state, so it belongs in the call chain between curve output and service-data assembly, not inside the curve math itself.
### D7: Two new output sensors: `ambient_lux` and `lux_reduction`
**Choice**: Add two entries to `OUTPUT_SENSORS` in `const.py`:
| key | name | unit | icon |
|---|---|---|---|
| `ambient_lux` | Ambient lux | lx | `mdi:brightness-5` |
| `lux_reduction` | Lux reduction | % | `mdi:chart-line-variant` |
`ambient_lux` is a pass-through of the configured sensor's reading — value is in `outputs["ambient_lux"]`, updated each tick. `lux_reduction` is `factor × 100` (100 = no reduction, 50 = halved).
Both show `unavailable` when no `lux_sensor` is configured (the existing sensor platform already handles `None` values as `unavailable`).
**Rationale for `ambient_lux`**: mirrors the lux reading into the AL profile's entity set so a single dashboard card tells the full story — curve output, ambient conditions, and the reduction factor — without the user correlating separate entities.
### D8: Sensor state subscription for responsive adaptation
**Choice**: In `switch.py`, when `lux_sensor` is configured, register an `async_track_state_change_event` listener on the lux sensor entity. On state change, trigger `_update_attrs_and_maybe_adapt_lights` (the same path the interval timer uses). This makes the reduction responsive to rapid lux changes (cloud passing, blinds opening) rather than waiting up to `interval` seconds.
**Guard against churn**: only trigger re-adaptation if the lux change crosses the target threshold (was below, now above — or vice versa), OR if the change in lux would alter the reduction factor by more than 5 percentage points. This prevents thrashing on noisy sensors.
**Teardown**: the listener is removed in `async_will_remove_from_hass`, same pattern as the existing interval timer removal.
### D9: Turn-off and turn-back-on behaviour
**Choice**: When `lux_reduce` returns `None` (below `min_brightness`):
- If the light is currently on, send `light.turn_off` with the profile's `transition` time for a graceful fade.
- Set an internal flag `_lux_turned_off` per light entity.
- On subsequent ticks, if `lux_reduce` returns a non-`None` value and `_lux_turned_off` is set, send `light.turn_on` with the adjusted brightness and `initial_transition`.
- Clear the flag.
This ensures the integration only turns lights back on that *it* turned off due to lux — not lights the user manually turned off. The flag is reset on HA restart (lights default to curve behaviour, which is correct).
**Rejected alternative**: relying on the existing `intercept` mechanism to handle turn-on. Intercept fires on user-initiated `turn_on` calls, not integration-initiated ones. The flag approach is explicit and doesn't tangle with intercept logic.
## Risks / Trade-offs
**[Noisy sensors]** → Cheap lux sensors can fluctuate ±50 lx between reads. The 5%-factor guard in D8 mitigates this. If still problematic, users can add a template sensor with a moving average — but that's external to AL, keeping the integration simple.
**[Sensor lag]** → Some sensors report every 3060 seconds. The interval timer still fires independently, so the worst case is one interval tick with stale lux data — the next tick corrects. Not a problem in practice.
**[User turns off lights manually, lux drops, integration turns them back on]** → The existing `intercept` and context-tracking logic in `switch.py` already handles this: manually-turned-off lights are excluded from adaptation until the user turns them on again. The lux gate doesn't change this — `_lux_turned_off` is a separate flag only for lux-initiated turn-offs.
**[Breaking change risk]** → None. Both fields are optional with empty/zero defaults. Existing config entries are unaffected. Minor version bump only.
## Open Questions
1. **Suggested default for `target_lux`**: 500 lx (typical office/living room comfort level per EN 12464-1) or 0 (disabled)? Leaning toward 0 (disabled) since the feature requires a sensor to be meaningful — a non-zero default without a sensor configured would be confusing.
2. **Should `lux_reduce` round to the nearest 5% to reduce command churn?** The existing `skip_redundant_commands` logic may already cover this, but worth verifying during implementation.

View file

@ -0,0 +1,36 @@
## Why
Adaptive Lighting sets brightness from a sun-curve — it knows the *time of day* but not the *actual light level in the room*. A room with skylights may already be bathed in 600 lux at noon; cranking the ceiling LEDs to 100 % is wasteful and glaring. The curve is right about *intent* but blind to *ambient conditions*. Closing the loop with a lux sensor lets the integration reduce brightness when daylight alone is sufficient, saving energy without sacrificing comfort. Rooms without a sensor keep the existing open-loop behaviour unchanged.
## What Changes
- **Reduce-only lux gate**: when a lux sensor reads *above* the user's target, the integration proportionally dims the lights using `factor = target_lux / current_lux`. When the sensor reads at or below target, the curve stands — the gate never *boosts* brightness. This one-way design eliminates the classic feedback spiral (lights on → sensor reads higher → integration dims → too dark → integration boosts → repeat) because reducing brightness can only lower lux, which self-stabilises.
- **Two new optional per-profile settings**:
- `lux_sensor` — entity ID, pre-filtered in the UI to `domain=sensor, device_class=illuminance`.
- `target_lux` — integer (lux). Only shown in the config flow when `lux_sensor` is populated.
- **Auto-off below min_brightness**: if the lux reduction drives the adjusted brightness below the profile's existing `min_brightness`, the lights turn off entirely — they're contributing effectively nothing.
- **Live lux reading in the config flow**: when a `lux_sensor` is already configured, the "Ambient lux" section description shows the sensor's current reading via `description_placeholders`, helping the user calibrate their target to their actual space.
- **Graceful degradation**: if `lux_sensor` is unavailable, unknown, or not configured, the profile falls back to pure curve brightness — no error, no manual intervention needed.
- **Two new output sensors**:
- `ambient_lux` — pass-through of the configured lux sensor's current reading (unavailable when no sensor configured).
- `lux_reduction` — the applied reduction factor as a percentage (100 % = no reduction, 50 % = halved). Unavailable when no sensor configured.
- **Color temperature unchanged** — the lux gate only affects brightness. Color temp stays on the sun curve.
## Capabilities
### New Capabilities
- `lux-feedback`: reduce-only ambient-lux gate — sensor binding, `target/current` proportional reduction, auto-off below `min_brightness`, and graceful degradation to curve-only mode.
### Modified Capabilities
- `options-flow`: adds the two new fields (`lux_sensor`, `target_lux`) to the config-flow UI and VALIDATION_TUPLES, in a new collapsed "Ambient lux" section with live-reading description placeholder.
- `output-sensors`: adds `ambient_lux` and `lux_reduction` sensors to the existing output-sensor set.
## Impact
- **`const.py`**: two new `CONF_` / `DEFAULT_` constants; two new entries in `VALIDATION_TUPLES`; two new entries in `OUTPUT_SENSORS`.
- **`config_flow.py`**: new "Ambient lux" section (collapsed) with entity selector filtered to `device_class=illuminance`, conditional `target_lux` number input, and `description_placeholders` for the live reading.
- **`color_and_brightness.py`**: new pure function `lux_reduce(curve_brightness, target_lux, current_lux, min_brightness) → float | None` — returns adjusted brightness or `None` (meaning turn off).
- **`switch.py`**: in the adapt path, read `lux_sensor` state and apply `lux_reduce` before assembling `service_data`. If result is `None`, send `light.turn_off` instead of `light.turn_on`. Subscribe to sensor state changes to trigger re-adaptation when lux shifts.
- **`strings.json`**: new section, field labels, and description with `{current_lux}` placeholder.
- **`manifest.json`**: minor version bump only (additive, non-breaking).
- **Tests**: unit tests for `lux_reduce` math (including edge cases: ratio exactly 1.0, sensor unavailable, result below min_brightness) + integration tests for sensor-available, sensor-unavailable, and no-sensor-configured paths.

View file

@ -0,0 +1,140 @@
## ADDED 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

@ -0,0 +1,109 @@
## MODIFIED Requirements
### Requirement: Options dialog presents fields in named collapsible sections
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` |
#### 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 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
- **WHEN** the user expands any section in the options dialog
- **THEN** the fields shown in that section SHALL exactly match the field list in the table above for that section
- **AND** no field SHALL appear in more than one section
### Requirement: Conditional fields hide when their driver makes them irrelevant
Fields whose configuration is meaningful only under a specific value of another field ("driver") SHALL be omitted from the rendered schema when the driver value makes them inapplicable. When the driver value changes, the form SHALL be re-submitted to re-render with the updated field set.
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
- **WHEN** the user opens the options dialog with `separate_turn_on_commands` set to `false`
- **THEN** the Advanced section SHALL NOT include the `send_split_delay` field
#### Scenario: send_split_delay revealed when transport mode enables it
- **WHEN** the user toggles `separate_turn_on_commands` to `true` and submits the form
- **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: All configurable fields use native HA selectors
Every field in the options dialog SHALL be rendered using a class from `homeassistant.helpers.selector`. The selector mapping includes:
| Field type | Selector |
|---|---|
| Numeric range (brightness, color temp) | `NumberSelector` with explicit `min`, `max`, `step`, `unit_of_measurement`, `mode=SLIDER` |
| Duration (seconds) | `NumberSelector` with `unit_of_measurement="s"`, `mode=BOX` |
| Duration (milliseconds) | `NumberSelector` with `unit_of_measurement="ms"`, `mode=BOX` |
| 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: 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

View file

@ -0,0 +1,89 @@
## MODIFIED Requirements
### Requirement: Each AL profile exposes three output sensor entities plus two conditional lux sensors
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` | icon | Condition |
|---|---|---|---|---|---|---|
| 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: Profile with lux sensor produces five sensor entities
- **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** 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
### 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

View file

@ -0,0 +1,46 @@
## 1. Constants and config schema
- [x] 1.1 Add `CONF_LUX_SENSOR` / `DEFAULT_LUX_SENSOR` (`""`) and `CONF_TARGET_LUX` / `DEFAULT_TARGET_LUX` (`0`) to `const.py` with `DOCS` entries [R: options-flow selectors, D4]
- [x] 1.2 Add both fields to `VALIDATION_TUPLES``lux_sensor` as `cv.entity_id`, `target_lux` as `int_between(0, 10000)` [R: options-flow selectors, D4]
- [x] 1.3 Add `ambient_lux` and `lux_reduction` entries to `OUTPUT_SENSORS` in `const.py` [R: output-sensors entity table, D7]
## 2. Pure lux-reduction math
- [x] 2.1 Implement `lux_reduce(curve_brightness, target_lux, current_lux, min_brightness) → float | None` in `color_and_brightness.py` [R: lux-feedback pure function, D2, D6]
- [x] 2.2 Unit tests for `lux_reduce`: above target, below target, exactly at target, below min_brightness → None, zero/negative current_lux → pass-through [R: lux-feedback pure function scenarios]
## 3. Config flow — Ambient lux section
- [x] 3.1 Add `_lux_sensor_selector()` factory returning `EntitySelector(domain="sensor", device_class="illuminance")` in `config_flow.py` [R: options-flow selectors, D4]
- [x] 3.2 Add `_target_lux_selector()` factory returning `NumberSelector(min=1, max=10000, step=10, unit="lx", mode=BOX)` [R: options-flow selectors, D4]
- [x] 3.3 Build the "Ambient lux" section in `_build_options_schema` — collapsed, `lux_sensor` always shown, `target_lux` conditional on `lux_sensor` being non-empty (same pattern as `send_split_delay`) [R: options-flow sections, options-flow conditionals, D4]
- [x] 3.4 Read the lux sensor's current state in `async_step_init` and pass it to `async_show_form` via `description_placeholders={"current_lux": lux_reading}` [R: options-flow live reading, D5]
- [x] 3.5 Add section, field labels, and description (with `{current_lux}` placeholder) to `strings.json` [R: options-flow live reading, D5]
## 4. Switch adapt path — lux gate integration
- [x] 4.1 In `switch.py` `_prepare_adaptation_data` (or its caller), read `lux_sensor` state from `self.hass.states.get()`, call `lux_reduce`, and replace `brightness_pct` in `self._settings` before service-data assembly [R: lux-feedback reduce-only gate, D1, D2, D6]
- [x] 4.2 Handle `lux_reduce` returning `None`: send `light.turn_off` with `transition`, set per-light `_lux_turned_off` flag [R: lux-feedback auto-off, D3, D9]
- [x] 4.3 Handle lux-off recovery: when `lux_reduce` returns non-`None` and `_lux_turned_off` is set, send `light.turn_on` with adjusted brightness and `initial_transition`, clear flag [R: lux-feedback auto-off recovery, D9]
- [x] 4.4 Register `async_track_state_change_event` on `lux_sensor` when configured, with 5pp significance guard; teardown in `async_will_remove_from_hass` [R: lux-feedback sensor subscription, D8]
## 5. Output sensors — lux values
- [x] 5.1 Publish `ambient_lux` and `lux_reduction` to the `outputs` cache dict alongside existing keys on each curve tick [R: output-sensors ambient lux, output-sensors lux reduction, D7]
- [x] 5.2 Conditionally create `_ambient_lux` and `_lux_reduction` sensor entities only when `lux_sensor` is configured; clean up entities on reconfigure when sensor is removed [R: output-sensors conditional creation]
- [x] 5.3 Ensure both new sensors subscribe to `SIGNAL_OUTPUTS_UPDATED` via the existing dispatcher pattern [R: output-sensors dispatcher pattern]
## 6. Strings and manifest
- [x] 6.1 Add `entity.sensor.ambient_lux` and `entity.sensor.lux_reduction` name entries to `strings.json` [R: output-sensors entity table]
- [x] 6.2 Bump minor version in `manifest.json` [D: non-breaking additive change]
## 7. Integration tests
- [x] 7.1 Test: profile without lux sensor — curve brightness unchanged, no lux output sensors created [R: lux-feedback graceful degradation, output-sensors conditional]
- [x] 7.2 Test: profile with lux sensor above target — brightness reduced by correct factor [R: lux-feedback reduce-only gate]
- [x] 7.3 Test: lux drives brightness below min_brightness — light turns off, turns back on when lux drops [R: lux-feedback auto-off and recovery, D9]
- [x] 7.4 Test: lux sensor becomes unavailable — falls back to curve brightness, warning logged once [R: lux-feedback graceful degradation]
- [x] 7.5 Test: lux sensor state change triggers re-adaptation (crossing target threshold) [R: lux-feedback sensor subscription, D8]
- [x] 7.6 Test: config flow shows live lux reading in description placeholder [R: options-flow live reading, D5]
- [x] 7.7 Test: conditional `target_lux` field visibility in options flow [R: options-flow conditionals]

View file

@ -15,6 +15,7 @@ import pytest
from custom_components.adaptive_lighting.color_and_brightness import (
SunLightSettings,
_tanh_day_curve,
lux_reduce,
)
HALF_WIDTH = 1800 # seconds — matches RAMP_HALF_WIDTH_SECONDS
@ -162,3 +163,43 @@ class TestTanhDayCurveDirect:
half_width=HALF_WIDTH,
)
assert v == 100
class TestLuxReduce:
"""Reduce-only lux gate: target/current ratio, floor at min_brightness."""
def test_above_target_reduces_brightness(self):
result = lux_reduce(85.0, 500, 700, 5)
assert result == pytest.approx(85.0 * 500 / 700, abs=0.1)
def test_below_target_passes_through(self):
assert lux_reduce(85.0, 500, 300, 5) == 85.0
def test_exactly_at_target_passes_through(self):
assert lux_reduce(85.0, 500, 500, 5) == 85.0
def test_below_min_brightness_returns_none(self):
assert lux_reduce(80.0, 500, 10000, 5) is None
def test_zero_current_lux_passes_through(self):
assert lux_reduce(85.0, 500, 0, 5) == 85.0
def test_negative_current_lux_passes_through(self):
assert lux_reduce(85.0, 500, -10, 5) == 85.0
def test_zero_target_lux_passes_through(self):
assert lux_reduce(85.0, 0, 500, 5) == 85.0
def test_exactly_at_min_brightness_keeps_on(self):
# 100 * (500/10000) = 5.0, which is NOT < 5 → stays on at 5.0
result = lux_reduce(100.0, 500, 10000, 5)
assert result == pytest.approx(5.0)
def test_boundary_just_above_min(self):
result = lux_reduce(100.0, 500, 9900, 5)
assert result is not None
assert result >= 5
def test_boundary_just_below_min(self):
result = lux_reduce(100.0, 500, 10100, 5)
assert result is None # 100 * 500/10100 ≈ 4.95 < 5

View file

@ -18,6 +18,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.adaptive_lighting.config_flow import (
SECTION_ADVANCED,
SECTION_AMBIENT_LUX,
SECTION_DAYTIME,
SECTION_DIAGNOSTICS,
SECTION_LIGHT_CONTROL,
@ -30,6 +31,7 @@ from custom_components.adaptive_lighting.const import (
CONF_INTERCEPT,
CONF_INTERVAL,
CONF_LIGHTS,
CONF_LUX_SENSOR,
CONF_MAX_BRIGHTNESS,
CONF_MAX_COLOR_TEMP,
CONF_MIN_BRIGHTNESS,
@ -41,6 +43,7 @@ from custom_components.adaptive_lighting.const import (
CONF_SKIP_REDUNDANT_COMMANDS,
CONF_SUNRISE_ENTITY,
CONF_SUNSET_ENTITY,
CONF_TARGET_LUX,
DEFAULT_NAME,
DEFAULT_SUNRISE_ENTITY,
DEFAULT_SUNSET_ENTITY,
@ -51,6 +54,7 @@ EXPECTED_SECTIONS = (
SECTION_TARGETS,
SECTION_DAYTIME,
SECTION_SUN,
SECTION_AMBIENT_LUX,
SECTION_LIGHT_CONTROL,
SECTION_ADVANCED,
SECTION_DIAGNOSTICS,
@ -66,6 +70,7 @@ EXPECTED_SECTION_FIELDS: dict[str, set[str]] = {
CONF_PREFER_RGB_COLOR,
},
SECTION_SUN: {CONF_SUNRISE_ENTITY, CONF_SUNSET_ENTITY},
SECTION_AMBIENT_LUX: {CONF_LUX_SENSOR},
SECTION_LIGHT_CONTROL: {CONF_INTERCEPT, CONF_MULTI_LIGHT_INTERCEPT},
SECTION_ADVANCED: {
CONF_INTERVAL,
@ -93,9 +98,9 @@ def _section_inner_keys(schema_section) -> set[str]:
# ---------------------------------------------------------------------------
def test_options_schema_has_all_six_sections_in_order() -> None:
"""R1: the options form returns the six named sections in order."""
schema = _build_options_schema({}, show_send_split_delay=False)
def test_options_schema_has_all_seven_sections_in_order() -> None:
"""R1: the options form returns the seven named sections in order."""
schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False)
keys = [
k.schema if hasattr(k, "schema") else k
for k in schema.schema # type: ignore[attr-defined]
@ -107,7 +112,7 @@ def test_each_section_contains_only_its_specified_fields() -> None:
"""R1 scenario 2: every field appears in exactly one section, matching
the layout table.
"""
schema = _build_options_schema({}, show_send_split_delay=True)
schema = _build_options_schema({}, show_send_split_delay=True, show_target_lux=False)
for marker in schema.schema: # type: ignore[attr-defined]
section_id = marker.schema if hasattr(marker, "schema") else marker
inner_fields = _section_inner_keys(schema.schema[marker]) # type: ignore[index]
@ -127,7 +132,7 @@ def test_each_section_contains_only_its_specified_fields() -> None:
def test_send_split_delay_hidden_when_driver_false() -> None:
"""R2: send_split_delay is absent when separate_turn_on_commands=False."""
schema = _build_options_schema({}, show_send_split_delay=False)
schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False)
advanced_marker = next(
m
for m in schema.schema # type: ignore[attr-defined]
@ -139,7 +144,7 @@ def test_send_split_delay_hidden_when_driver_false() -> None:
def test_send_split_delay_visible_when_driver_true() -> None:
"""R2: send_split_delay appears when separate_turn_on_commands=True."""
schema = _build_options_schema({}, show_send_split_delay=True)
schema = _build_options_schema({}, show_send_split_delay=True, show_target_lux=False)
advanced_marker = next(
m
for m in schema.schema # type: ignore[attr-defined]
@ -156,7 +161,7 @@ def test_send_split_delay_visible_when_driver_true() -> None:
def test_default_sunrise_and_sunset_entities() -> None:
"""R3: default entities point at the built-in sun.sun sensors."""
schema = _build_options_schema({}, show_send_split_delay=False)
schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False)
sun_marker = next(
m
for m in schema.schema # type: ignore[attr-defined]
@ -184,7 +189,7 @@ def test_sun_entity_selectors_are_strict_timestamp_sensors() -> None:
"""R3 + D14: both sun-event entity selectors filter by domain=sensor and
device_class=timestamp.
"""
schema = _build_options_schema({}, show_send_split_delay=False)
schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False)
sun_marker = next(
m
for m in schema.schema # type: ignore[attr-defined]
@ -213,7 +218,7 @@ def test_sun_entity_selectors_are_strict_timestamp_sensors() -> None:
def test_brightness_uses_slider_number_selector() -> None:
"""R5: brightness fields are NumberSelectors with slider mode, 1100 %, step 1."""
schema = _build_options_schema({}, show_send_split_delay=False)
schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False)
daytime_marker = next(
m
for m in schema.schema # type: ignore[attr-defined]
@ -234,7 +239,7 @@ def test_brightness_uses_slider_number_selector() -> None:
def test_color_temp_uses_box_number_selector() -> None:
"""R5: color-temp fields are NumberSelectors, 100010000 K, step 100."""
schema = _build_options_schema({}, show_send_split_delay=False)
schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False)
daytime_marker = next(
m
for m in schema.schema # type: ignore[attr-defined]
@ -254,7 +259,7 @@ def test_color_temp_uses_box_number_selector() -> None:
def test_booleans_use_boolean_selector() -> None:
"""R5: every boolean field renders as a BooleanSelector."""
schema = _build_options_schema({}, show_send_split_delay=True)
schema = _build_options_schema({}, show_send_split_delay=True, show_target_lux=False)
boolean_fields = {
CONF_PREFER_RGB_COLOR,
CONF_INTERCEPT,
@ -329,9 +334,109 @@ async def test_options_flow_renders_sectioned_schema(hass) -> None:
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
# The form's data_schema contains the six section keys.
# The form's data_schema contains the seven section keys.
schema_keys = [
(k.schema if hasattr(k, "schema") else k)
for k in result["data_schema"].schema # type: ignore[union-attr,attr-defined]
]
assert tuple(schema_keys) == EXPECTED_SECTIONS
# ---------------------------------------------------------------------------
# Lux: conditional target_lux visibility
# ---------------------------------------------------------------------------
def test_target_lux_hidden_when_no_sensor() -> None:
"""target_lux should not appear when lux_sensor is empty."""
schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False)
lux_marker = next(
m
for m in schema.schema
if (m.schema if hasattr(m, "schema") else m) == SECTION_AMBIENT_LUX
)
lux_fields = _section_inner_keys(schema.schema[lux_marker])
assert CONF_LUX_SENSOR in lux_fields
assert CONF_TARGET_LUX not in lux_fields
def test_target_lux_visible_when_sensor_set() -> None:
"""target_lux should appear when lux_sensor is populated."""
schema = _build_options_schema(
{CONF_LUX_SENSOR: "sensor.office_lux"},
show_send_split_delay=False,
show_target_lux=True,
)
lux_marker = next(
m
for m in schema.schema
if (m.schema if hasattr(m, "schema") else m) == SECTION_AMBIENT_LUX
)
lux_fields = _section_inner_keys(schema.schema[lux_marker])
assert CONF_LUX_SENSOR in lux_fields
assert CONF_TARGET_LUX in lux_fields
def test_lux_sensor_selector_filters_to_illuminance() -> None:
"""lux_sensor entity selector should filter to device_class=illuminance."""
schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False)
lux_marker = next(
m
for m in schema.schema
if (m.schema if hasattr(m, "schema") else m) == SECTION_AMBIENT_LUX
)
lux_inner = schema.schema[lux_marker].schema.schema
for k, v in lux_inner.items():
field_name = k.schema if hasattr(k, "schema") else k
if field_name == CONF_LUX_SENSOR:
assert isinstance(v, EntitySelector)
cfg = v.config
domain = cfg.get("domain")
device_class = cfg.get("device_class")
assert "sensor" in (domain if isinstance(domain, list) else [domain])
assert "illuminance" in (
device_class if isinstance(device_class, list) else [device_class]
)
def test_target_lux_selector_uses_box_mode() -> None:
"""target_lux should be a NumberSelector in BOX mode, range 1-10000, unit lx."""
schema = _build_options_schema(
{CONF_LUX_SENSOR: "sensor.office_lux"},
show_send_split_delay=False,
show_target_lux=True,
)
lux_marker = next(
m
for m in schema.schema
if (m.schema if hasattr(m, "schema") else m) == SECTION_AMBIENT_LUX
)
lux_inner = schema.schema[lux_marker].schema.schema
for k, v in lux_inner.items():
field_name = k.schema if hasattr(k, "schema") else k
if field_name == CONF_TARGET_LUX:
assert isinstance(v, NumberSelector)
cfg = v.config
assert cfg["min"] == 1
assert cfg["max"] == 10000
assert cfg["step"] == 10
assert cfg["unit_of_measurement"] == "lx"
assert cfg["mode"] == NumberSelectorMode.BOX
async def test_options_flow_shows_lux_reading_placeholder(hass) -> None:
"""The options flow should include description_placeholders with current_lux."""
entry = MockConfigEntry(
domain=DOMAIN,
title=DEFAULT_NAME,
data={CONF_NAME: DEFAULT_NAME},
options={},
version=2,
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert "description_placeholders" in result
assert "current_lux" in result["description_placeholders"]

View file

@ -184,6 +184,8 @@ async def test_curve_tick_publishes_outputs(hass) -> None:
"output_brightness",
"output_color_temp",
"sun_elevation",
"ambient_lux",
"lux_reduction",
"updated_at",
}
assert outputs["output_brightness"] == 72
@ -312,6 +314,8 @@ async def test_friendly_names_compose_correctly(hass) -> None:
entry = await _setup_entry(hass)
names = {}
for row in OUTPUT_SENSORS:
if row.get("conditional"):
continue
key = row["key"]
eid = _resolve_entity_id(hass, entry, key)
names[key] = hass.states.get(eid).attributes["friendly_name"]