From 3b1b84e7e8390dd7d13f81a30d81b446b522a684 Mon Sep 17 00:00:00 2001 From: Ahmad Tawakol Date: Mon, 7 Sep 2026 12:26:13 -0300 Subject: [PATCH 1/4] Add a per-switch intensity dial (number entity) Adds one `number.adaptive_lighting_intensity_` entity per configuration, alongside the Sleep Mode / Adapt Brightness / Adapt Color switches on the same device. It is a 0-100% slider that interpolates the adaptive result towards the switch's floor: output = floor_value + (adaptive_value - floor_value) * intensity / 100 100 short-circuits and returns the adaptive value untouched, so it is the default and existing configurations behave exactly as before. 0 returns the floor. In between the light stays fully adaptive -- the sun still moves it at every setting, so it is a scaled curve rather than a frozen level, which is what distinguishes this from simply scaling the brightness band. The floor is set by a new per-switch `intensity_floor` option: the switch's sleep settings (`sleep_brightness`/`sleep_color_temp`) by default, or its `min_brightness`/`min_color_temp`. `transition_until_sleep` forces the sleep floor whatever `intensity_floor` says. With that option on, the adaptive color temperature after sunset 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. Documented in the README's features and a new Intensity section, and on the docs site as advanced/intensity.md. Co-Authored-By: Claude Opus 5 --- README.md | 113 +++++--- .../adaptive_lighting/__init__.py | 8 +- .../adaptive_lighting/color_and_brightness.py | 86 ++++++ custom_components/adaptive_lighting/const.py | 25 ++ custom_components/adaptive_lighting/number.py | 147 +++++++++++ .../adaptive_lighting/strings.json | 2 + custom_components/adaptive_lighting/switch.py | 65 +++++ .../adaptive_lighting/translations/en.json | 2 + docs/advanced/intensity.md | 83 ++++++ docs/index.md | 8 +- tests/test_intensity.py | 248 ++++++++++++++++++ tests/test_switch.py | 8 +- zensical.toml | 1 + 13 files changed, 747 insertions(+), 49 deletions(-) create mode 100644 custom_components/adaptive_lighting/number.py create mode 100644 docs/advanced/intensity.md create mode 100644 tests/test_intensity.py diff --git a/README.md b/README.md index 0736b56a..2afc7364 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,33 @@ 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, so the dial costs nothing until you move it. +At 0% they receive the floor. +In between they remain fully adaptive: the sun keeps moving them at every setting, so the dial scales the curve rather than freezing the lights at a level. + +The floor is set per configuration with `intensity_floor`: + +- `sleep` (the default) interpolates towards `sleep_brightness` and `sleep_color_temp`, so 0% matches what sleep mode would do. +- `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 that option on, the adaptive color temperature after sunset 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. +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. + +This is what you want for a room-wide "mood" level that coexists with Adaptive Lighting: point an automation or a dashboard slider at the number entity, rather than rewriting every light's brightness band and putting it out of step with the configuration. + + ## :books: Table of Contents @@ -128,50 +156,51 @@ The YAML and frontend configuration methods support all of the options listed be -| Variable name | Description | Default | Type | -|:--------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:----------------------------------------| -| `lights` | List of light entity_ids to be controlled (may be empty). 🌟 | `[]` | list of `entity_id`s | -| `interval` | Frequency to adapt the lights, in seconds. πŸ”„ | `90` | `int > 0` | -| `transition` | Duration of transition when lights change, in seconds. πŸ•‘ | `45` | `float` 0-6553 | -| `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ | `1` | `float` 0-6553 | -| `min_brightness` | Minimum brightness percentage. πŸ’‘ | `1` | `int` 1-100 | -| `max_brightness` | Maximum brightness percentage. πŸ’‘ | `100` | `int` 1-100 | -| `min_color_temp` | Warmest color temperature in Kelvin. πŸ”₯ | `2000` | `int` 1000-10000 | -| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 | -| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` | -| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 | -| `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. πŸŒ™ | `color_temp` | one of `['color_temp', 'rgb_color']` | -| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 | -| `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` | -| `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` | -| `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` | -| `sunset_time` | Set a fixed time (HH:MM:SS) for sunset. πŸŒ‡ | `None` | `str` | -| `min_sunset_time` | Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. πŸŒ‡ | `None` | `str` | -| `max_sunset_time` | Set the latest virtual sunset time (HH:MM:SS), allowing for earlier sunsets. πŸŒ‡ | `None` | `str` | -| `sunset_offset` | Adjust sunset time with a positive or negative offset in seconds. ⏰ | `0` | `int` | -| `brightness_mode` | Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). πŸ“ˆ | `default` | one of `['default', 'linear', 'tanh']` | +| Variable name | Description | Default | Type | +|:--------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:----------------------------------------| +| `lights` | List of light entity_ids to be controlled (may be empty). 🌟 | `[]` | list of `entity_id`s | +| `interval` | Frequency to adapt the lights, in seconds. πŸ”„ | `90` | `int > 0` | +| `transition` | Duration of transition when lights change, in seconds. πŸ•‘ | `45` | `float` 0-6553 | +| `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ | `1` | `float` 0-6553 | +| `min_brightness` | Minimum brightness percentage. πŸ’‘ | `1` | `int` 1-100 | +| `max_brightness` | Maximum brightness percentage. πŸ’‘ | `100` | `int` 1-100 | +| `min_color_temp` | Warmest color temperature in Kelvin. πŸ”₯ | `2000` | `int` 1000-10000 | +| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 | +| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` | +| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 | +| `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. πŸŒ™ | `color_temp` | one of `['color_temp', 'rgb_color']` | +| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 | +| `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 the bottom of the intensity dial means. `sleep` interpolates towards `sleep_brightness`/`sleep_color_temp`, `minimum` towards `min_brightness`/`min_color_temp`. Has no effect while `transition_until_sleep` is enabled: the sleep settings are then the bottom of the adaptive curve itself, so 0% is always the sleep settings. 🎚️ | `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` | +| `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` | +| `sunset_time` | Set a fixed time (HH:MM:SS) for sunset. πŸŒ‡ | `None` | `str` | +| `min_sunset_time` | Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. πŸŒ‡ | `None` | `str` | +| `max_sunset_time` | Set the latest virtual sunset time (HH:MM:SS), allowing for earlier sunsets. πŸŒ‡ | `None` | `str` | +| `sunset_offset` | Adjust sunset time with a positive or negative offset in seconds. ⏰ | `0` | `int` | +| `brightness_mode` | Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). πŸ“ˆ | `default` | one of `['default', 'linear', 'tanh']` | | `brightness_mode_time_dark` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. πŸ“ˆπŸ“‰ | `900` | `int` | | `brightness_mode_time_light` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. πŸ“ˆπŸ“‰. | `3600` | `int` | -| `take_over_control` | Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. πŸ”’ | `True` | `bool` | -| `take_over_control_mode` | The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed. | `pause_all` | one of `['pause_all', 'pause_changed']` | -| `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. πŸ•΅οΈ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues. | `False` | `bool` | -| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 | -| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). πŸ”„ | `False` | `bool` | +| `take_over_control` | Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. πŸ”’ | `True` | `bool` | +| `take_over_control_mode` | The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed. | `pause_all` | one of `['pause_all', 'pause_changed']` | +| `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. πŸ•΅οΈ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues. | `False` | `bool` | +| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 | +| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). πŸ”„ | `False` | `bool` | | `adapt_only_on_bare_turn_on` | When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene and marks the light as manually controlled. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. πŸ•΅οΈ | `False` | `bool` | -| `manual_control_on_external_turn_on` | Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. πŸ•΅οΈ | `False` | `bool` | -| `reset_manual_control_on_sleep_mode_change` | Reset manual control when the sleep mode switch is toggled. Set to `false` to preserve manual control across sleep mode changes. 😴 | `True` | `bool` | -| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. πŸ”€ | `False` | `bool` | -| `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 | -| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` | -| `skip_redundant_commands` | Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. πŸ“‰Disable if physical light states get out of sync with HA's recorded state. | `False` | `bool` | -| `intercept` | Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. 🏎️ Disable for lights that do not support `light.turn_on` with color and brightness. | `True` | `bool` | -| `multi_light_intercept` | Intercept and adapt `light.turn_on` calls that target multiple lights. βž—βš οΈ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. Requires `intercept` to be enabled. | `True` | `bool` | -| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. πŸ“ | `False` | `bool` | -| `expand_light_groups` | Expand light groups to their members (`true`, default). Set `false` to send commands to the group and track manual control for the group. Explicit member targets in services stay individual targets. | `True` | `bool` | +| `manual_control_on_external_turn_on` | Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. πŸ•΅οΈ | `False` | `bool` | +| `reset_manual_control_on_sleep_mode_change` | Reset manual control when the sleep mode switch is toggled. Set to `false` to preserve manual control across sleep mode changes. 😴 | `True` | `bool` | +| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. πŸ”€ | `False` | `bool` | +| `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 | +| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` | +| `skip_redundant_commands` | Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. πŸ“‰Disable if physical light states get out of sync with HA's recorded state. | `False` | `bool` | +| `intercept` | Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. 🏎️ Disable for lights that do not support `light.turn_on` with color and brightness. | `True` | `bool` | +| `multi_light_intercept` | Intercept and adapt `light.turn_on` calls that target multiple lights. βž—βš οΈ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. Requires `intercept` to be enabled. | `True` | `bool` | +| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. πŸ“ | `False` | `bool` | +| `expand_light_groups` | Expand light groups to their members (`true`, default). Set `false` to send commands to the group and track manual control for the group. Explicit member targets in services stay individual targets. | `True` | `bool` | diff --git a/custom_components/adaptive_lighting/__init__.py b/custom_components/adaptive_lighting/__init__.py index 0ac9ef71..8a05970b 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 = ["switch", "number"] # "number" is the intensity dial def _all_unique_names(value: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -122,9 +122,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..95e09449 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,80 @@ 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. With + that option on, the adaptive colour temperature after sunset 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 -- backwards. The sleep + settings are the bottom of the curve there, so they are the only + sensible floor. + """ + 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, + keep_rgb: 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. + Scaling towards zero instead (the obvious implementation) is wrong for a + mood dial. + + 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 keep_rgb: + # This switch drives colour as RGB after sunset, so walk the RGB value + # towards the configured sleep colour rather than re-deriving it from + # the (unused) colour temperature. `keep_rgb` is only ever set when + # `adapt_until_sleep` is on, which forces the sleep floor, so the + # sleep colour is always the right target here. + 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 +521,14 @@ 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, + keep_rgb=force_rgb_color, + ) + # 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..5e8bfc5a 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,16 @@ 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 the bottom of the intensity dial means. `sleep` interpolates towards " + "`sleep_brightness`/`sleep_color_temp`, `minimum` towards " + "`min_brightness`/`min_color_temp`. Has no effect while " + "`transition_until_sleep` is enabled: the sleep settings are then the " + "bottom of the adaptive curve itself, so 0% is always the sleep " + "settings. 🎚️" +) + 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 +313,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 +404,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..02b4faab --- /dev/null +++ b/custom_components/adaptive_lighting/number.py @@ -0,0 +1,147 @@ +"""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 is a straight interpolation between the two, recomputed +continuously, so the sun still moves the light at every setting -- it is a +scaled adaptive curve, not a frozen snapshot. + +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, + ) + # Push even at 100 so the switch and the entity can never disagree. + # Re-adapt only if this is an actual dimmed level being restored, so a + # plain restart does not push a redundant adaptation at every switch. + await self._push(adapt=self._value != DEFAULT_INTENSITY) + + 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: + # Home Assistant does not guarantee that the "switch" platform + # finishes before "number", so the switch may not exist yet. Leave + # the value where AdaptiveSwitch.async_added_to_hass will find it + # rather than dropping a restored dial on the floor. + 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..00374bdc 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 the bottom of the intensity dial means. `sleep` interpolates towards `sleep_brightness`/`sleep_color_temp`, `minimum` towards `min_brightness`/`min_color_temp`. Has no effect while `transition_until_sleep` is enabled: the sleep settings are then the bottom of the adaptive curve itself, so 0% is always the sleep settings. 🎚️", "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 871dcf03..472108fc 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 @@ -103,6 +104,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, @@ -137,12 +139,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, @@ -898,6 +902,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) @@ -1025,6 +1038,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'", @@ -1062,6 +1077,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: @@ -1213,6 +1248,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 @@ -1519,6 +1560,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..a6efccd0 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 the bottom of the intensity dial means. `sleep` interpolates towards `sleep_brightness`/`sleep_color_temp`, `minimum` towards `min_brightness`/`min_color_temp`. Has no effect while `transition_until_sleep` is enabled: the sleep settings are then the bottom of the adaptive curve itself, so 0% is always the sleep settings. 🎚️", "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..134ab5a7 --- /dev/null +++ b/docs/advanced/intensity.md @@ -0,0 +1,83 @@ +--- +icon: lucide/sliders-horizontal +--- + +# Intensity + +Intensity scales how far the adaptive settings travel from their floor, without leaving adaptive mode. It is the option to reach for when you want a dimmer room that still tracks the sun β€” a "mood" level, rather than a fixed brightness. + +## 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. The dial costs nothing until you move it. | +| `0` | The floor. | +| in between | A scaled adaptive curve β€” the sun still moves the lights at every setting. | + +The key property is that the lights stay adaptive at every value. Intensity does not freeze a light at a level; it moves the whole curve closer to the floor and keeps following the sun from there. Both brightness and color are scaled together. + +Changing the dial re-adapts the lights immediately rather than waiting for the next `interval`, and the value survives a restart. + +## Choosing the Floor + +The floor is set per configuration with `intensity_floor`: + +| `intensity_floor` | 0% gives | Use it when | +|-------------------|----------|-------------| +| `sleep` (default) | `sleep_brightness` / `sleep_color_temp` | You want the dial to reach as low as the configuration goes. 0% then matches [sleep mode](sleep-mode.md). | +| `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. + +`sleep` keeps the dial useful at every hour, because the sleep settings sit below the adaptive curve at all times. + +## `transition_until_sleep` Overrides the Floor + +With [`transition_until_sleep`](sleep-mode.md) enabled, the adaptive color temperature after sunset descends *below* `min_color_temp` towards `sleep_color_temp`. A `min_color_temp` floor would then sit **above** the adaptive value, and turning the dial down would make the light *cooler* β€” the opposite of what a dimmer should do. + +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. | + +## Why Not Just Scale `min_brightness` and `max_brightness`? + +Rescaling the brightness band from an automation works, but it hardcodes each configuration's band in the automation. Retuning a light in Adaptive Lighting then silently puts the two out of step, and the automation has to be updated in parallel forever. It also leaves color untouched unless you cap `max_color_temp` separately. + +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/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..ad3e92f9 --- /dev/null +++ b/tests/test_intensity.py @@ -0,0 +1,248 @@ +"""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, +) + +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, keep_rgb=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("intensity_floor", ["sleep", "minimum"]) +@pytest.mark.parametrize("adapt_until_sleep", [True, False]) +@pytest.mark.parametrize("datetime", TIMES) +def test_rgb_path_always_has_the_sleep_floor( + datetime, + adapt_until_sleep, + intensity_floor, +): + """`_apply_intensity` walks RGB towards `sleep_rgb_color` unconditionally. + + That is only sound because the RGB path implies `adapt_until_sleep`, which + forces the sleep floor. + """ + settings = make_settings( + intensity=50, + intensity_floor=intensity_floor, + adapt_until_sleep=adapt_until_sleep, + sleep_rgb_or_color_temp="rgb_color", + ) + if settings.brightness_and_color(datetime, is_sleep=False)["force_rgb_color"]: + assert settings.intensity_floor_is_sleep + + +def test_rgb_path_is_covered(): + """Guard against `test_rgb_path_always_has_the_sleep_floor` going vacuous.""" + settings = make_settings( + adapt_until_sleep=True, + sleep_rgb_or_color_temp="rgb_color", + ) + assert any( + settings.brightness_and_color(datetime, is_sleep=False)["force_rgb_color"] + for datetime in TIMES + ) diff --git a/tests/test_switch.py b/tests/test_switch.py index 6efe68d5..8ef057d7 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, @@ -102,6 +103,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 @@ -420,6 +422,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 @@ -430,8 +435,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): 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" }, ] }, From 51e0b11860eb065ba080bd7fefa83371be87f571 Mon Sep 17 00:00:00 2001 From: Ahmad Tawakol Date: Mon, 7 Sep 2026 18:47:10 -0300 Subject: [PATCH 2/4] Use the sleep RGB colour as the floor whenever it is configured A switch with `sleep_rgb_or_color_temp: rgb_color` expresses its sleep colour as RGB. `_apply_intensity` only walked RGB towards `sleep_rgb_color` on the `adapt_until_sleep` path; everywhere else it re-derived RGB from the interpolated colour temperature. Intensity 0 therefore produced `color_temperature_to_rgb(sleep_color_temp)` rather than the configured `sleep_rgb_color`, breaking the guarantee that 0 reproduces sleep mode. The condition is now the switch's own configuration -- the sleep floor being in force, and the sleep colour being expressed as RGB -- which subsumes the `adapt_until_sleep` case and makes the `keep_rgb` parameter redundant. The `minimum` floor still derives RGB from the colour temperature, because its anchor is `min_color_temp`, a Kelvin, and the sleep colour is not the floor there. The existing test asserted the RGB match only when `force_rgb_color` was set, so it stepped over this. Replaced with a test that asserts what was actually promised, under both values of `adapt_until_sleep`, plus one pinning the `minimum` floor's Kelvin-derived behaviour. Reported by greptile-apps on #1593. Co-Authored-By: Claude Opus 5 --- .../adaptive_lighting/color_and_brightness.py | 17 ++--- tests/test_intensity.py | 63 ++++++++++--------- 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index 95e09449..e876da2a 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -433,7 +433,6 @@ class SunLightSettings: rgb_color: tuple[int, int, int], *, is_sleep: bool, - keep_rgb: bool, ) -> tuple[float | None, int, tuple[int, int, int]]: """Scale the adaptive result towards this switch's floor settings. @@ -473,12 +472,15 @@ class SunLightSettings: ) color_temp_kelvin = 5 * round(color_temp_kelvin / 5) # round to nearest 5 - if keep_rgb: - # This switch drives colour as RGB after sunset, so walk the RGB value - # towards the configured sleep colour rather than re-deriving it from - # the (unused) colour temperature. `keep_rgb` is only ever set when - # `adapt_until_sleep` is on, which forces the sleep floor, so the - # sleep colour is always the right target here. + 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) @@ -526,7 +528,6 @@ class SunLightSettings: color_temp_kelvin, rgb_color, is_sleep=is_sleep, - keep_rgb=force_rgb_color, ) # backwards compatibility for versions < 1.3.1 - see #403 diff --git a/tests/test_intensity.py b/tests/test_intensity.py index ad3e92f9..c5d630b9 100644 --- a/tests/test_intensity.py +++ b/tests/test_intensity.py @@ -9,6 +9,7 @@ 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( @@ -95,10 +96,7 @@ 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, keep_rgb=False) - == arguments - ) + assert settings._apply_intensity(*arguments, is_sleep=False) == arguments @pytest.mark.parametrize("intensity", [0, 25, 50, 75, 100]) @@ -213,36 +211,41 @@ def test_adapt_until_sleep_overrides_the_floor(datetime, intensity): assert results[0] == results[1] -@pytest.mark.parametrize("intensity_floor", ["sleep", "minimum"]) @pytest.mark.parametrize("adapt_until_sleep", [True, False]) @pytest.mark.parametrize("datetime", TIMES) -def test_rgb_path_always_has_the_sleep_floor( - datetime, - adapt_until_sleep, - intensity_floor, -): - """`_apply_intensity` walks RGB towards `sleep_rgb_color` unconditionally. +def test_zero_intensity_matches_sleep_rgb(datetime, adapt_until_sleep): + """0 must reproduce sleep mode's RGB, not a colour derived from Kelvin. - That is only sound because the RGB path implies `adapt_until_sleep`, which - forces the sleep floor. + 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=50, - intensity_floor=intensity_floor, - adapt_until_sleep=adapt_until_sleep, + intensity=0, + intensity_floor="minimum", + adapt_until_sleep=False, sleep_rgb_or_color_temp="rgb_color", ) - if settings.brightness_and_color(datetime, is_sleep=False)["force_rgb_color"]: - assert settings.intensity_floor_is_sleep - - -def test_rgb_path_is_covered(): - """Guard against `test_rgb_path_always_has_the_sleep_floor` going vacuous.""" - settings = make_settings( - adapt_until_sleep=True, - sleep_rgb_or_color_temp="rgb_color", - ) - assert any( - settings.brightness_and_color(datetime, is_sleep=False)["force_rgb_color"] - for datetime in TIMES - ) + 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) From 844c7ced85f77ab6f99c1c31f8d8be494f25aaba Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 8 Sep 2026 05:03:10 -0700 Subject: [PATCH 3/4] Fix intensity restoration and RGB output --- README.md | 14 +- .../adaptive_lighting/__init__.py | 6 +- .../adaptive_lighting/color_and_brightness.py | 22 ++- custom_components/adaptive_lighting/const.py | 11 +- custom_components/adaptive_lighting/number.py | 20 +-- .../adaptive_lighting/strings.json | 2 +- .../adaptive_lighting/translations/en.json | 2 +- docs/advanced/intensity.md | 18 +- tests/test_switch.py | 170 ++++++++++++++++++ 9 files changed, 220 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 2afc7364..317b53b9 100644 --- a/README.md +++ b/README.md @@ -79,22 +79,24 @@ Every configuration provides a number entity, `number.adaptive_lighting_living_r output = floor_value + (adaptive_value - floor_value) Γ— intensity / 100 ``` -At 100% β€” the default β€” the lights receive the adaptive values unchanged, so the dial costs nothing until you move it. +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 fully adaptive: the sun keeps moving them at every setting, so the dial scales the curve rather than freezing the lights at a level. +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 `sleep_color_temp`, so 0% matches what sleep mode would do. +- `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 that option on, the adaptive color temperature after sunset 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. +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. -This is what you want for a room-wide "mood" level that coexists with Adaptive Lighting: point an automation or a dashboard slider at the number entity, rather than rewriting every light's brightness band and putting it out of step with the configuration. +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 @@ -173,7 +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 the bottom of the intensity dial means. `sleep` interpolates towards `sleep_brightness`/`sleep_color_temp`, `minimum` towards `min_brightness`/`min_color_temp`. Has no effect while `transition_until_sleep` is enabled: the sleep settings are then the bottom of the adaptive curve itself, so 0% is always the sleep settings. 🎚️ | `sleep` | one of `['sleep', 'minimum']` | +| `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 8a05970b..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", "number"] # "number" is the intensity dial +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 diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index e876da2a..f27bdfab 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -416,13 +416,10 @@ class SunLightSettings: 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. With - that option on, the adaptive colour temperature after sunset 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 -- backwards. The sleep - settings are the bottom of the curve there, so they are the only - sensible floor. + `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 @@ -441,8 +438,7 @@ class SunLightSettings: out = floor_value + (adaptive_value - floor_value) * intensity / 100 so 100 returns the adaptive value untouched and 0 returns the floor value. - Scaling towards zero instead (the obvious implementation) is wrong for a - mood dial. + 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 -- @@ -529,6 +525,14 @@ class SunLightSettings: 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) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 5e8bfc5a..847574b9 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -238,12 +238,11 @@ DOCS[CONF_ADAPT_UNTIL_SLEEP] = ( CONF_INTENSITY_FLOOR, DEFAULT_INTENSITY_FLOOR = "intensity_floor", "sleep" DOCS[CONF_INTENSITY_FLOOR] = ( - "What the bottom of the intensity dial means. `sleep` interpolates towards " - "`sleep_brightness`/`sleep_color_temp`, `minimum` towards " - "`min_brightness`/`min_color_temp`. Has no effect while " - "`transition_until_sleep` is enabled: the sleep settings are then the " - "bottom of the adaptive curve itself, so 0% is always the sleep " - "settings. 🎚️" + "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 diff --git a/custom_components/adaptive_lighting/number.py b/custom_components/adaptive_lighting/number.py index 02b4faab..c79f325e 100644 --- a/custom_components/adaptive_lighting/number.py +++ b/custom_components/adaptive_lighting/number.py @@ -8,9 +8,8 @@ Adapt Brightness / Adapt Color switches on the same device: 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 is a straight interpolation between the two, recomputed -continuously, so the sun still moves the light at every setting -- it is a -scaled adaptive curve, not a frozen snapshot. +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 @@ -118,10 +117,9 @@ class AdaptiveIntensityNumber(NumberEntity, RestoreEntity): last_state.state, DEFAULT_INTENSITY, ) - # Push even at 100 so the switch and the entity can never disagree. - # Re-adapt only if this is an actual dimmed level being restored, so a - # plain restart does not push a redundant adaptation at every switch. - await self._push(adapt=self._value != 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.""" @@ -131,11 +129,9 @@ class AdaptiveIntensityNumber(NumberEntity, RestoreEntity): async def _push(self, *, adapt: bool) -> None: switch = self._switch - if switch is None: - # Home Assistant does not guarantee that the "switch" platform - # finishes before "number", so the switch may not exist yet. Leave - # the value where AdaptiveSwitch.async_added_to_hass will find it - # rather than dropping a restored dial on the floor. + 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( diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 00374bdc..dab2ff3e 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -87,7 +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 the bottom of the intensity dial means. `sleep` interpolates towards `sleep_brightness`/`sleep_color_temp`, `minimum` towards `min_brightness`/`min_color_temp`. Has no effect while `transition_until_sleep` is enabled: the sleep settings are then the bottom of the adaptive curve itself, so 0% is always the sleep settings. 🎚️", + "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/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index a6efccd0..84f15b34 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -88,7 +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 the bottom of the intensity dial means. `sleep` interpolates towards `sleep_brightness`/`sleep_color_temp`, `minimum` towards `min_brightness`/`min_color_temp`. Has no effect while `transition_until_sleep` is enabled: the sleep settings are then the bottom of the adaptive curve itself, so 0% is always the sleep settings. 🎚️", + "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 index 134ab5a7..408c537a 100644 --- a/docs/advanced/intensity.md +++ b/docs/advanced/intensity.md @@ -4,7 +4,7 @@ icon: lucide/sliders-horizontal # Intensity -Intensity scales how far the adaptive settings travel from their floor, without leaving adaptive mode. It is the option to reach for when you want a dimmer room that still tracks the sun β€” a "mood" level, rather than a fixed brightness. +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 @@ -32,13 +32,13 @@ output = floor_value + (adaptive_value - floor_value) Γ— intensity / 100 | Intensity | Result | |-----------|--------| -| `100` (default) | The adaptive values, unchanged. The dial costs nothing until you move it. | +| `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. | -The key property is that the lights stay adaptive at every value. Intensity does not freeze a light at a level; it moves the whole curve closer to the floor and keeps following the sun from there. Both brightness and color are scaled together. +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 the lights immediately rather than waiting for the next `interval`, and the value survives a restart. +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 @@ -46,16 +46,16 @@ The floor is set per configuration with `intensity_floor`: | `intensity_floor` | 0% gives | Use it when | |-------------------|----------|-------------| -| `sleep` (default) | `sleep_brightness` / `sleep_color_temp` | You want the dial to reach as low as the configuration goes. 0% then matches [sleep mode](sleep-mode.md). | +| `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. -`sleep` keeps the dial useful at every hour, because the sleep settings sit below the adaptive curve at all times. +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 temperature after sunset descends *below* `min_color_temp` towards `sleep_color_temp`. A `min_color_temp` floor would then sit **above** the adaptive value, and turning the dial down would make the light *cooler* β€” the opposite of what a dimmer should do. +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: @@ -76,8 +76,10 @@ The main switch exposes both values: | `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`? -Rescaling the brightness band from an automation works, but it hardcodes each configuration's band in the automation. Retuning a light in Adaptive Lighting then silently puts the two out of step, and the automation has to be updated in parallel forever. It also leaves color untouched unless you cap `max_color_temp` separately. +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/tests/test_switch.py b/tests/test_switch.py index 8ef057d7..e69b0991 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -6239,3 +6239,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 From 220e3ef05731e50b8b122d6c63487cfe8babc351 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 8 Sep 2026 05:07:49 -0700 Subject: [PATCH 4/4] Refresh generated intensity documentation --- README.md | 84 +++++++++++++++++++++---------------------- docs/configuration.md | 1 + 2 files changed, 43 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index e91d2b0d..68f5ad52 100644 --- a/README.md +++ b/README.md @@ -158,51 +158,51 @@ The YAML and frontend configuration methods support all of the options listed be -| Variable name | Description | Default | Type | -|:--------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:----------------------------------------| -| `lights` | List of light entity_ids to be controlled (may be empty). 🌟 | `[]` | list of `entity_id`s | -| `interval` | Frequency to adapt the lights, in seconds. πŸ”„ | `90` | `int > 0` | -| `transition` | Duration of transition when lights change, in seconds. πŸ•‘ | `45` | `float` 0-6553 | -| `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ | `1` | `float` 0-6553 | -| `min_brightness` | Minimum brightness percentage. πŸ’‘ | `1` | `int` 1-100 | -| `max_brightness` | Maximum brightness percentage. πŸ’‘ | `100` | `int` 1-100 | -| `min_color_temp` | Warmest color temperature in Kelvin. πŸ”₯ | `2000` | `int` 1000-10000 | -| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 | -| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` | -| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 | -| `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. πŸŒ™ | `color_temp` | one of `['color_temp', 'rgb_color']` | -| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 | -| `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` | -| `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` | -| `sunset_time` | Set a fixed time (HH:MM:SS) for sunset. πŸŒ‡ | `None` | `str` | -| `min_sunset_time` | Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. πŸŒ‡ | `None` | `str` | -| `max_sunset_time` | Set the latest virtual sunset time (HH:MM:SS), allowing for earlier sunsets. πŸŒ‡ | `None` | `str` | -| `sunset_offset` | Adjust sunset time with a positive or negative offset in seconds. ⏰ | `0` | `int` | -| `brightness_mode` | Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). πŸ“ˆ | `default` | one of `['default', 'linear', 'tanh']` | +| Variable name | Description | Default | Type | +|:--------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:----------------------------------------| +| `lights` | List of light entity_ids to be controlled (may be empty). 🌟 | `[]` | list of `entity_id`s | +| `interval` | Frequency to adapt the lights, in seconds. πŸ”„ | `90` | `int > 0` | +| `transition` | Duration of transition when lights change, in seconds. πŸ•‘ | `45` | `float` 0-6553 | +| `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ | `1` | `float` 0-6553 | +| `min_brightness` | Minimum brightness percentage. πŸ’‘ | `1` | `int` 1-100 | +| `max_brightness` | Maximum brightness percentage. πŸ’‘ | `100` | `int` 1-100 | +| `min_color_temp` | Warmest color temperature in Kelvin. πŸ”₯ | `2000` | `int` 1000-10000 | +| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 | +| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` | +| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 | +| `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. πŸŒ™ | `color_temp` | one of `['color_temp', 'rgb_color']` | +| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 | +| `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` | +| `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` | +| `sunset_time` | Set a fixed time (HH:MM:SS) for sunset. πŸŒ‡ | `None` | `str` | +| `min_sunset_time` | Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. πŸŒ‡ | `None` | `str` | +| `max_sunset_time` | Set the latest virtual sunset time (HH:MM:SS), allowing for earlier sunsets. πŸŒ‡ | `None` | `str` | +| `sunset_offset` | Adjust sunset time with a positive or negative offset in seconds. ⏰ | `0` | `int` | +| `brightness_mode` | Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). πŸ“ˆ | `default` | one of `['default', 'linear', 'tanh']` | | `brightness_mode_time_dark` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. πŸ“ˆπŸ“‰ | `900` | `int` | | `brightness_mode_time_light` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. πŸ“ˆπŸ“‰. | `3600` | `int` | -| `take_over_control` | Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. πŸ”’ | `True` | `bool` | -| `take_over_control_mode` | The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed. | `pause_all` | one of `['pause_all', 'pause_changed']` | -| `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. πŸ•΅οΈ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues. | `False` | `bool` | -| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 | -| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). πŸ”„ | `False` | `bool` | +| `take_over_control` | Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. πŸ”’ | `True` | `bool` | +| `take_over_control_mode` | The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed. | `pause_all` | one of `['pause_all', 'pause_changed']` | +| `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. πŸ•΅οΈ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues. | `False` | `bool` | +| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 | +| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). πŸ”„ | `False` | `bool` | | `adapt_only_on_bare_turn_on` | When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene and marks the light as manually controlled. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. πŸ•΅οΈ | `False` | `bool` | -| `manual_control_on_external_turn_on` | Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. πŸ•΅οΈ | `False` | `bool` | -| `reset_manual_control_on_sleep_mode_change` | Reset manual control when the sleep mode switch is toggled. Set to `false` to preserve manual control across sleep mode changes. 😴 | `True` | `bool` | -| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. πŸ”€ | `False` | `bool` | -| `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 | -| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` | -| `skip_redundant_commands` | Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. πŸ“‰Disable if physical light states get out of sync with HA's recorded state. | `False` | `bool` | -| `intercept` | Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. 🏎️ Disable for lights that do not support `light.turn_on` with color and brightness. | `True` | `bool` | -| `multi_light_intercept` | Intercept and adapt `light.turn_on` calls that target multiple lights. βž—βš οΈ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. Requires `intercept` to be enabled. | `True` | `bool` | -| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. πŸ“ | `False` | `bool` | -| `expand_light_groups` | Expand light groups to their members (`true`, default). Set `false` to send commands to the group and track manual control for the group. Explicit member targets in services stay individual targets. | `True` | `bool` | +| `manual_control_on_external_turn_on` | Treat turn-ons without a matching Home Assistant `light.turn_on` context as manual control. Normal manual-control resets apply. Still allows `detect_non_ha_changes` for already-on lights. Needs `take_over_control` enabled. πŸ•΅οΈ | `False` | `bool` | +| `reset_manual_control_on_sleep_mode_change` | Reset manual control when the sleep mode switch is toggled. Set to `false` to preserve manual control across sleep mode changes. 😴 | `True` | `bool` | +| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. πŸ”€ | `False` | `bool` | +| `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 | +| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` | +| `skip_redundant_commands` | Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. πŸ“‰Disable if physical light states get out of sync with HA's recorded state. | `False` | `bool` | +| `intercept` | Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. 🏎️ Disable for lights that do not support `light.turn_on` with color and brightness. | `True` | `bool` | +| `multi_light_intercept` | Intercept and adapt `light.turn_on` calls that target multiple lights. βž—βš οΈ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. Requires `intercept` to be enabled. | `True` | `bool` | +| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. πŸ“ | `False` | `bool` | +| `expand_light_groups` | Expand light groups to their members (`true`, default). Set `false` to send commands to the group and track manual control for the group. Explicit member targets in services stay individual targets. | `True` | `bool` | 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` |