This commit is contained in:
Ahmad Tawakol 2026-09-08 12:07:59 +00:00 committed by GitHub
commit eacd4aadd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 887 additions and 9 deletions

View file

@ -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%.
<!-- SECTION:features:END -->
<!-- SECTION:manual-control:START -->
@ -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._**
<!-- SECTION:manual-control:END -->
<!-- SECTION:intensity:START -->
### :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.
<!-- SECTION:intensity:END -->
## :books: Table of Contents
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
@ -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` |

View file

@ -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]()

View file

@ -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)

View file

@ -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),

View file

@ -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_<name>
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:
# <name>" 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)

View file

@ -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. 🌅",

View file

@ -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,
*,

View file

@ -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. 🌅",

View file

@ -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_<name>_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.

View file

@ -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` |

View file

@ -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%.
<!-- OUTPUT:END -->
@ -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

251
tests/test_intensity.py Normal file
View file

@ -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)

View file

@ -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

View file

@ -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" },
] },