diff --git a/README.md b/README.md index 89685620..68f5ad52 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,13 @@ https://github.com/basnijholt/adaptive-lighting/assets/6897215/68908f7d-fbf1-499 When initially turning on a light that is controlled by Adaptive Lighting, the `light.turn_on` service call is intercepted, and the light's brightness and color are automatically adjusted based on the sun's position. After that, the light's brightness and color are automatically adjusted at a regular interval. -Adaptive Lighting provides four switches (using "living_room" as an example component name): +Adaptive Lighting provides four switches and a number entity (using "living_room" as an example component name): - `switch.adaptive_lighting_living_room`: Turn Adaptive Lighting on or off and view current light settings through its attributes. - `switch.adaptive_lighting_sleep_mode_living_room`: Activate "sleep mode" 😴 and set custom sleep_brightness and sleep_color_temp. - `switch.adaptive_lighting_adapt_brightness_living_room`: Enable or disable brightness adaptation 🔆 for supported lights. - `switch.adaptive_lighting_adapt_color_living_room`: Enable or disable color adaptation 🌈 for supported lights. +- `number.adaptive_lighting_living_room_intensity`: Scale 🎚️ how far the adaptive settings travel from their floor, from 100% (unchanged) down to 0%. @@ -69,6 +70,35 @@ The attributes are absent when the Adaptive Lighting switch is off. Use a fallba > ⚠️ **_Caution: Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable `detect_non_ha_changes` if you encounter such issues._** + +### :level_slider: Intensity + +Every configuration provides a number entity, `number.adaptive_lighting_living_room_intensity`, that scales how far the adaptive settings travel from their floor 🎚️: + +``` +output = floor_value + (adaptive_value - floor_value) × intensity / 100 +``` + +At 100%, the default, the lights receive the adaptive values unchanged and interpolation is skipped. +At 0% they receive the floor. +In between they remain adaptive: the sun keeps moving them through a smaller range. At 0%, the target stays at the configured endpoint. + +The floor is set per configuration with `intensity_floor`: + +- `sleep` (the default) interpolates towards `sleep_brightness` and the configured sleep color, so 0% matches what sleep mode would do. Color-capable lights use `sleep_rgb_color` when configured; CT-only lights use `sleep_color_temp`. +- `minimum` interpolates towards `min_brightness` and `min_color_temp`. This gives a shallower dial that never takes a light below what the adaptive curve reaches on its own, at the cost of doing progressively less as the evening goes on — and of leaving color alone after sunset, where the adaptive color temperature already *is* `min_color_temp`. + +Enabling `transition_until_sleep` forces the `sleep` floor whatever `intensity_floor` says. +With warmer sleep settings, the adaptive color after sunset goes below `min_color_temp`; using the minimum endpoint could then make dial-down cool the light. +The switch's `intensity_floor` attribute reports the floor actually in use. + +Sleep mode ignores the dial entirely — its output already *is* the sleep value. + +The sleep endpoint can go below `min_brightness`. Lowering intensity dims and warms only when the endpoint is dimmer and warmer than the current adaptive target. Intensity 0 means the endpoint, not off. + +Changes affect eligible, already-on lights and preserve manual control. Restarts restore intensity before adaptation and respect `only_once`; moving the dial explicitly adapts immediately. Runtime settings resets preserve intensity. See [Intensity](https://basnijholt.github.io/adaptive-lighting/advanced/intensity/) for interactions and the existing helper-automation alternative. + + ## :books: Table of Contents @@ -145,6 +175,7 @@ The YAML and frontend configuration methods support all of the options listed be | `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color | | `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 | | `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙 | `False` | `bool` | +| `intensity_floor` | What 0% on the intensity dial means. `sleep` blends towards `sleep_brightness` and the configured sleep color; `minimum` towards `min_brightness`/`min_color_temp`. `transition_until_sleep` forces the sleep endpoint. Lower intensity dims only when the endpoint is below the current adaptive value. 🎚️ | `sleep` | one of `['sleep', 'minimum']` | | `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` | | `min_sunrise_time` | Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅 | `None` | `str` | | `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅 | `None` | `str` | diff --git a/custom_components/adaptive_lighting/__init__.py b/custom_components/adaptive_lighting/__init__.py index 0ac9ef71..e0bc8bbf 100644 --- a/custom_components/adaptive_lighting/__init__.py +++ b/custom_components/adaptive_lighting/__init__.py @@ -32,7 +32,7 @@ from .switch import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["switch"] +PLATFORMS = ["number", "switch"] def _all_unique_names(value: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -110,7 +110,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b undo_listener = config_entry.add_update_listener(async_update_options) data[config_entry.entry_id] = {UNDO_UPDATE_LISTENER: undo_listener} - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + # Restore the number before the switch can send its first adaptation. + for platform in PLATFORMS: + await hass.config_entries.async_forward_entry_setups(config_entry, [platform]) return True @@ -122,9 +124,11 @@ async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) - async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_forward_entry_unload( + # Unload every platform: leaving the number entity loaded here would + # strand it when the entry is removed or reloaded. + unload_ok = await hass.config_entries.async_unload_platforms( config_entry, - "switch", + PLATFORMS, ) data = hass.data[DOMAIN] data[config_entry.entry_id][UNDO_UPDATE_LISTENER]() diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index e62beb0c..f27bdfab 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -298,6 +298,10 @@ class SunLightSettings: sunrise_offset: datetime.timedelta = datetime.timedelta() sunset_offset: datetime.timedelta = datetime.timedelta() timezone: datetime.tzinfo = UTC + # 0-100. 100 is the normal adaptive behaviour; 0 is the intensity floor. + intensity: float = 100.0 + # What the dial's 0% end is. See `intensity_floor_is_sleep`. + intensity_floor: Literal["sleep", "minimum"] = "sleep" @cached_property def sun(self) -> SunEvents: @@ -408,6 +412,78 @@ class SunLightSettings: msg = "Should not happen" raise ValueError(msg) + @property + def intensity_floor_is_sleep(self) -> bool: + """Whether the dial's 0% end is the sleep settings. + + `adapt_until_sleep` forces it, whatever `intensity_floor` says. When + sleep is warmer than the minimum, the adaptive colour after sunset + goes below `min_color_temp`. Using the minimum endpoint could then + make dial-down cool the light during that period. + """ + return self.intensity_floor == "sleep" or self.adapt_until_sleep + + def _apply_intensity( + self, + brightness_pct: float | None, + color_temp_kelvin: int, + rgb_color: tuple[int, int, int], + *, + is_sleep: bool, + ) -> tuple[float | None, int, tuple[int, int, int]]: + """Scale the adaptive result towards this switch's floor settings. + + ``intensity`` is an interpolation factor, not a multiplier: + + out = floor_value + (adaptive_value - floor_value) * intensity / 100 + + so 100 returns the adaptive value untouched and 0 returns the floor value. + Unlike multiplication towards zero, this retains the configured endpoint. + + The floor is the sleep settings by default. ``intensity_floor: + minimum`` anchors it to ``min_brightness``/``min_color_temp`` instead -- + a shallower dial that never goes below what the adaptive curve itself + reaches after dark, at the cost of doing nothing at those hours. + + Skipped while sleep mode is on -- the value already IS the sleep value + there, so this would be a no-op, and short-circuiting keeps sleep mode + unchanged for anyone not using the dial. + """ + if is_sleep or self.intensity >= 100 or brightness_pct is None: + return brightness_pct, color_temp_kelvin, rgb_color + + factor = clamp(self.intensity, 0.0, 100.0) / 100.0 + + if self.intensity_floor_is_sleep: + floor_brightness = self.sleep_brightness + floor_color_temp = self.sleep_color_temp + else: + floor_brightness = self.min_brightness + floor_color_temp = self.min_color_temp + + brightness_pct = floor_brightness + (brightness_pct - floor_brightness) * factor + + color_temp_kelvin = round( + floor_color_temp + (color_temp_kelvin - floor_color_temp) * factor, + ) + color_temp_kelvin = 5 * round(color_temp_kelvin / 5) # round to nearest 5 + + if ( + self.intensity_floor_is_sleep + and self.sleep_rgb_or_color_temp == "rgb_color" + ): + # This switch expresses its sleep colour as RGB, so walk the RGB value + # towards `sleep_rgb_color` rather than re-deriving it from the + # interpolated colour temperature. Deriving it would land 0% on + # `color_temperature_to_rgb(sleep_color_temp)`, which is not the + # colour sleep mode actually uses. + rgb_color = lerp_color_hsv(self.sleep_rgb_color, rgb_color, factor) + else: + r, g, b = color_temperature_to_rgb(color_temp_kelvin) + rgb_color = (round(r), round(g), round(b)) + + return brightness_pct, color_temp_kelvin, rgb_color + def brightness_and_color( self, dt: datetime.datetime, @@ -443,6 +519,21 @@ class SunLightSettings: color_temp_kelvin = self.color_temp_kelvin(sun_position) r, g, b = color_temperature_to_rgb(color_temp_kelvin) rgb_color = (round(r), round(g), round(b)) + brightness_pct, color_temp_kelvin, rgb_color = self._apply_intensity( + brightness_pct, + color_temp_kelvin, + rgb_color, + is_sleep=is_sleep, + ) + if ( + not is_sleep + and self.intensity < 100 + and self.intensity_floor_is_sleep + and self.sleep_rgb_or_color_temp == "rgb_color" + ): + # Select the blended RGB target even on lights that also support CT. + force_rgb_color = True + # backwards compatibility for versions < 1.3.1 - see #403 color_temp_mired: float = math.floor(1000000 / color_temp_kelvin) xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index afead25c..847574b9 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -14,6 +14,7 @@ ICON_MAIN = "mdi:theme-light-dark" ICON_BRIGHTNESS = "mdi:brightness-4" ICON_COLOR_TEMP = "mdi:sun-thermometer" ICON_SLEEP = "mdi:sleep" +ICON_INTENSITY = "mdi:brightness-percent" DOMAIN = "adaptive_lighting" @@ -235,6 +236,15 @@ DOCS[CONF_ADAPT_UNTIL_SLEEP] = ( "transitioning to these values after sunset. 🌙" ) +CONF_INTENSITY_FLOOR, DEFAULT_INTENSITY_FLOOR = "intensity_floor", "sleep" +DOCS[CONF_INTENSITY_FLOOR] = ( + "What 0% on the intensity dial means. `sleep` blends towards " + "`sleep_brightness` and the configured sleep color; `minimum` towards " + "`min_brightness`/`min_color_temp`. `transition_until_sleep` forces the " + "sleep endpoint. Lower intensity dims only when the endpoint is below " + "the current adaptive value. 🎚️" +) + CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0 DOCS[CONF_ADAPT_DELAY] = ( "Wait time (seconds) between light turn on and Adaptive Lighting applying " @@ -302,6 +312,9 @@ DOCS[CONF_EXPAND_LIGHT_GROUPS] = ( SLEEP_MODE_SWITCH = "sleep_mode_switch" ADAPT_COLOR_SWITCH = "adapt_color_switch" ADAPT_BRIGHTNESS_SWITCH = "adapt_brightness_switch" +INTENSITY_NUMBER = "intensity_number" +PENDING_INTENSITY = "pending_intensity" +DEFAULT_INTENSITY = 100.0 ATTR_ADAPTIVE_LIGHTING_MANAGER = "manager" UNDO_UPDATE_LISTENER = "undo_update_listener" NONE_STR = "None" @@ -390,6 +403,17 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [ ), (CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION, VALID_TRANSITION), (CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP, bool), + ( + CONF_INTENSITY_FLOOR, + DEFAULT_INTENSITY_FLOOR, + selector.SelectSelector( # type: ignore[arg-type] + selector.SelectSelectorConfig( + options=["sleep", "minimum"], + multiple=False, + mode=selector.SelectSelectorMode.DROPDOWN, + ), + ), + ), (CONF_SUNRISE_TIME, NONE_STR, str), (CONF_MIN_SUNRISE_TIME, NONE_STR, str), (CONF_MAX_SUNRISE_TIME, NONE_STR, str), diff --git a/custom_components/adaptive_lighting/number.py b/custom_components/adaptive_lighting/number.py new file mode 100644 index 00000000..c79f325e --- /dev/null +++ b/custom_components/adaptive_lighting/number.py @@ -0,0 +1,143 @@ +"""A per-switch intensity dial for Adaptive Lighting. + +Adds one number entity per configuration, sitting alongside the Sleep Mode / +Adapt Brightness / Adapt Color switches on the same device: + + number.adaptive_lighting_intensity_ + +100% is the normal adaptive behaviour. 0% is the switch's floor, set by the +``intensity_floor`` option: its sleep settings (``sleep_brightness`` and +``sleep_color_temp``) by default, or its ``min_brightness``/``min_color_temp``. +Anything in between blends the two, recomputed continuously so the sun still +moves the light through a smaller range. At 0%, the target stays at the endpoint. + +The value survives restarts via RestoreEntity, and is re-applied to the switch +whenever it changes so the lights follow immediately instead of waiting for the +next adaptation interval. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import PERCENTAGE +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import slugify + +from .const import ( + CONF_NAME, + DEFAULT_INTENSITY, + DOMAIN, + ICON_INTENSITY, + INTENSITY_NUMBER, + PENDING_INTENSITY, +) +from .switch import validate + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + from homeassistant.helpers.entity_platform import AddEntitiesCallback + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the intensity number for one Adaptive Lighting configuration.""" + data = hass.data[DOMAIN] + number = AdaptiveIntensityNumber(hass, config_entry) + data[config_entry.entry_id][INTENSITY_NUMBER] = number + async_add_entities([number], update_before_add=True) + + +class AdaptiveIntensityNumber(NumberEntity, RestoreEntity): + """A 0-100% dial between the adaptive settings and the switch's floor.""" + + _attr_native_min_value = 0.0 + _attr_native_max_value = 100.0 + _attr_native_step = 1.0 + _attr_native_unit_of_measurement = PERCENTAGE + _attr_mode = NumberMode.SLIDER + _attr_should_poll = False + # Matches the sibling switches: the device supplies "Adaptive Lighting: + # " and this entity contributes only "Intensity". + _attr_has_entity_name = True + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize the intensity number.""" + self.hass = hass + self._config_entry = config_entry + config = validate(config_entry) + self._config_name = config[CONF_NAME] + self._which = "Intensity" + self._attr_unique_id = f"{self._config_name}_{slugify(self._which)}" + self._attr_name = self._which + self._attr_icon = ICON_INTENSITY + self._value: float = DEFAULT_INTENSITY + + @property + def native_value(self) -> float: + """Return the current intensity.""" + return self._value + + @property + def device_info(self) -> DeviceInfo: + """Group with the other entities for this configuration.""" + return DeviceInfo( + identifiers={(DOMAIN, self._config_name)}, + name=f"Adaptive Lighting: {self._config_name}", + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def _switch(self) -> Any | None: + """The AdaptiveSwitch this dial belongs to, if it is set up yet.""" + entry = self.hass.data.get(DOMAIN, {}).get(self._config_entry.entry_id, {}) + return entry.get(SWITCH_DOMAIN) + + async def async_added_to_hass(self) -> None: + """Restore the last value and push it to the switch.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if last_state is not None: + try: + self._value = float(last_state.state) + except (TypeError, ValueError): + _LOGGER.debug( + "%s: could not restore intensity from %s, using %s", + self._attr_name, + last_state.state, + DEFAULT_INTENSITY, + ) + # The switch starts after this platform and owns startup adaptation, + # including the decision to skip it for only_once configurations. + await self._push(adapt=False) + + async def async_set_native_value(self, value: float) -> None: + """Set a new intensity and re-adapt the lights straight away.""" + self._value = float(value) + self.async_write_ha_state() + await self._push(adapt=True) + + async def _push(self, *, adapt: bool) -> None: + switch = self._switch + if switch is None or switch.hass is None or switch.is_on is None: + # The parent may not have started yet, or may be disabled in the + # entity registry. It adopts this value when added to Home Assistant. + entry_data = self.hass.data[DOMAIN][self._config_entry.entry_id] + entry_data[PENDING_INTENSITY] = self._value + _LOGGER.debug( + "%s: switch not set up yet, handing intensity %s over to it", + self._attr_name, + self._value, + ) + return + await switch.async_set_intensity(self._value, adapt=adapt) diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 9e22ff57..dab2ff3e 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -53,6 +53,7 @@ "sleep_rgb_color": "sleep_rgb_color", "sleep_transition": "sleep_transition", "transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙", + "intensity_floor": "intensity_floor", "sunrise_time": "sunrise_time", "min_sunrise_time": "min_sunrise_time", "max_sunrise_time": "max_sunrise_time", @@ -86,6 +87,7 @@ "sleep_rgb_or_color_temp": "Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙", "sleep_rgb_color": "RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈", "sleep_transition": "Duration of transition when \"sleep mode\" is toggled in seconds. 😴", + "intensity_floor": "What 0% on the intensity dial means. `sleep` blends towards `sleep_brightness` and the configured sleep color; `minimum` towards `min_brightness`/`min_color_temp`. `transition_until_sleep` forces the sleep endpoint. Lower intensity dims only when the endpoint is below the current adaptive value. 🎚️", "sunrise_time": "Set a fixed time (HH:MM:SS) for sunrise. 🌅", "min_sunrise_time": "Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅", "max_sunrise_time": "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅", diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index db1c2d0b..c5de55a0 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -8,6 +8,7 @@ import hashlib import logging import zoneinfo from copy import deepcopy +from dataclasses import replace from datetime import timedelta from typing import TYPE_CHECKING, Any @@ -104,6 +105,7 @@ from .const import ( CONF_EXPAND_LIGHT_GROUPS, CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, CONF_INITIAL_TRANSITION, + CONF_INTENSITY_FLOOR, CONF_INTERCEPT, CONF_INTERVAL, CONF_LIGHTS, @@ -138,12 +140,14 @@ from .const import ( CONF_TRANSITION, CONF_TURN_ON_LIGHTS, CONF_USE_DEFAULTS, + DEFAULT_INTENSITY, DOMAIN, EXTRA_VALIDATION, ICON_BRIGHTNESS, ICON_COLOR_TEMP, ICON_MAIN, ICON_SLEEP, + PENDING_INTENSITY, SERVICE_CHANGE_SWITCH_SETTINGS, SLEEP_MODE_SWITCH, TURNING_OFF_DELAY, @@ -911,6 +915,15 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._configured_lights: list[str] = list(data[CONF_LIGHTS]) self.lights: list[str] = [] + # Needed to find the value the number entity may have left behind. + self._config_entry_id = config_entry.entry_id + + # Set before _set_changeable_settings, which builds SunLightSettings. + # The number entity restores the real value and pushes it in once it is + # added; rebuilding the settings later must not drop it, which is why it + # lives on the switch rather than only inside SunLightSettings. + self._intensity: float = DEFAULT_INTENSITY + # backup data for use in change_switch_settings "configuration" CONF_USE_DEFAULTS self._config_backup = deepcopy(data) self._set_changeable_settings(data=data, defaults=None) @@ -1038,6 +1051,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): 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), + intensity=self._intensity, + intensity_floor=data[CONF_INTENSITY_FLOOR], ) _LOGGER.debug( "%s: Set switch settings for lights '%s'. now using data: '%s'", @@ -1075,6 +1090,26 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Call when entity about to be added to hass.""" + # The number platform may have been set up first, in which case it + # left its restored intensity here because this switch did not exist yet. + # Take it before the first adaptation, so a restart never comes back at + # full intensity for one interval. + pending = self.hass.data[DOMAIN][self._config_entry_id].pop( + PENDING_INTENSITY, + None, + ) + if pending is not None: + self._intensity = float(pending) + self._sun_light_settings = replace( + self._sun_light_settings, + intensity=self._intensity, + ) + _LOGGER.debug( + "%s: adopted pending intensity %s from the number entity", + self._name, + self._intensity, + ) + if self.hass.is_running: await self._setup_listeners() else: @@ -1226,6 +1261,12 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): def extra_state_attributes(self) -> dict[str, Any]: """Return the attributes of the switch.""" extra_state_attributes: dict[str, Any] = {"configuration": self._config} + extra_state_attributes["intensity"] = self._intensity + # The floor actually in use, which `transition_until_sleep` can + # force to "sleep" regardless of the configured `intensity_floor`. + extra_state_attributes["intensity_floor"] = ( + "sleep" if self._sun_light_settings.intensity_floor_is_sleep else "minimum" + ) if not self.is_on: for key in self._settings: extra_state_attributes[key] = None @@ -1532,6 +1573,30 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): data, ) + async def async_set_intensity(self, value: float, *, adapt: bool = True) -> None: + """Set the intensity dial and re-adapt immediately. + + ``SunLightSettings`` is a frozen dataclass rebuilt by + ``_set_changeable_settings``, so the value is kept on the switch and + replayed into it here; that way ``change_switch_settings`` (which + rebuilds the settings from the stored config) cannot silently reset the + dial back to 100. + """ + self._intensity = float(value) + self._sun_light_settings = replace( + self._sun_light_settings, + intensity=self._intensity, + ) + _LOGGER.debug("%s: intensity set to %s", self._name, self._intensity) + self.async_write_ha_state() + if adapt and self.is_on: + await self._update_attrs_and_maybe_adapt_lights( + context=self.create_context("intensity"), + lights=self.lights, + transition=self.initial_transition, + force=True, + ) + async def _update_attrs_and_maybe_adapt_lights( self, *, diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index cc589156..84f15b34 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -54,6 +54,7 @@ "sleep_rgb_color": "sleep_rgb_color", "sleep_transition": "sleep_transition", "transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙", + "intensity_floor": "intensity_floor", "sunrise_time": "sunrise_time", "min_sunrise_time": "min_sunrise_time", "max_sunrise_time": "max_sunrise_time", @@ -87,6 +88,7 @@ "sleep_rgb_or_color_temp": "Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙", "sleep_rgb_color": "RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈", "sleep_transition": "Duration of transition when \"sleep mode\" is toggled in seconds. 😴", + "intensity_floor": "What 0% on the intensity dial means. `sleep` blends towards `sleep_brightness` and the configured sleep color; `minimum` towards `min_brightness`/`min_color_temp`. `transition_until_sleep` forces the sleep endpoint. Lower intensity dims only when the endpoint is below the current adaptive value. 🎚️", "sunrise_time": "Set a fixed time (HH:MM:SS) for sunrise. 🌅", "min_sunrise_time": "Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅", "max_sunrise_time": "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅", diff --git a/docs/advanced/intensity.md b/docs/advanced/intensity.md new file mode 100644 index 00000000..408c537a --- /dev/null +++ b/docs/advanced/intensity.md @@ -0,0 +1,85 @@ +--- +icon: lucide/sliders-horizontal +--- + +# Intensity + +Intensity blends the adaptive settings toward a configured endpoint, without leaving adaptive mode. It provides a room-wide "mood" level that still tracks the sun. + +## The Dial + +Each Adaptive Lighting configuration creates a number entity: + +``` +number.adaptive_lighting__intensity +``` + +Set it like any other number: + +```yaml +service: number.set_value +target: + entity_id: number.adaptive_lighting_living_room_intensity +data: + value: 60 +``` + +The value is a percentage, and it interpolates: + +``` +output = floor_value + (adaptive_value - floor_value) × intensity / 100 +``` + +| Intensity | Result | +|-----------|--------| +| `100` (default) | The adaptive values, unchanged; interpolation is skipped. | +| `0` | The floor. | +| in between | A scaled adaptive curve — the sun still moves the lights at every setting. | + +Between 0 and 100, the light keeps following the sun with a smaller range. At 0, the target stays at the endpoint. Both brightness and color are blended, subject to the profile's adaptation switches and each light's manual-control status and supported color modes. + +Changing the dial re-adapts eligible, already-on lights immediately rather than waiting for the next `interval`, even with `only_once` enabled. It does not turn lights on or clear manual control. While the main switch is off, it stores the value for later. The value survives a restart and is restored before startup adaptation; `only_once` still prevents startup adaptation. + +## Choosing the Floor + +The floor is set per configuration with `intensity_floor`: + +| `intensity_floor` | 0% gives | Use it when | +|-------------------|----------|-------------| +| `sleep` (default) | `sleep_brightness` and the configured sleep color | You want 0% to match [sleep mode](sleep-mode.md). RGB sleep colors are used on color-capable lights; CT-only lights use `sleep_color_temp`. | +| `minimum` | `min_brightness` / `min_color_temp` | You want the dial to stay inside the range the adaptive curve already uses. | + +`minimum` never takes a light below what Adaptive Lighting would have done at its darkest anyway. The trade is that it does less and less as the evening goes on, and **color stops moving after sunset** — the adaptive color temperature is already `min_color_temp` there, so the floor and the value being interpolated from are the same number. + +The `sleep` endpoint can go below `min_brightness`. It dims and warms the light only when the sleep settings are dimmer and warmer than the current adaptive target. If you configured a brighter or cooler sleep setting, lowering intensity instead moves toward that setting. Intensity 0 means the configured endpoint, not off. + +## `transition_until_sleep` Overrides the Floor + +With [`transition_until_sleep`](sleep-mode.md) enabled, the adaptive color after sunset moves toward the sleep color. When sleep is warmer than `min_color_temp`, using the minimum endpoint could make dial-down cool the light during that period. + +For that reason the `sleep` floor is forced whenever `transition_until_sleep` is on, whatever `intensity_floor` says. The switch's `intensity_floor` attribute reports the floor actually in use, so you can see when this applies: + +```yaml +{{ state_attr('switch.adaptive_lighting_living_room', 'intensity_floor') }} +``` + +## Interaction With Sleep Mode + +Sleep mode ignores the dial entirely. Its output already *is* the sleep value, so scaling towards the floor would be a no-op with the default floor and misleading with the other. Sleep mode behaves identically whatever the intensity is set to. + +## Attributes + +The main switch exposes both values: + +| Attribute | Meaning | +|-----------|---------| +| `intensity` | The current dial value, 0-100. | +| `intensity_floor` | The floor in use, `sleep` or `minimum`, after the `transition_until_sleep` override. | + +`change_switch_settings` preserves intensity, including when resetting settings to factory or configuration defaults. Set the number to 100 to restore the unmodified adaptive curve. Automations watching `brightness_pct` see the blended target, so changing intensity can trigger their brightness thresholds. + +## Why Not Just Scale `min_brightness` and `max_brightness`? + +An `input_number` helper and `change_switch_settings` automation can reproduce this brightness curve by replacing each original bound `B` with `floor + (B - floor) * intensity / 100`. Both bounds keep following the sun; this does not freeze the target. The automation needs the original bounds and must keep them in sync when the profile is retuned. Ordinary color-temperature bounds can be transformed similarly, but this alone does not reproduce sleep-RGB blending. + +The dial reads the configuration's own numbers, so there is nothing to keep in sync, and it moves color along with brightness. diff --git a/docs/configuration.md b/docs/configuration.md index afd05721..b9261d2d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,6 +49,7 @@ All configuration options are listed below with their default values. These opti | `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color | | `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 | | `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙 | `False` | `bool` | +| `intensity_floor` | What 0% on the intensity dial means. `sleep` blends towards `sleep_brightness` and the configured sleep color; `minimum` towards `min_brightness`/`min_color_temp`. `transition_until_sleep` forces the sleep endpoint. Lower intensity dims only when the endpoint is below the current adaptive value. 🎚️ | `sleep` | one of `['sleep', 'minimum']` | | `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` | | `min_sunrise_time` | Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅 | `None` | `str` | | `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅 | `None` | `str` | diff --git a/docs/index.md b/docs/index.md index f01cbfa2..1f550ecc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,12 +34,13 @@ By automatically adapting the settings of your lights throughout the day, Adapti When initially turning on a light that is controlled by Adaptive Lighting, the `light.turn_on` service call is intercepted, and the light's brightness and color are automatically adjusted based on the sun's position. After that, the light's brightness and color are automatically adjusted at a regular interval. -Adaptive Lighting provides four switches (using "living_room" as an example component name): +Adaptive Lighting provides four switches and a number entity (using "living_room" as an example component name): - `switch.adaptive_lighting_living_room`: Turn Adaptive Lighting on or off and view current light settings through its attributes. - `switch.adaptive_lighting_sleep_mode_living_room`: Activate "sleep mode" 😴 and set custom sleep_brightness and sleep_color_temp. - `switch.adaptive_lighting_adapt_brightness_living_room`: Enable or disable brightness adaptation 🔆 for supported lights. - `switch.adaptive_lighting_adapt_color_living_room`: Enable or disable color adaptation 🌈 for supported lights. +- `number.adaptive_lighting_living_room_intensity`: Scale 🎚️ how far the adaptive settings travel from their floor, from 100% (unchanged) down to 0%. @@ -54,14 +55,15 @@ Adaptive Lighting provides four switches (using "living_room" as an example comp ## How It Works -Adaptive Lighting provides four switches for each configuration (using "living_room" as an example): +Adaptive Lighting provides four switches and a number entity for each configuration (using "living_room" as an example): -| Switch | Purpose | +| Entity | Purpose | |--------|---------| | `switch.adaptive_lighting_living_room` | Main on/off control | | `switch.adaptive_lighting_sleep_mode_living_room` | Activate sleep mode | | `switch.adaptive_lighting_adapt_brightness_living_room` | Enable/disable brightness adaptation | | `switch.adaptive_lighting_adapt_color_living_room` | Enable/disable color adaptation | +| `number.adaptive_lighting_living_room_intensity` | [Scale the adaptive curve](advanced/intensity.md) towards its floor | ## Interactive Simulator diff --git a/tests/test_intensity.py b/tests/test_intensity.py new file mode 100644 index 00000000..c5d630b9 --- /dev/null +++ b/tests/test_intensity.py @@ -0,0 +1,251 @@ +"""Tests for the intensity dial on `SunLightSettings`.""" + +import datetime as dt +import zoneinfo + +import pytest +from astral import LocationInfo +from astral.location import Location +from homeassistant.components.adaptive_lighting.color_and_brightness import ( + SunLightSettings, +) +from homeassistant.util.color import color_temperature_to_rgb + +TZINFO = zoneinfo.ZoneInfo("Europe/Amsterdam") +LOCATION = Location( + LocationInfo( + name="name", + region="region", + timezone="Europe/Amsterdam", + latitude=52.379189, + longitude=4.899431, + ), +) + +# Spread over a winter day so that the daylight branch, the post-sunset branch +# and solar midnight are all covered. +TIMES = [dt.datetime(2022, 1, 1, hour, tzinfo=dt.UTC) for hour in range(0, 24, 3)] + +MIN_BRIGHTNESS = 20 +MAX_BRIGHTNESS = 100 +MIN_COLOR_TEMP = 2200 +MAX_COLOR_TEMP = 5500 +SLEEP_BRIGHTNESS = 1 +SLEEP_COLOR_TEMP = 1000 + + +def make_settings(**kwargs) -> SunLightSettings: + """Build a `SunLightSettings`, overriding any field by keyword.""" + defaults = { + "name": "test", + "astral_observer": LOCATION.observer, + "adapt_until_sleep": False, + "max_brightness": MAX_BRIGHTNESS, + "max_color_temp": MAX_COLOR_TEMP, + "min_brightness": MIN_BRIGHTNESS, + "min_color_temp": MIN_COLOR_TEMP, + "sleep_brightness": SLEEP_BRIGHTNESS, + "sleep_rgb_or_color_temp": "color_temp", + "sleep_color_temp": SLEEP_COLOR_TEMP, + "sleep_rgb_color": (255, 56, 0), + "sunrise_time": None, + "min_sunrise_time": None, + "max_sunrise_time": None, + "sunset_time": None, + "min_sunset_time": None, + "max_sunset_time": None, + "brightness_mode_time_dark": dt.timedelta(seconds=900), + "brightness_mode_time_light": dt.timedelta(seconds=3600), + "timezone": TZINFO, + } + return SunLightSettings(**(defaults | kwargs)) + + +def floor_values(intensity_floor: str) -> tuple[int, int]: + """The (brightness, color temp) the dial interpolates towards.""" + if intensity_floor == "sleep": + return SLEEP_BRIGHTNESS, SLEEP_COLOR_TEMP + return MIN_BRIGHTNESS, MIN_COLOR_TEMP + + +@pytest.mark.parametrize("intensity_floor", ["sleep", "minimum"]) +@pytest.mark.parametrize("adapt_until_sleep", [True, False]) +@pytest.mark.parametrize("is_sleep", [True, False]) +@pytest.mark.parametrize("datetime", TIMES) +def test_full_intensity_changes_nothing( + datetime, + is_sleep, + adapt_until_sleep, + intensity_floor, +): + """The default (100) must leave the adaptive result untouched.""" + default = make_settings(adapt_until_sleep=adapt_until_sleep) + dialled = make_settings( + adapt_until_sleep=adapt_until_sleep, + intensity=100, + intensity_floor=intensity_floor, + ) + assert dialled.brightness_and_color( + datetime, + is_sleep=is_sleep, + ) == default.brightness_and_color(datetime, is_sleep=is_sleep) + + +@pytest.mark.parametrize("intensity_floor", ["sleep", "minimum"]) +def test_apply_intensity_short_circuits_at_full(intensity_floor): + """At 100 the interpolation is skipped outright, not merely a no-op.""" + settings = make_settings(intensity=100, intensity_floor=intensity_floor) + arguments = (50.0, 3000, (255, 180, 100)) + assert settings._apply_intensity(*arguments, is_sleep=False) == arguments + + +@pytest.mark.parametrize("intensity", [0, 25, 50, 75, 100]) +@pytest.mark.parametrize("datetime", TIMES) +def test_sleep_mode_ignores_the_dial(datetime, intensity): + """Sleep mode already is the sleep value, so the dial must not touch it.""" + default = make_settings() + dialled = make_settings(intensity=intensity) + assert dialled.brightness_and_color( + datetime, + is_sleep=True, + ) == default.brightness_and_color(datetime, is_sleep=True) + + +@pytest.mark.parametrize("intensity_floor", ["sleep", "minimum"]) +@pytest.mark.parametrize("datetime", TIMES) +def test_zero_intensity_reaches_the_floor(datetime, intensity_floor): + """0 must land exactly on the configured floor, at every hour.""" + settings = make_settings(intensity=0, intensity_floor=intensity_floor) + result = settings.brightness_and_color(datetime, is_sleep=False) + brightness, color_temp = floor_values(intensity_floor) + assert result["brightness_pct"] == pytest.approx(brightness) + assert result["color_temp_kelvin"] == 5 * round(color_temp / 5) + + +@pytest.mark.parametrize("intensity_floor", ["sleep", "minimum"]) +@pytest.mark.parametrize("datetime", TIMES) +def test_half_intensity_is_the_midpoint(datetime, intensity_floor): + """The dial interpolates linearly between the floor and the full value.""" + full = make_settings().brightness_and_color(datetime, is_sleep=False) + half = make_settings( + intensity=50, + intensity_floor=intensity_floor, + ).brightness_and_color(datetime, is_sleep=False) + brightness, color_temp = floor_values(intensity_floor) + assert half["brightness_pct"] == pytest.approx( + brightness + (full["brightness_pct"] - brightness) / 2, + ) + expected_kelvin = round(color_temp + (full["color_temp_kelvin"] - color_temp) / 2) + assert half["color_temp_kelvin"] == 5 * round(expected_kelvin / 5) + + +@pytest.mark.parametrize("intensity_floor", ["sleep", "minimum"]) +@pytest.mark.parametrize("datetime", TIMES) +def test_intensity_is_monotonic(datetime, intensity_floor): + """Turning the dial up may never dim or cool the light.""" + results = [ + make_settings( + intensity=intensity, + intensity_floor=intensity_floor, + ).brightness_and_color(datetime, is_sleep=False) + for intensity in (0, 25, 50, 75, 100) + ] + brightnesses = [result["brightness_pct"] for result in results] + kelvins = [result["color_temp_kelvin"] for result in results] + assert brightnesses == sorted(brightnesses) + assert kelvins == sorted(kelvins) + + +@pytest.mark.parametrize("intensity_floor", ["sleep", "minimum"]) +@pytest.mark.parametrize("intensity", [0, 25, 50, 75, 100]) +@pytest.mark.parametrize("datetime", TIMES) +def test_results_stay_in_range(datetime, intensity, intensity_floor): + """Whatever the dial does, the output must remain a legal light setting.""" + result = make_settings( + intensity=intensity, + intensity_floor=intensity_floor, + ).brightness_and_color(datetime, is_sleep=False) + full = make_settings().brightness_and_color(datetime, is_sleep=False) + brightness, color_temp = floor_values(intensity_floor) + assert brightness <= result["brightness_pct"] <= full["brightness_pct"] + assert 0 < result["brightness_pct"] <= 100 + assert color_temp <= result["color_temp_kelvin"] <= full["color_temp_kelvin"] + assert all(0 <= channel <= 255 for channel in result["rgb_color"]) + + +@pytest.mark.parametrize( + ("intensity_floor", "adapt_until_sleep", "expected"), + [ + ("sleep", False, True), + ("sleep", True, True), + ("minimum", False, False), + ("minimum", True, True), + ], +) +def test_intensity_floor_is_sleep(intensity_floor, adapt_until_sleep, expected): + """`adapt_until_sleep` forces the sleep floor, whatever the option says.""" + settings = make_settings( + intensity_floor=intensity_floor, + adapt_until_sleep=adapt_until_sleep, + ) + assert settings.intensity_floor_is_sleep is expected + + +@pytest.mark.parametrize("intensity", [0, 25, 50, 75, 100]) +@pytest.mark.parametrize("datetime", TIMES) +def test_adapt_until_sleep_overrides_the_floor(datetime, intensity): + """With `adapt_until_sleep` on, `intensity_floor` makes no difference. + + The adaptive color temperature then descends below `min_color_temp` towards + `sleep_color_temp`, so a `min_color_temp` floor would sit above the adaptive + value and turning the dial down would make the light cooler. + """ + results = [ + make_settings( + intensity=intensity, + intensity_floor=intensity_floor, + adapt_until_sleep=True, + ).brightness_and_color(datetime, is_sleep=False) + for intensity_floor in ("sleep", "minimum") + ] + assert results[0] == results[1] + + +@pytest.mark.parametrize("adapt_until_sleep", [True, False]) +@pytest.mark.parametrize("datetime", TIMES) +def test_zero_intensity_matches_sleep_rgb(datetime, adapt_until_sleep): + """0 must reproduce sleep mode's RGB, not a colour derived from Kelvin. + + A switch using `sleep_rgb_or_color_temp: rgb_color` expresses its sleep + colour as RGB. Interpolating the colour temperature and re-deriving RGB from + it lands 0% on `color_temperature_to_rgb(sleep_color_temp)`, which is a + different colour from the configured `sleep_rgb_color`. This held only on + the `adapt_until_sleep` path before. + """ + kwargs = { + "sleep_rgb_or_color_temp": "rgb_color", + "adapt_until_sleep": adapt_until_sleep, + } + asleep = make_settings(**kwargs).brightness_and_color(datetime, is_sleep=True) + dialled = make_settings(intensity=0, **kwargs).brightness_and_color( + datetime, + is_sleep=False, + ) + assert dialled["rgb_color"] == asleep["rgb_color"] + + +@pytest.mark.parametrize("datetime", TIMES) +def test_minimum_floor_derives_rgb_from_color_temp(datetime): + """With the `minimum` floor the anchor is a Kelvin, so RGB follows it. + + The sleep RGB colour is deliberately NOT used here -- it is not the floor. + """ + settings = make_settings( + intensity=0, + intensity_floor="minimum", + adapt_until_sleep=False, + sleep_rgb_or_color_temp="rgb_color", + ) + result = settings.brightness_and_color(datetime, is_sleep=False) + expected = color_temperature_to_rgb(result["color_temp_kelvin"]) + assert result["rgb_color"] == tuple(round(c) for c in expected) diff --git a/tests/test_switch.py b/tests/test_switch.py index a3f5b689..6f7b46e1 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -69,6 +69,7 @@ from homeassistant.components.adaptive_lighting.const import ( DEFAULT_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_RGB_COLOR, DOMAIN, + INTENSITY_NUMBER, SERVICE_APPLY, SERVICE_CHANGE_SWITCH_SETTINGS, SERVICE_SET_MANUAL_CONTROL, @@ -103,6 +104,7 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.template import light as template_light from homeassistant.components.template.light import StateLightEntity as LightTemplate @@ -422,6 +424,9 @@ async def test_adaptive_lighting_switches(hass): switch.adapt_color_switch.entity_id, switch.adapt_brightness_switch.entity_id, } + assert hass.states.async_entity_ids(NUMBER_DOMAIN) == [ + "number.adaptive_lighting_default_intensity", + ] assert ATTR_ADAPTIVE_LIGHTING_MANAGER in hass.data[DOMAIN] assert entry.entry_id in hass.data[DOMAIN] assert len(hass.data[DOMAIN].keys()) == 2 @@ -432,8 +437,9 @@ async def test_adaptive_lighting_switches(hass): assert ADAPT_COLOR_SWITCH in data assert ADAPT_BRIGHTNESS_SWITCH in data assert UNDO_UPDATE_LISTENER in data + assert INTENSITY_NUMBER in data - assert len(data.keys()) == 5 + assert len(data.keys()) == 6 def async_process_ha_core_config(hass, config): @@ -6344,3 +6350,173 @@ async def test_unloaded_polling_profile_preserves_other_split_adaptation( await hass.async_block_till_done() assert len(calls) == 2 assert ATTR_COLOR_TEMP_KELVIN in calls[-1] + + +@pytest.mark.parametrize("only_once", [False, True]) +async def test_intensity_restore_before_adaptation(hass, only_once): + """Restoration never emits a full-intensity command or overrides only_once.""" + from tests.common import async_mock_service, mock_restore_cache + + await setup_lights(hass) + mock_restore_cache( + hass, + [State("number.adaptive_lighting_default_intensity", "25")], + ) + calls = async_mock_service(hass, LIGHT_DOMAIN, SERVICE_TURN_ON) + _, switch = await setup_switch( + hass, + { + CONF_LIGHTS: [ENTITY_LIGHT_1], + CONF_MIN_BRIGHTNESS: 100, + CONF_MAX_BRIGHTNESS: 100, + "sleep_brightness": 4, + CONF_ONLY_ONCE: only_once, + }, + ) + assert switch.extra_state_attributes["intensity"] == 25 + assert [call.data[ATTR_BRIGHTNESS] for call in calls] == ([] if only_once else [71]) + + +async def test_intensity_with_disabled_main_switch(hass): + """A disabled parent must not prevent its intensity entity from loading.""" + from tests.common import mock_restore_cache + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: DEFAULT_NAME, CONF_INTERCEPT: False}, + ) + entry.add_to_hass(hass) + registry = entity_registry.async_get(hass) + registry.async_get_or_create( + SWITCH_DOMAIN, + DOMAIN, + DEFAULT_NAME, + config_entry=entry, + disabled_by=entity_registry.RegistryEntryDisabler.USER, + ) + mock_restore_cache( + hass, + [State("number.adaptive_lighting_default_intensity", "25")], + ) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + number_id = "number.adaptive_lighting_default_intensity" + assert hass.states.get(number_id).state == "25.0" + await hass.services.async_call( + NUMBER_DOMAIN, + "set_value", + {ATTR_ENTITY_ID: number_id, "value": 50}, + blocking=True, + ) + assert hass.states.get(number_id).state == "50.0" + + +@pytest.mark.parametrize( + ("color_modes", "floor", "intensity", "expected_attribute"), + [ + ([ColorMode.COLOR_TEMP, ColorMode.RGB], "sleep", 0, ATTR_RGB_COLOR), + ([ColorMode.COLOR_TEMP, ColorMode.RGB], "sleep", 50, ATTR_RGB_COLOR), + ([ColorMode.COLOR_TEMP, ColorMode.RGB], "sleep", 100, ATTR_COLOR_TEMP_KELVIN), + ([ColorMode.COLOR_TEMP, ColorMode.RGB], "minimum", 0, ATTR_COLOR_TEMP_KELVIN), + ([ColorMode.COLOR_TEMP], "sleep", 0, ATTR_COLOR_TEMP_KELVIN), + ([ColorMode.RGB], "sleep", 0, ATTR_RGB_COLOR), + ], +) +async def test_intensity_uses_sleep_rgb_in_light_command( + hass, + color_modes, + floor, + intensity, + expected_attribute, +): + """Select the blended RGB target on lights that also support Kelvin.""" + entry, switch = await setup_switch( + hass, + { + "sleep_rgb_color": [255, 0, 0], + CONF_SLEEP_RGB_OR_COLOR_TEMP: "rgb_color", + "sleep_color_temp": 2000, + "intensity_floor": floor, + CONF_ADAPT_UNTIL_SLEEP: False, + }, + ) + hass.states.async_set( + ENTITY_LIGHT_1, + STATE_ON, + { + "supported_color_modes": color_modes, + "min_color_temp_kelvin": 2000, + "max_color_temp_kelvin": 6500, + }, + ) + await hass.data[DOMAIN][entry.entry_id][INTENSITY_NUMBER].async_set_native_value( + intensity, + ) + data = await switch.prepare_adaptation_data(ENTITY_LIGHT_1, transition=0) + assert data is not None + command = await data.next_service_call_data() + assert expected_attribute in command + other_attribute = ( + ATTR_RGB_COLOR + if expected_attribute == ATTR_COLOR_TEMP_KELVIN + else ATTR_COLOR_TEMP_KELVIN + ) + assert other_attribute not in command + if expected_attribute == ATTR_RGB_COLOR and intensity == 0: + assert command[ATTR_RGB_COLOR] == (255, 0, 0) + + +async def test_intensity_set_preserves_control_and_runtime_settings(hass): + """Dial changes affect on, adaptive lights and survive settings changes/reload.""" + from tests.common import async_mock_service + + await setup_lights(hass) + entry, switch = await setup_switch( + hass, + { + CONF_LIGHTS: [ENTITY_LIGHT_1, ENTITY_LIGHT_2, ENTITY_LIGHT_3], + CONF_MIN_BRIGHTNESS: 100, + CONF_MAX_BRIGHTNESS: 100, + "sleep_brightness": 4, + CONF_ONLY_ONCE: True, + }, + ) + switch.manager.set_manual_control_attributes(ENTITY_LIGHT_2) + calls = async_mock_service(hass, LIGHT_DOMAIN, SERVICE_TURN_ON) + number_id = "number.adaptive_lighting_default_intensity" + await hass.services.async_call( + NUMBER_DOMAIN, + "set_value", + {ATTR_ENTITY_ID: number_id, "value": 25}, + blocking=True, + ) + assert [ + (call.data[ATTR_ENTITY_ID], call.data[ATTR_BRIGHTNESS]) for call in calls + ] == [ + (ENTITY_LIGHT_1, 71), + ] + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + {ATTR_ENTITY_ID: switch.entity_id, CONF_MIN_BRIGHTNESS: 80}, + blocking=True, + ) + assert switch.extra_state_attributes["intensity"] == 25 + assert switch._sun_light_settings.intensity == 25 + assert switch.manager.get_manual_control_attributes(ENTITY_LIGHT_2).has_all() + await switch.async_turn_off() + calls.clear() + await hass.services.async_call( + NUMBER_DOMAIN, + "set_value", + {ATTR_ENTITY_ID: number_id, "value": 50}, + blocking=True, + ) + assert not calls + assert switch.extra_state_attributes["intensity"] == 50 + assert await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + restored = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN] + assert restored.extra_state_attributes["intensity"] == 50 + assert not restored.is_on + assert not calls diff --git a/zensical.toml b/zensical.toml index dfa1b309..a9c7b3ca 100644 --- a/zensical.toml +++ b/zensical.toml @@ -21,6 +21,7 @@ nav = [ { "Troubleshooting" = "troubleshooting.md" }, { "Advanced" = [ { "Brightness Modes" = "advanced/brightness-modes.md" }, + { "Intensity" = "advanced/intensity.md" }, { "Manual Control" = "advanced/manual-control.md" }, { "Sleep Mode" = "advanced/sleep-mode.md" }, ] },