mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-29 13:24:18 +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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue