Skip to content

Commit ad847c0

Browse files
committed
feat: add lux sensor brightness mode (#1177)
Add support for controlling brightness based on an outdoor lux sensor instead of sun position. Users can select "lux" as the brightness_mode and configure a lux sensor entity. New configuration options: - lux_sensor: Entity ID of outdoor illuminance sensor - lux_min: Lux value for minimum brightness (default: 0) - lux_max: Lux value for maximum brightness (default: 10000) - lux_smoothing_samples: Number of samples to average (default: 5) - lux_smoothing_window: Time window in seconds (default: 300) Features: - Linear brightness mapping matching circadian behavior (dark = dim) - Smoothing buffer to prevent rapid fluctuations - Automatic fallback to sun-based calculation when sensor unavailable - Switch attributes show current_lux and lux_samples_count
1 parent 74a795d commit ad847c0

6 files changed

Lines changed: 493 additions & 12 deletions

File tree

custom_components/adaptive_lighting/color_and_brightness.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -226,10 +226,12 @@ class SunLightSettings:
226226
max_sunset_time: datetime.time | None
227227
brightness_mode_time_dark: datetime.timedelta
228228
brightness_mode_time_light: datetime.timedelta
229-
brightness_mode: Literal["default", "linear", "tanh"] = "default"
229+
brightness_mode: Literal["default", "linear", "tanh", "lux"] = "default"
230230
sunrise_offset: datetime.timedelta = datetime.timedelta()
231231
sunset_offset: datetime.timedelta = datetime.timedelta()
232232
timezone: datetime.tzinfo = UTC
233+
lux_min: int = 0
234+
lux_max: int = 10000
233235

234236
@cached_property
235237
def sun(self) -> SunEvents:
@@ -312,12 +314,45 @@ def _brightness_pct_linear(self, dt: datetime.datetime) -> float:
312314
raise ValueError(msg)
313315
return clamp(brightness, self.min_brightness, self.max_brightness)
314316

315-
def brightness_pct(self, dt: datetime.datetime, is_sleep: bool) -> float | None:
316-
"""Calculate the brightness in %."""
317+
def _brightness_pct_lux(self, lux_value: float) -> float:
318+
"""Calculate brightness based on lux value.
319+
320+
Linear mapping matching circadian behavior: low lux = min brightness,
321+
high lux = max brightness. This follows the same philosophy as sun-based
322+
modes where darkness means dimmer lights.
323+
"""
324+
if lux_value <= self.lux_min:
325+
return float(self.min_brightness)
326+
if lux_value >= self.lux_max:
327+
return float(self.max_brightness)
328+
lux_range = self.lux_max - self.lux_min
329+
if lux_range <= 0:
330+
return float(self.min_brightness)
331+
normalized = (lux_value - self.lux_min) / lux_range
332+
brightness = self.min_brightness + (
333+
normalized * (self.max_brightness - self.min_brightness)
334+
)
335+
return clamp(brightness, self.min_brightness, self.max_brightness)
336+
337+
def brightness_pct(
338+
self,
339+
dt: datetime.datetime,
340+
is_sleep: bool,
341+
lux_value: float | None = None,
342+
) -> float | None:
343+
"""Calculate the brightness in %.
344+
345+
When brightness_mode is "lux" and lux_value is provided, uses lux-based
346+
brightness. Falls back to "default" sun-based calculation when lux_value
347+
is unavailable.
348+
"""
317349
if is_sleep:
318350
return self.sleep_brightness
319-
assert self.brightness_mode in ("default", "linear", "tanh")
320-
if self.brightness_mode == "default":
351+
assert self.brightness_mode in ("default", "linear", "tanh", "lux")
352+
if self.brightness_mode == "lux" and lux_value is not None:
353+
return self._brightness_pct_lux(lux_value)
354+
# Lux mode without value falls back to default
355+
if self.brightness_mode in ("default", "lux"):
321356
return self._brightness_pct_default(dt)
322357
if self.brightness_mode == "linear":
323358
return self._brightness_pct_linear(dt)
@@ -344,13 +379,14 @@ def brightness_and_color(
344379
self,
345380
dt: datetime.datetime,
346381
is_sleep: bool,
382+
lux_value: float | None = None,
347383
) -> dict[str, Any]:
348384
"""Calculate the brightness and color."""
349385
sun_position = self.sun.sun_position(dt)
350386
rgb_color: tuple[int, int, int]
351387
# Variable `force_rgb_color` is needed for RGB color after sunset (if enabled)
352388
force_rgb_color = False
353-
brightness_pct = self.brightness_pct(dt, is_sleep)
389+
brightness_pct = self.brightness_pct(dt, is_sleep, lux_value)
354390
if is_sleep:
355391
color_temp_kelvin = self.sleep_color_temp
356392
rgb_color = self.sleep_rgb_color
@@ -394,13 +430,14 @@ def get_settings(
394430
self,
395431
is_sleep: bool,
396432
transition: float | None,
433+
lux_value: float | None = None,
397434
) -> dict[str, float | int | tuple[float, float] | tuple[float, float, float]]:
398435
"""Get all light settings.
399436
400437
Calculating all values takes <0.5ms.
401438
"""
402439
dt = utcnow() + timedelta(seconds=transition or 0)
403-
return self.brightness_and_color(dt, is_sleep)
440+
return self.brightness_and_color(dt, is_sleep, lux_value)
404441

405442

406443
def find_a_b(x1: float, x2: float, y1: float, y2: float) -> tuple[float, float]:

custom_components/adaptive_lighting/config_flow.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,19 @@
77
from homeassistant import config_entries
88
from homeassistant.const import CONF_NAME
99
from homeassistant.core import callback
10-
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
10+
from homeassistant.helpers.selector import (
11+
EntitySelector,
12+
EntitySelectorConfig,
13+
NumberSelector,
14+
NumberSelectorConfig,
15+
NumberSelectorMode,
16+
)
1117

1218
from .const import ( # pylint: disable=unused-import
1319
CONF_LIGHTS,
20+
CONF_LUX_SENSOR,
21+
CONF_LUX_SMOOTHING_SAMPLES,
22+
CONF_LUX_SMOOTHING_WINDOW,
1423
DOMAIN,
1524
EXTRA_VALIDATION,
1625
NONE_STR,
@@ -152,6 +161,18 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None):
152161
multiple=True,
153162
),
154163
),
164+
CONF_LUX_SENSOR: EntitySelector(
165+
EntitySelectorConfig(
166+
domain="sensor",
167+
device_class="illuminance",
168+
),
169+
),
170+
CONF_LUX_SMOOTHING_SAMPLES: NumberSelector(
171+
NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX),
172+
),
173+
CONF_LUX_SMOOTHING_WINDOW: NumberSelector(
174+
NumberSelectorConfig(min=1, max=3600, mode=NumberSelectorMode.BOX),
175+
),
155176
}
156177

157178
options_schema = {}

custom_components/adaptive_lighting/const.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,35 @@ class TakeOverControlMode(Enum):
176176

177177
CONF_BRIGHTNESS_MODE, DEFAULT_BRIGHTNESS_MODE = "brightness_mode", "default"
178178
DOCS[CONF_BRIGHTNESS_MODE] = (
179-
"Brightness mode to use. Possible values are `default`, `linear`, and `tanh` "
180-
"(uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈"
179+
"Brightness mode to use. Possible values are `default`, `linear`, `tanh` "
180+
"(uses `brightness_mode_time_dark` and `brightness_mode_time_light`), and `lux` "
181+
"(uses an outdoor lux sensor for brightness control). 📈"
182+
)
183+
184+
CONF_LUX_SENSOR = "lux_sensor"
185+
DOCS[CONF_LUX_SENSOR] = (
186+
"Entity ID of an outdoor illuminance (lux) sensor to use for brightness control "
187+
"when `brightness_mode` is set to `lux`. ☀️"
188+
)
189+
190+
CONF_LUX_MIN, DEFAULT_LUX_MIN = "lux_min", 0
191+
DOCS[CONF_LUX_MIN] = (
192+
"Lux value below which brightness will be at minimum (dark = dim lights). ☀️"
193+
)
194+
195+
CONF_LUX_MAX, DEFAULT_LUX_MAX = "lux_max", 10000
196+
DOCS[CONF_LUX_MAX] = (
197+
"Lux value above which brightness will be at maximum (bright = bright lights). ☀️"
198+
)
199+
200+
CONF_LUX_SMOOTHING_SAMPLES, DEFAULT_LUX_SMOOTHING_SAMPLES = "lux_smoothing_samples", 5
201+
DOCS[CONF_LUX_SMOOTHING_SAMPLES] = (
202+
"Number of lux samples to average for smoothing rapid fluctuations. ☀️"
203+
)
204+
205+
CONF_LUX_SMOOTHING_WINDOW, DEFAULT_LUX_SMOOTHING_WINDOW = "lux_smoothing_window", 300
206+
DOCS[CONF_LUX_SMOOTHING_WINDOW] = (
207+
"Time window in seconds within which lux samples are considered for averaging. ☀️"
181208
)
182209
CONF_BRIGHTNESS_MODE_TIME_DARK, DEFAULT_BRIGHTNESS_MODE_TIME_DARK = (
183210
"brightness_mode_time_dark",
@@ -363,12 +390,17 @@ def int_between(min_int: int, max_int: int) -> vol.All:
363390
DEFAULT_BRIGHTNESS_MODE,
364391
selector.SelectSelector( # type: ignore[arg-type]
365392
selector.SelectSelectorConfig(
366-
options=["default", "linear", "tanh"],
393+
options=["default", "linear", "tanh", "lux"],
367394
multiple=False,
368395
mode=selector.SelectSelectorMode.DROPDOWN,
369396
),
370397
),
371398
),
399+
(CONF_LUX_SENSOR, NONE_STR, str),
400+
(CONF_LUX_MIN, DEFAULT_LUX_MIN, cv.positive_int),
401+
(CONF_LUX_MAX, DEFAULT_LUX_MAX, cv.positive_int),
402+
(CONF_LUX_SMOOTHING_SAMPLES, DEFAULT_LUX_SMOOTHING_SAMPLES, int_between(1, 100)),
403+
(CONF_LUX_SMOOTHING_WINDOW, DEFAULT_LUX_SMOOTHING_WINDOW, int_between(1, 3600)),
372404
(CONF_BRIGHTNESS_MODE_TIME_DARK, DEFAULT_BRIGHTNESS_MODE_TIME_DARK, int),
373405
(CONF_BRIGHTNESS_MODE_TIME_LIGHT, DEFAULT_BRIGHTNESS_MODE_TIME_LIGHT, int),
374406
(CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool),

custom_components/adaptive_lighting/strings.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@
5050
"max_sunset_time": "max_sunset_time",
5151
"sunset_offset": "sunset_offset",
5252
"brightness_mode": "brightness_mode",
53+
"lux_sensor": "lux_sensor",
54+
"lux_min": "lux_min",
55+
"lux_max": "lux_max",
56+
"lux_smoothing_samples": "lux_smoothing_samples",
57+
"lux_smoothing_window": "lux_smoothing_window",
5358
"brightness_mode_time_dark": "brightness_mode_time_dark",
5459
"brightness_mode_time_light": "brightness_mode_time_light",
5560
"take_over_control": "take_over_control: Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒",
@@ -83,7 +88,12 @@
8388
"min_sunset_time": "Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇",
8489
"max_sunset_time": "Set the latest virtual sunset time (HH:MM:SS), allowing for earlier sunsets. 🌇",
8590
"sunset_offset": "Adjust sunset time with a positive or negative offset in seconds. ⏰",
86-
"brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈",
91+
"brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`), and `lux` (uses an outdoor lux sensor). 📈",
92+
"lux_sensor": "Entity ID of an outdoor illuminance (lux) sensor to use for brightness control when `brightness_mode` is set to `lux`. ☀️",
93+
"lux_min": "Lux value below which brightness will be at minimum (dark = dim lights). ☀️",
94+
"lux_max": "Lux value above which brightness will be at maximum (bright = bright lights). ☀️",
95+
"lux_smoothing_samples": "Number of lux samples to average for smoothing rapid fluctuations. ☀️",
96+
"lux_smoothing_window": "Time window in seconds within which lux samples are considered for averaging. ☀️",
8797
"brightness_mode_time_dark": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉",
8898
"brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.",
8999
"take_over_control_mode": "The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed.",

0 commit comments

Comments
 (0)