mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-15 16:24:04 +02:00
Add a per-switch intensity dial (number entity)
Adds one `number.adaptive_lighting_intensity_<name>` 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 <noreply@anthropic.com>
This commit is contained in:
parent
2f37b6ea40
commit
3b1b84e7e8
13 changed files with 747 additions and 49 deletions
113
README.md
113
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%.
|
||||
<!-- SECTION:features:END -->
|
||||
|
||||
<!-- SECTION:manual-control:START -->
|
||||
|
|
@ -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._**
|
||||
<!-- SECTION:manual-control:END -->
|
||||
|
||||
<!-- SECTION:intensity:START -->
|
||||
### :level_slider: Intensity
|
||||
|
||||
Every configuration provides a number entity, `number.adaptive_lighting_living_room_intensity`, that scales how far the adaptive settings travel from their floor 🎚️:
|
||||
|
||||
```
|
||||
output = floor_value + (adaptive_value - floor_value) × intensity / 100
|
||||
```
|
||||
|
||||
At 100% — the default — the lights receive the adaptive values unchanged, 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.
|
||||
<!-- SECTION:intensity:END -->
|
||||
|
||||
## :books: Table of Contents
|
||||
|
||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
|
@ -128,50 +156,51 @@ The YAML and frontend configuration methods support all of the options listed be
|
|||
|
||||
<!-- OUTPUT:START -->
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
| 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` |
|
||||
|
||||
<!-- OUTPUT:END -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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]()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
147
custom_components/adaptive_lighting/number.py
Normal file
147
custom_components/adaptive_lighting/number.py
Normal file
|
|
@ -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_<name>
|
||||
|
||||
100% is the normal adaptive behaviour. 0% is the switch's floor, set by the
|
||||
``intensity_floor`` option: its sleep settings (``sleep_brightness`` and
|
||||
``sleep_color_temp``) by default, or its ``min_brightness``/``min_color_temp``.
|
||||
Anything in between 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:
|
||||
# <name>" and this entity contributes only "Intensity".
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize the intensity number."""
|
||||
self.hass = hass
|
||||
self._config_entry = config_entry
|
||||
config = validate(config_entry)
|
||||
self._config_name = config[CONF_NAME]
|
||||
self._which = "Intensity"
|
||||
self._attr_unique_id = f"{self._config_name}_{slugify(self._which)}"
|
||||
self._attr_name = self._which
|
||||
self._attr_icon = ICON_INTENSITY
|
||||
self._value: float = DEFAULT_INTENSITY
|
||||
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Return the current intensity."""
|
||||
return self._value
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Group with the other entities for this configuration."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self._config_name)},
|
||||
name=f"Adaptive Lighting: {self._config_name}",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@property
|
||||
def _switch(self) -> Any | None:
|
||||
"""The AdaptiveSwitch this dial belongs to, if it is set up yet."""
|
||||
entry = self.hass.data.get(DOMAIN, {}).get(self._config_entry.entry_id, {})
|
||||
return entry.get(SWITCH_DOMAIN)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore the last value and push it to the switch."""
|
||||
await super().async_added_to_hass()
|
||||
last_state = await self.async_get_last_state()
|
||||
if last_state is not None:
|
||||
try:
|
||||
self._value = float(last_state.state)
|
||||
except (TypeError, ValueError):
|
||||
_LOGGER.debug(
|
||||
"%s: could not restore intensity from %s, using %s",
|
||||
self._attr_name,
|
||||
last_state.state,
|
||||
DEFAULT_INTENSITY,
|
||||
)
|
||||
# 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)
|
||||
|
|
@ -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. 🌅",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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. 🌅",
|
||||
|
|
|
|||
83
docs/advanced/intensity.md
Normal file
83
docs/advanced/intensity.md
Normal file
|
|
@ -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_<name>_intensity
|
||||
```
|
||||
|
||||
Set it like any other number:
|
||||
|
||||
```yaml
|
||||
service: number.set_value
|
||||
target:
|
||||
entity_id: number.adaptive_lighting_living_room_intensity
|
||||
data:
|
||||
value: 60
|
||||
```
|
||||
|
||||
The value is a percentage, and it interpolates:
|
||||
|
||||
```
|
||||
output = floor_value + (adaptive_value - floor_value) × intensity / 100
|
||||
```
|
||||
|
||||
| Intensity | Result |
|
||||
|-----------|--------|
|
||||
| `100` (default) | The adaptive values, unchanged. 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.
|
||||
|
|
@ -34,12 +34,13 @@ By automatically adapting the settings of your lights throughout the day, Adapti
|
|||
When initially turning on a light that is controlled by Adaptive Lighting, the `light.turn_on` service call is intercepted, and the light's brightness and color are automatically adjusted based on the sun's position.
|
||||
After that, the light's brightness and color are automatically adjusted at a regular interval.
|
||||
|
||||
Adaptive Lighting provides four switches (using "living_room" as an example component name):
|
||||
Adaptive Lighting provides four switches and a number entity (using "living_room" as an example component name):
|
||||
|
||||
- `switch.adaptive_lighting_living_room`: Turn Adaptive Lighting on or off and view current light settings through its attributes.
|
||||
- `switch.adaptive_lighting_sleep_mode_living_room`: Activate "sleep mode" 😴 and set custom sleep_brightness and sleep_color_temp.
|
||||
- `switch.adaptive_lighting_adapt_brightness_living_room`: Enable or disable brightness adaptation 🔆 for supported lights.
|
||||
- `switch.adaptive_lighting_adapt_color_living_room`: Enable or disable color adaptation 🌈 for supported lights.
|
||||
- `number.adaptive_lighting_living_room_intensity`: Scale 🎚️ how far the adaptive settings travel from their floor, from 100% (unchanged) down to 0%.
|
||||
|
||||
<!-- OUTPUT:END -->
|
||||
|
||||
|
|
@ -54,14 +55,15 @@ Adaptive Lighting provides four switches (using "living_room" as an example comp
|
|||
|
||||
## How It Works
|
||||
|
||||
Adaptive Lighting provides four switches for each configuration (using "living_room" as an example):
|
||||
Adaptive Lighting provides four switches and a number entity for each configuration (using "living_room" as an example):
|
||||
|
||||
| Switch | Purpose |
|
||||
| Entity | Purpose |
|
||||
|--------|---------|
|
||||
| `switch.adaptive_lighting_living_room` | Main on/off control |
|
||||
| `switch.adaptive_lighting_sleep_mode_living_room` | Activate sleep mode |
|
||||
| `switch.adaptive_lighting_adapt_brightness_living_room` | Enable/disable brightness adaptation |
|
||||
| `switch.adaptive_lighting_adapt_color_living_room` | Enable/disable color adaptation |
|
||||
| `number.adaptive_lighting_living_room_intensity` | [Scale the adaptive curve](advanced/intensity.md) towards its floor |
|
||||
|
||||
## Interactive Simulator
|
||||
|
||||
|
|
|
|||
248
tests/test_intensity.py
Normal file
248
tests/test_intensity.py
Normal file
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
] },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue