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: