diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index f3ae3efed..ce6454130 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -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: @@ -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) @@ -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 @@ -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]: diff --git a/custom_components/adaptive_lighting/config_flow.py b/custom_components/adaptive_lighting/config_flow.py index eb7b4a0a7..86e5f3176 100644 --- a/custom_components/adaptive_lighting/config_flow.py +++ b/custom_components/adaptive_lighting/config_flow.py @@ -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, @@ -154,6 +167,19 @@ 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( @@ -161,6 +187,18 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None): 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 = {} diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 502318f01..7773afc15 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -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", @@ -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), diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index cf816d4ff..827fc41d3 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -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. 🔒", @@ -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.", diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 91049970e..09e903367 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -5,7 +5,9 @@ import asyncio import datetime import logging +import time import zoneinfo +from collections import deque from copy import deepcopy from datetime import timedelta from typing import TYPE_CHECKING, Any @@ -102,6 +104,11 @@ CONF_INTERCEPT, CONF_INTERVAL, CONF_LIGHTS, + CONF_LUX_MAX, + CONF_LUX_MIN, + CONF_LUX_SENSOR, + CONF_LUX_SMOOTHING_SAMPLES, + CONF_LUX_SMOOTHING_WINDOW, CONF_MANUAL_CONTROL, CONF_MAX_BRIGHTNESS, CONF_MAX_COLOR_TEMP, @@ -869,6 +876,11 @@ def __init__( # Set and unset tracker in async_turn_on and async_turn_off self.remove_listeners: list[CALLBACK_TYPE] = [] self.remove_interval: CALLBACK_TYPE = lambda: None + + # Lux sensor smoothing buffer: stores (timestamp, value) tuples + self._lux_samples: deque[tuple[float, float]] = deque() + self._last_lux_brightness: float | None = None + _LOGGER.debug( "%s: Setting up with '%s'," " config_entry.data: '%s'," @@ -944,6 +956,16 @@ def _set_changeable_settings( ) self._multi_light_intercept = False self._expand_light_groups() # updates manual control timers + + # Lux sensor configuration + lux_sensor = data.get(CONF_LUX_SENSOR) + self._lux_sensor: str | None = ( + lux_sensor if lux_sensor and lux_sensor != "None" else None + ) + self._lux_smoothing_samples: int = data[CONF_LUX_SMOOTHING_SAMPLES] + self._lux_smoothing_window: int = data[CONF_LUX_SMOOTHING_WINDOW] + self._last_lux_brightness = None + location, _ = get_astral_location(self.hass) self._sun_light_settings = SunLightSettings( @@ -970,6 +992,8 @@ def _set_changeable_settings( brightness_mode_time_dark=data[CONF_BRIGHTNESS_MODE_TIME_DARK], brightness_mode_time_light=data[CONF_BRIGHTNESS_MODE_TIME_LIGHT], timezone=zoneinfo.ZoneInfo(self.hass.config.time_zone), + lux_min=data[CONF_LUX_MIN], + lux_max=data[CONF_LUX_MAX], ) _LOGGER.debug( "%s: Set switch settings for lights '%s'. now using data: '%s'", @@ -1035,6 +1059,111 @@ def _expand_light_groups(self, hass: HomeAssistant | None = None) -> None: ) self.lights = list(all_lights) + def _add_lux_sample(self, lux_value: float) -> None: + """Add a lux sample to the smoothing buffer.""" + now = time.time() + self._lux_samples.append((now, lux_value)) + while len(self._lux_samples) > self._lux_smoothing_samples: + self._lux_samples.popleft() + + def _read_initial_lux_value(self) -> None: + """Read the current lux sensor state to initialize the sample buffer.""" + state = self.hass.states.get(self._lux_sensor) + if state is None or state.state in ("unavailable", "unknown"): + return + try: + lux_value = float(state.state) + if lux_value >= 0: + self._add_lux_sample(lux_value) + _LOGGER.debug( + "%s: Initialized lux from current sensor state: %s", + self._name, + lux_value, + ) + except (ValueError, TypeError): + pass + + def _get_smoothed_lux(self) -> float | None: + """Get the smoothed lux value from recent samples within the time window. + + Returns None if no samples exist. Falls back to most recent sample + if all samples are outside the time window. + """ + if not self._lux_samples: + return None + + now = time.time() + cutoff = now - self._lux_smoothing_window + + valid_samples = [ + value for timestamp, value in self._lux_samples if timestamp >= cutoff + ] + + if not valid_samples: + _, most_recent_value = self._lux_samples[-1] + return most_recent_value + + return sum(valid_samples) / len(valid_samples) + + async def _lux_sensor_state_event_action( + self, + event: Event[EventStateChangedData], + ) -> None: + """Handle lux sensor state change events.""" + new_state = event.data.get("new_state") + if new_state is None: + return + + if new_state.state in ("unavailable", "unknown"): + _LOGGER.debug( + "%s: Lux sensor is %s, skipping", + self._name, + new_state.state, + ) + return + + try: + lux_value = float(new_state.state) + except (ValueError, TypeError): + _LOGGER.warning( + "%s: Could not parse lux sensor value '%s'", + self._name, + new_state.state, + ) + return + + # Negative lux is physically impossible + if lux_value < 0: + _LOGGER.warning( + "%s: Ignoring invalid negative lux value: %s", + self._name, + lux_value, + ) + return + + self._add_lux_sample(lux_value) + + if self._sun_light_settings.brightness_mode != "lux" or not self.is_on: + return + + smoothed_lux = self._get_smoothed_lux() + if smoothed_lux is not None: + new_brightness = self._sun_light_settings._brightness_pct_lux( + smoothed_lux, + ) + if ( + self._last_lux_brightness is not None + and abs(new_brightness - self._last_lux_brightness) < 1.0 + ): + return + self._last_lux_brightness = new_brightness + + await self._update_attrs_and_maybe_adapt_lights( + context=self.create_context("lux_change"), + transition=self._transition, + force=False, + ) + async def _setup_listeners(self, _: Event[NoEventData] | None = None) -> None: _LOGGER.debug("%s: Called '_setup_listeners'", self._name) if not self.is_on or not self.hass.is_running: @@ -1052,6 +1181,27 @@ async def _setup_listeners(self, _: Event[NoEventData] | None = None) -> None: ) self.remove_listeners.append(remove_sleep) + + # Set up lux sensor listener if configured + if self._lux_sensor: + lux_state = self.hass.states.get(self._lux_sensor) + if lux_state is None: + _LOGGER.warning( + "%s: Configured lux sensor '%s' not found, " + "falling back to sun-based brightness", + self._name, + self._lux_sensor, + ) + else: + remove_lux = async_track_state_change_event( + self.hass, + entity_ids=self._lux_sensor, + action=self._lux_sensor_state_event_action, + ) + self.remove_listeners.append(remove_lux) + # Read initial lux value since sensor only reports on change + self._read_initial_lux_value() + self._expand_light_groups() def _update_time_interval_listener(self) -> None: @@ -1103,6 +1253,7 @@ def _remove_interval_listener(self) -> None: def _remove_listeners(self) -> None: self._remove_interval_listener() + self._last_lux_brightness = None while self.remove_listeners: remove_listener = self.remove_listeners.pop() @@ -1131,6 +1282,11 @@ def extra_state_attributes(self) -> dict[str, Any]: for light in self.lights if (timer := timers.get(light)) and (time := timer.remaining_time()) > 0 } + # Lux sensor attributes + if self._lux_sensor: + extra_state_attributes["lux_sensor"] = self._lux_sensor + extra_state_attributes["current_lux"] = self._get_smoothed_lux() + extra_state_attributes["lux_samples_count"] = len(self._lux_samples) return extra_state_attributes def create_context( @@ -1224,6 +1380,7 @@ async def prepare_adaptation_data( self._settings = self._sun_light_settings.get_settings( self.sleep_mode_switch.is_on, transition, + self._get_smoothed_lux(), ) # Build service data. @@ -1422,6 +1579,7 @@ async def _update_attrs_and_maybe_adapt_lights( self._sun_light_settings.get_settings( self.sleep_mode_switch.is_on, transition, + self._get_smoothed_lux(), ), ) self.async_write_ha_state() diff --git a/tests/test_lux_brightness.py b/tests/test_lux_brightness.py new file mode 100644 index 000000000..ff1448254 --- /dev/null +++ b/tests/test_lux_brightness.py @@ -0,0 +1,250 @@ +"""Tests for lux sensor brightness control.""" + +import datetime as dt +import time +import zoneinfo +from collections import deque + +from astral import LocationInfo +from astral.location import Location +from homeassistant.components.adaptive_lighting.color_and_brightness import ( + SunLightSettings, +) +from homeassistant.components.adaptive_lighting.switch import AdaptiveSwitch + +# Create a mock astral_location object +location = Location(LocationInfo()) +tzinfo = zoneinfo.ZoneInfo("UTC") + + +def create_sun_light_settings( + brightness_mode: str = "lux", + min_brightness: int = 1, + max_brightness: int = 100, + lux_min: int = 0, + lux_max: int = 10000, +) -> SunLightSettings: + """Create a SunLightSettings instance for testing.""" + return SunLightSettings( + name="test", + astral_location=location, + adapt_until_sleep=False, + max_brightness=max_brightness, + max_color_temp=5500, + min_brightness=min_brightness, + min_color_temp=2000, + sleep_brightness=1, + sleep_rgb_or_color_temp="color_temp", + sleep_color_temp=1000, + sleep_rgb_color=(255, 56, 0), + sunrise_time=dt.time(6, 0), + min_sunrise_time=None, + max_sunrise_time=None, + sunset_time=dt.time(18, 0), + min_sunset_time=None, + max_sunset_time=None, + brightness_mode_time_dark=dt.timedelta(seconds=900), + brightness_mode_time_light=dt.timedelta(seconds=3600), + brightness_mode=brightness_mode, + timezone=tzinfo, + lux_min=lux_min, + lux_max=lux_max, + ) + + +class TestLuxBrightnessPct: + """Test the _brightness_pct_lux method. + + Follows circadian behavior: low lux (dark) = dim lights, high lux (bright) = bright lights. + """ + + def test_lux_at_minimum_returns_min_brightness(self): + """When lux is at or below lux_min (dark), brightness should be at minimum.""" + settings = create_sun_light_settings(min_brightness=10, max_brightness=100) + assert settings._brightness_pct_lux(0) == 10 + assert settings._brightness_pct_lux(-10) == 10 # below lux_min + + def test_lux_at_maximum_returns_max_brightness(self): + """When lux is at or above lux_max (bright), brightness should be at maximum.""" + settings = create_sun_light_settings( + min_brightness=10, + max_brightness=100, + lux_max=10000, + ) + assert settings._brightness_pct_lux(10000) == 100 + assert settings._brightness_pct_lux(15000) == 100 # above lux_max + + def test_lux_midpoint_returns_midpoint_brightness(self): + """When lux is at midpoint, brightness should be at midpoint.""" + settings = create_sun_light_settings( + min_brightness=0, + max_brightness=100, + lux_min=0, + lux_max=10000, + ) + # At lux 5000, brightness should be 50% + assert settings._brightness_pct_lux(5000) == 50.0 + + def test_lux_linear_interpolation(self): + """Test linear interpolation at various lux levels.""" + settings = create_sun_light_settings( + min_brightness=20, + max_brightness=80, + lux_min=0, + lux_max=1000, + ) + # brightness range is 80 - 20 = 60 + # at lux 0: 20 (min, dark) + # at lux 250: 20 + (0.25 * 60) = 35 + # at lux 500: 20 + (0.5 * 60) = 50 + # at lux 750: 20 + (0.75 * 60) = 65 + # at lux 1000: 80 (max, bright) + assert settings._brightness_pct_lux(0) == 20.0 + assert settings._brightness_pct_lux(250) == 35.0 + assert settings._brightness_pct_lux(500) == 50.0 + assert settings._brightness_pct_lux(750) == 65.0 + assert settings._brightness_pct_lux(1000) == 80.0 + + def test_custom_lux_range(self): + """Test with custom lux_min and lux_max values.""" + settings = create_sun_light_settings( + min_brightness=10, + max_brightness=90, + lux_min=100, + lux_max=1100, + ) + # Range is 1000 lux, brightness range is 80 + # at lux 100: 10 (min, dark) + # at lux 600 (midpoint): 10 + (0.5 * 80) = 50 + # at lux 1100: 90 (max, bright) + assert settings._brightness_pct_lux(100) == 10.0 + assert settings._brightness_pct_lux(600) == 50.0 + assert settings._brightness_pct_lux(1100) == 90.0 + + +class TestBrightnessPctWithLux: + """Test the brightness_pct method with lux mode.""" + + def test_lux_mode_with_lux_value(self): + """When brightness_mode is 'lux' and lux_value is provided, use lux calculation.""" + settings = create_sun_light_settings( + brightness_mode="lux", + min_brightness=10, + max_brightness=100, + lux_max=10000, + ) + dt_now = dt.datetime(2022, 1, 1, 12, 0, tzinfo=dt.timezone.utc) + result = settings.brightness_pct(dt_now, is_sleep=False, lux_value=5000) + # At lux 5000, should be midpoint + assert result == 55.0 # 10 + (0.5 * (100 - 10)) = 55 + + def test_lux_mode_without_lux_value_falls_back(self): + """When brightness_mode is 'lux' but no lux_value, fall back to sun-based.""" + settings = create_sun_light_settings( + brightness_mode="lux", + min_brightness=10, + max_brightness=100, + ) + dt_noon = dt.datetime(2022, 1, 1, 12, 0, tzinfo=dt.timezone.utc) + result = settings.brightness_pct(dt_noon, is_sleep=False, lux_value=None) + # Should fall back to default sun-based calculation (sun position > 0 at noon) + assert result == 100 # max_brightness when sun is up + + def test_non_lux_mode_ignores_lux_value(self): + """When brightness_mode is not 'lux', lux_value is ignored.""" + settings = create_sun_light_settings( + brightness_mode="default", + min_brightness=10, + max_brightness=100, + ) + dt_noon = dt.datetime(2022, 1, 1, 12, 0, tzinfo=dt.timezone.utc) + result = settings.brightness_pct(dt_noon, is_sleep=False, lux_value=10000) + # Should use sun-based calculation, not lux + assert result == 100 # max_brightness when sun is up (not based on lux) + + def test_sleep_mode_overrides_lux(self): + """When is_sleep is True, sleep_brightness is returned regardless of lux.""" + settings = create_sun_light_settings(brightness_mode="lux") + dt_now = dt.datetime(2022, 1, 1, 12, 0, tzinfo=dt.timezone.utc) + result = settings.brightness_pct(dt_now, is_sleep=True, lux_value=5000) + assert result == 1 # sleep_brightness + + +class TestGetSettingsWithLux: + """Test the get_settings method with lux value.""" + + def test_get_settings_passes_lux_value(self): + """Verify get_settings passes lux_value through to brightness calculation.""" + settings = create_sun_light_settings( + brightness_mode="lux", + min_brightness=10, + max_brightness=100, + lux_max=10000, + ) + result = settings.get_settings(is_sleep=False, transition=0, lux_value=5000) + assert "brightness_pct" in result + assert result["brightness_pct"] == 55.0 + + +class _LuxBufferFixture: + """Minimal stand-in that binds the real AdaptiveSwitch smoothing methods. + + `_add_lux_sample` / `_get_smoothed_lux` only touch `_lux_samples`, + `_lux_smoothing_samples`, and `_lux_smoothing_window` — no hass, no + config entry — so we can exercise the production code paths directly. + """ + + _add_lux_sample = AdaptiveSwitch._add_lux_sample + _get_smoothed_lux = AdaptiveSwitch._get_smoothed_lux + + def __init__(self, smoothing_samples: int = 5, smoothing_window: int = 300): + self._lux_samples: deque[tuple[float, float]] = deque() + self._lux_smoothing_samples = smoothing_samples + self._lux_smoothing_window = smoothing_window + + +class TestLuxSamplesBuffer: + """Test the AdaptiveSwitch lux smoothing buffer.""" + + def test_smoothing_average(self): + """Smoothed value is the mean of samples inside the window.""" + sw = _LuxBufferFixture() + sw._add_lux_sample(100.0) + sw._add_lux_sample(200.0) + sw._add_lux_sample(300.0) + assert sw._get_smoothed_lux() == 200.0 + + def test_smoothing_filters_old_samples(self): + """Samples older than the window are excluded from the average.""" + sw = _LuxBufferFixture(smoothing_window=300) + now = time.time() + sw._lux_samples.append((now - 400, 100.0)) # outside 300s window + sw._lux_samples.append((now - 200, 200.0)) # inside + sw._lux_samples.append((now - 100, 300.0)) # inside + assert sw._get_smoothed_lux() == 250.0 + + def test_empty_buffer_returns_none(self): + """An empty buffer yields None.""" + sw = _LuxBufferFixture() + assert sw._get_smoothed_lux() is None + + def test_all_samples_expired_falls_back_to_most_recent(self): + """When every sample is outside the window, return the most recent value. + + Intentional per _get_smoothed_lux docstring — a stale value is a more + useful signal mid-adaptation than None (which would force a silent + revert to the sun-based fallback). + """ + sw = _LuxBufferFixture(smoothing_window=300) + now = time.time() + sw._lux_samples.append((now - 400, 100.0)) + sw._lux_samples.append((now - 350, 200.0)) + assert sw._get_smoothed_lux() == 200.0 # most recent fallback + + def test_buffer_caps_at_smoothing_samples(self): + """The deque never exceeds _lux_smoothing_samples in length.""" + sw = _LuxBufferFixture(smoothing_samples=3) + for value in (10.0, 20.0, 30.0, 40.0, 50.0): + sw._add_lux_sample(value) + assert len(sw._lux_samples) == 3 + assert sw._get_smoothed_lux() == 40.0 # mean of 30, 40, 50