Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 43 additions & 7 deletions custom_components/adaptive_lighting/color_and_brightness.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,12 @@ class SunLightSettings:
max_sunset_time: datetime.time | None
brightness_mode_time_dark: datetime.timedelta
brightness_mode_time_light: datetime.timedelta
brightness_mode: Literal["default", "linear", "tanh"] = "default"
brightness_mode: Literal["default", "linear", "tanh", "lux"] = "default"
sunrise_offset: datetime.timedelta = datetime.timedelta()
sunset_offset: datetime.timedelta = datetime.timedelta()
timezone: datetime.tzinfo = UTC
lux_min: int = 0
lux_max: int = 10000

@cached_property
def sun(self) -> SunEvents:
Expand Down Expand Up @@ -312,12 +314,44 @@ def _brightness_pct_linear(self, dt: datetime.datetime) -> float:
raise ValueError(msg)
return clamp(brightness, self.min_brightness, self.max_brightness)

def brightness_pct(self, dt: datetime.datetime, is_sleep: bool) -> float | None:
"""Calculate the brightness in %."""
def _brightness_pct_lux(self, lux_value: float) -> float:
"""Calculate brightness based on lux value.

Linear mapping matching circadian behavior: low lux = min brightness,
high lux = max brightness. This follows the same philosophy as sun-based
modes where darkness means dimmer lights.
"""
if lux_value <= self.lux_min:
return float(self.min_brightness)
if lux_value >= self.lux_max:
return float(self.max_brightness)
lux_range = self.lux_max - self.lux_min
if lux_range <= 0:
return float(self.min_brightness)
normalized = (lux_value - self.lux_min) / lux_range
return self.min_brightness + (
normalized * (self.max_brightness - self.min_brightness)
)

def brightness_pct(
self,
dt: datetime.datetime,
is_sleep: bool,
lux_value: float | None = None,
) -> float | None:
"""Calculate the brightness in %.

When brightness_mode is "lux" and lux_value is provided, uses lux-based
brightness. Falls back to "default" sun-based calculation when lux_value
is unavailable.
"""
if is_sleep:
return self.sleep_brightness
assert self.brightness_mode in ("default", "linear", "tanh")
if self.brightness_mode == "default":
assert self.brightness_mode in ("default", "linear", "tanh", "lux")
if self.brightness_mode == "lux" and lux_value is not None:
return self._brightness_pct_lux(lux_value)
# Lux mode without value falls back to default
if self.brightness_mode in ("default", "lux"):
return self._brightness_pct_default(dt)
if self.brightness_mode == "linear":
return self._brightness_pct_linear(dt)
Expand All @@ -344,13 +378,14 @@ def brightness_and_color(
self,
dt: datetime.datetime,
is_sleep: bool,
lux_value: float | None = None,
) -> dict[str, Any]:
"""Calculate the brightness and color."""
sun_position = self.sun.sun_position(dt)
rgb_color: tuple[int, int, int]
# Variable `force_rgb_color` is needed for RGB color after sunset (if enabled)
force_rgb_color = False
brightness_pct = self.brightness_pct(dt, is_sleep)
brightness_pct = self.brightness_pct(dt, is_sleep, lux_value)
if is_sleep:
color_temp_kelvin = self.sleep_color_temp
rgb_color = self.sleep_rgb_color
Expand Down Expand Up @@ -394,13 +429,14 @@ def get_settings(
self,
is_sleep: bool,
transition: float | None,
lux_value: float | None = None,
) -> dict[str, float | int | tuple[float, float] | tuple[float, float, float]]:
"""Get all light settings.

Calculating all values takes <0.5ms.
"""
dt = utcnow() + timedelta(seconds=transition or 0)
return self.brightness_and_color(dt, is_sleep)
return self.brightness_and_color(dt, is_sleep, lux_value)


def find_a_b(x1: float, x2: float, y1: float, y2: float) -> tuple[float, float]:
Expand Down
40 changes: 39 additions & 1 deletion custom_components/adaptive_lighting/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,23 @@
from homeassistant import config_entries
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)

from .const import ( # pylint: disable=unused-import
CONF_LIGHTS,
CONF_LUX_SENSOR,
CONF_LUX_SMOOTHING_SAMPLES,
CONF_LUX_SMOOTHING_WINDOW,
DOMAIN,
EXTRA_VALIDATION,
NONE_STR,
Expand Down Expand Up @@ -145,13 +158,38 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None):
configured_light,
)

# Build list of illuminance sensors for dropdown
lux_sensor_options: list[SelectOptionDict] = [
SelectOptionDict(value="", label="None (use sun position)"),
]
lux_sensor_options.extend(
SelectOptionDict(
value=state.entity_id,
label=f"{state.attributes.get('friendly_name', state.entity_id)} ({state.entity_id})",
)
for state in self.hass.states.async_all("sensor")
if state.attributes.get("device_class") == "illuminance"
)

to_replace: dict[str, Any] = {
CONF_LIGHTS: EntitySelector(
EntitySelectorConfig(
domain="light",
multiple=True,
),
),
CONF_LUX_SENSOR: SelectSelector(
SelectSelectorConfig(
options=lux_sensor_options,
mode=SelectSelectorMode.DROPDOWN,
),
),
CONF_LUX_SMOOTHING_SAMPLES: NumberSelector(
NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX),
),
CONF_LUX_SMOOTHING_WINDOW: NumberSelector(
NumberSelectorConfig(min=1, max=3600, mode=NumberSelectorMode.BOX),
),
}

options_schema = {}
Expand Down
38 changes: 35 additions & 3 deletions custom_components/adaptive_lighting/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,35 @@ class TakeOverControlMode(Enum):

CONF_BRIGHTNESS_MODE, DEFAULT_BRIGHTNESS_MODE = "brightness_mode", "default"
DOCS[CONF_BRIGHTNESS_MODE] = (
"Brightness mode to use. Possible values are `default`, `linear`, and `tanh` "
"(uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈"
"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 for brightness control). 📈"
)

CONF_LUX_SENSOR = "lux_sensor"
DOCS[CONF_LUX_SENSOR] = (
"Entity ID of an outdoor illuminance (lux) sensor to use for brightness control "
"when `brightness_mode` is set to `lux`. ☀️"
)

CONF_LUX_MIN, DEFAULT_LUX_MIN = "lux_min", 0
DOCS[CONF_LUX_MIN] = (
"Lux value below which brightness will be at minimum (dark = dim lights). ☀️"
)

CONF_LUX_MAX, DEFAULT_LUX_MAX = "lux_max", 10000
DOCS[CONF_LUX_MAX] = (
"Lux value above which brightness will be at maximum (bright = bright lights). ☀️"
)

CONF_LUX_SMOOTHING_SAMPLES, DEFAULT_LUX_SMOOTHING_SAMPLES = "lux_smoothing_samples", 5
DOCS[CONF_LUX_SMOOTHING_SAMPLES] = (
"Number of lux samples to average for smoothing rapid fluctuations. ☀️"
)

CONF_LUX_SMOOTHING_WINDOW, DEFAULT_LUX_SMOOTHING_WINDOW = "lux_smoothing_window", 300
DOCS[CONF_LUX_SMOOTHING_WINDOW] = (
"Time window in seconds within which lux samples are considered for averaging. ☀️"
)
CONF_BRIGHTNESS_MODE_TIME_DARK, DEFAULT_BRIGHTNESS_MODE_TIME_DARK = (
"brightness_mode_time_dark",
Expand Down Expand Up @@ -363,12 +390,17 @@ def int_between(min_int: int, max_int: int) -> vol.All:
DEFAULT_BRIGHTNESS_MODE,
selector.SelectSelector( # type: ignore[arg-type]
selector.SelectSelectorConfig(
options=["default", "linear", "tanh"],
options=["default", "linear", "tanh", "lux"],
multiple=False,
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
),
(CONF_LUX_SENSOR, "", str),
(CONF_LUX_MIN, DEFAULT_LUX_MIN, cv.positive_int),
(CONF_LUX_MAX, DEFAULT_LUX_MAX, cv.positive_int),
(CONF_LUX_SMOOTHING_SAMPLES, DEFAULT_LUX_SMOOTHING_SAMPLES, int_between(1, 100)),
(CONF_LUX_SMOOTHING_WINDOW, DEFAULT_LUX_SMOOTHING_WINDOW, int_between(1, 3600)),
(CONF_BRIGHTNESS_MODE_TIME_DARK, DEFAULT_BRIGHTNESS_MODE_TIME_DARK, int),
(CONF_BRIGHTNESS_MODE_TIME_LIGHT, DEFAULT_BRIGHTNESS_MODE_TIME_LIGHT, int),
(CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool),
Expand Down
12 changes: 11 additions & 1 deletion custom_components/adaptive_lighting/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@
"max_sunset_time": "max_sunset_time",
"sunset_offset": "sunset_offset",
"brightness_mode": "brightness_mode",
"lux_sensor": "lux_sensor",
"lux_min": "lux_min",
"lux_max": "lux_max",
"lux_smoothing_samples": "lux_smoothing_samples",
"lux_smoothing_window": "lux_smoothing_window",
"brightness_mode_time_dark": "brightness_mode_time_dark",
"brightness_mode_time_light": "brightness_mode_time_light",
"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. 🔒",
Expand Down Expand Up @@ -83,7 +88,12 @@
"min_sunset_time": "Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇",
"max_sunset_time": "Set the latest virtual sunset time (HH:MM:SS), allowing for earlier sunsets. 🌇",
"sunset_offset": "Adjust sunset time with a positive or negative offset in seconds. ⏰",
"brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈",
"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). 📈",
"lux_sensor": "Entity ID of an outdoor illuminance (lux) sensor to use for brightness control when `brightness_mode` is set to `lux`. ☀️",
"lux_min": "Lux value below which brightness will be at minimum (dark = dim lights). ☀️",
"lux_max": "Lux value above which brightness will be at maximum (bright = bright lights). ☀️",
"lux_smoothing_samples": "Number of lux samples to average for smoothing rapid fluctuations. ☀️",
"lux_smoothing_window": "Time window in seconds within which lux samples are considered for averaging. ☀️",
"brightness_mode_time_dark": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉",
"brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.",
"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.",
Expand Down
Loading