mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-16 00:34:04 +02:00
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:
parent
d31d1928e5
commit
11fe23253c
18 changed files with 1047 additions and 19 deletions
|
|
@ -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, {})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
2
openspec/changes/add-lux-target/.openspec.yaml
Normal file
2
openspec/changes/add-lux-target/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-05-24
|
||||
160
openspec/changes/add-lux-target/design.md
Normal file
160
openspec/changes/add-lux-target/design.md
Normal 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 30–60 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.
|
||||
36
openspec/changes/add-lux-target/proposal.md
Normal file
36
openspec/changes/add-lux-target/proposal.md
Normal 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.
|
||||
140
openspec/changes/add-lux-target/specs/lux-feedback/spec.md
Normal file
140
openspec/changes/add-lux-target/specs/lux-feedback/spec.md
Normal 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)
|
||||
109
openspec/changes/add-lux-target/specs/options-flow/spec.md
Normal file
109
openspec/changes/add-lux-target/specs/options-flow/spec.md
Normal 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 0–10000 milliseconds
|
||||
|
||||
#### Scenario: target_lux hidden when no lux sensor is selected
|
||||
|
||||
- **WHEN** the user opens the options dialog with `lux_sensor` set to `""` (empty)
|
||||
- **THEN** the Ambient lux section SHALL show only the `lux_sensor` entity selector
|
||||
- **AND** `target_lux` SHALL NOT appear
|
||||
|
||||
#### Scenario: target_lux revealed when lux sensor is selected
|
||||
|
||||
- **WHEN** the user selects a `lux_sensor` entity and submits the form
|
||||
- **THEN** the options dialog SHALL re-render with `target_lux` present in the Ambient lux section
|
||||
- **AND** the field SHALL accept values in the range 1–10000 lux
|
||||
|
||||
### Requirement: 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 1–10000, step 10
|
||||
- **AND** the field SHALL display the unit "lx"
|
||||
|
||||
### Requirement: Ambient lux section shows the sensor's current reading
|
||||
|
||||
When a `lux_sensor` is configured and its state is numeric, the Ambient lux section description SHALL include the sensor's current reading via `description_placeholders`. This helps the user calibrate their `target_lux` to their actual space.
|
||||
|
||||
#### Scenario: Current lux reading shown in section description
|
||||
|
||||
- **GIVEN** `lux_sensor` is set to `sensor.office_illuminance`
|
||||
- **AND** that sensor's current state is `"340"`
|
||||
- **WHEN** the user opens the options dialog
|
||||
- **THEN** the Ambient lux section description SHALL include the text "340 lx"
|
||||
|
||||
#### Scenario: No reading shown when sensor is not configured
|
||||
|
||||
- **GIVEN** `lux_sensor` is empty (not configured)
|
||||
- **WHEN** the user opens the options dialog
|
||||
- **THEN** the Ambient lux section description SHALL NOT include any lux reading number
|
||||
|
||||
#### Scenario: Fallback when sensor is unavailable
|
||||
|
||||
- **GIVEN** `lux_sensor` is configured but its state is `"unavailable"`
|
||||
- **WHEN** the user opens the options dialog
|
||||
- **THEN** the Ambient lux section description SHALL show a dash or "unavailable" in place of a numeric reading
|
||||
89
openspec/changes/add-lux-target/specs/output-sensors/spec.md
Normal file
89
openspec/changes/add-lux-target/specs/output-sensors/spec.md
Normal 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
|
||||
46
openspec/changes/add-lux-target/tasks.md
Normal file
46
openspec/changes/add-lux-target/tasks.md
Normal 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]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, 1–100 %, 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, 1000–10000 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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue