feat: add different brightness ramping mechanisms (#699)

* feat: add different brightness ramping mechanisms

* Rephrase

* Fix curves

* update images

* link to other graphs

* update images
This commit is contained in:
Bas Nijholt 2023-08-05 13:14:21 -07:00 committed by GitHub
commit a1cec19351
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 534 additions and 175 deletions

View file

@ -66,6 +66,7 @@ The `adaptive_lighting.manual_control` event is fired when a light is marked as
- [:thermometer: Color Temperature](#thermometer-color-temperature)
- [:high_brightness: Brightness](#high_brightness-brightness)
- [While using `transition_until_sleep: true`](#while-using-transition_until_sleep-true)
- [Custom brightness ramps using `brightness_mode` with `"linear"` and `"tanh"`](#custom-brightness-ramps-using-brightness_mode-with-linear-and-tanh)
- [:eyes: See also](#eyes-see-also)
- [:busts_in_silhouette: Contributors](#busts_in_silhouette-contributors)
@ -96,39 +97,43 @@ The YAML and frontend configuration methods support all of the options listed be
<!-- START_OUTPUT -->
<!-- THIS CONTENT IS AUTOMATICALLY GENERATED -->
| 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` |
| `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` |
| `sunset_offset` | Adjust sunset time with a positive or negative offset in seconds. ⏰ | `0` | `int` |
| `take_over_control` | Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒 | `True` | `bool` |
| `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. 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` |
| `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` |
| `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. | `True` | `bool` |
| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` |
| 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` |
| `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` |
| `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` |
| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 | `False` | `bool` |
| `take_over_control` | Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒 | `True` | `bool` |
| `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. 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` |
| `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` |
| `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. | `True` | `bool` |
| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` |
<!-- END_OUTPUT -->
@ -400,6 +405,32 @@ These graphs were generated using the values calculated by the Adaptive Lighting
### While using `transition_until_sleep: true`
![image](https://user-images.githubusercontent.com/2219836/228949675-f9699624-8abc-466c-bb04-250ce0f495b8.png)
### Custom brightness ramps using `brightness_mode` with `"linear"` and `"tanh"`
<details>
<summary>Enhance your control over brightness transitions during sunrise and sunset with <code>brightness_mode</code> (click here to learn more 🧠).</summary>
With Adaptive Lighting, you can set a `brightness_mode` to specify how the brightness changes during sunrise and sunset. The `brightness_mode` can be set to `"default"` ([as illustrated in other graphs above](#high_brightness-brightness)), `"linear"`, or `"tanh"`. If you choose to deviate from the `"default"` mode, you can adjust `brightness_mode_time_dark` and `brightness_mode_time_light` to further customize the lighting transitions.
When `brightness_mode` is set to `"linear"`:
- During **_sunset_**, the brightness begins to gradually decrease from `max_brightness` starting at `time=sunset_time - brightness_mode_time_light`, until it reaches `min_brightness` at `time=sunset_time + brightness_mode_time_dark`.
- During **_sunrise_**, the brightness begins to gradually increase from `min_brightness` starting at `time=sunrise_time - brightness_mode_time_dark`, until it reaches `max_brightness` at `time=sunrise_time + brightness_mode_time_light`.
When `brightness_mode` is set to `"tanh"`, it uses the smooth transition of a [hyperbolic tangent function](https://mathworld.wolfram.com/HyperbolicTangent.html):
- During **_sunset_**, the brightness starts to decrease from 95% of `max_brightness` starting at `time=sunset_time - brightness_mode_time_light`, until it reaches 5% of `min_brightness` at `time=sunset_time + brightness_mode_time_dark`.
- During **_sunrise_**, the brightness starts to increase from 5% of `min_brightness` starting at `time=sunrise_time - brightness_mode_time_dark`, until it reaches 95% of `max_brightness` at `time=sunrise_time + brightness_mode_time_light`.
</details>
Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark` in the text box.
![image](https://github.com/basnijholt/adaptive-lighting/assets/6897215/15143580-13cd-4ab2-a603-89f2b7830afd)
![image](https://github.com/basnijholt/adaptive-lighting/assets/6897215/f61fdac9-6d47-48c9-84ed-cbb451d5de5d)
![image](https://github.com/basnijholt/adaptive-lighting/assets/6897215/e5fc5d27-3c37-4e3d-93d1-6e7cf4b48e7c)
![image](https://github.com/basnijholt/adaptive-lighting/assets/6897215/3dcbdc42-63c4-49df-8651-d2fae53dd08d)
> [*Code to make the plots*](https://github.com/basnijholt/adaptive-lighting/pull/699#issuecomment-1666232555)
## :eyes: See also
- [*Sleep better with Adaptive Lighting in Home Assistant*](https://wartner.io/sleep-better-with-adaptive-lightning-in-home-assistant/) by Florian Wartner on 2023-02-23 (blog post 📜)

View file

@ -141,6 +141,28 @@ DOCS[
CONF_MIN_SUNSET_TIME
] = "Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇"
CONF_BRIGHTNESS_MODE, DEFAULT_BRIGHTNESS_MODE = "brightness_mode", "default"
DOCS[CONF_BRIGHTNESS_MODE] = (
"Brightness mode to use. Possible values are `default`, `linear`, and `tanh` "
"(uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈"
)
CONF_BRIGHTNESS_MODE_TIME_DARK, DEFAULT_BRIGHTNESS_MODE_TIME_DARK = (
"brightness_mode_time_dark",
900,
)
DOCS[CONF_BRIGHTNESS_MODE_TIME_DARK] = (
"(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down "
"the brightness before/after sunrise/sunset. 📈📉"
)
CONF_BRIGHTNESS_MODE_TIME_LIGHT, DEFAULT_BRIGHTNESS_MODE_TIME_LIGHT = (
"brightness_mode_time_light",
3600,
)
DOCS[CONF_BRIGHTNESS_MODE_TIME_LIGHT] = (
"(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down "
"the brightness after/before sunrise/sunset. 📈📉."
)
CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True
DOCS[CONF_TAKE_OVER_CONTROL] = (
"Disable Adaptive Lighting if another source calls `light.turn_on` while lights "
@ -282,6 +304,20 @@ VALIDATION_TUPLES = [
(CONF_SUNSET_TIME, NONE_STR, str),
(CONF_MIN_SUNSET_TIME, NONE_STR, str),
(CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET, int),
(
CONF_BRIGHTNESS_MODE,
DEFAULT_BRIGHTNESS_MODE,
selector.SelectSelector(
selector.SelectSelectorConfig(
options=["default", "linear", "tanh"],
multiple=False,
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
),
(CONF_BRIGHTNESS_MODE_TIME_DARK, DEFAULT_BRIGHTNESS_MODE_TIME_DARK, int),
(CONF_BRIGHTNESS_MODE_TIME_LIGHT, DEFAULT_BRIGHTNESS_MODE_TIME_LIGHT, int),
(CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE, bool),
(CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool),
(CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool),
(
@ -321,6 +357,8 @@ EXTRA_VALIDATION = {
CONF_SUNSET_OFFSET: (cv.time_period, timedelta_as_int),
CONF_SUNSET_TIME: (cv.time, str),
CONF_MIN_SUNSET_TIME: (cv.time, str),
CONF_BRIGHTNESS_MODE_TIME_LIGHT: (cv.time_period, timedelta_as_int),
CONF_BRIGHTNESS_MODE_TIME_DARK: (cv.time_period, timedelta_as_int),
}

View file

@ -0,0 +1,206 @@
"""Helper functions for the Adaptive Lighting custom components."""
from __future__ import annotations
import base64
import colorsys
import logging
import math
from typing import cast
_LOGGER = logging.getLogger(__name__)
def clamp(value: float, minimum: float, maximum: float) -> float:
"""Clamp value between minimum and maximum."""
return max(minimum, min(value, maximum))
def find_a_b(x1: float, x2: float, y1: float, y2: float) -> tuple[float, float]:
"""Compute the values of 'a' and 'b' for a scaled and shifted tanh function.
Given two points (x1, y1) and (x2, y2), this function calculates the coefficients 'a' and 'b'
for a tanh function of the form y = 0.5 * (tanh(a * (x - b)) + 1) that passes through these points.
The derivation is as follows:
1. Start with the equation of the tanh function:
y = 0.5 * (tanh(a * (x - b)) + 1)
2. Rearrange the equation to isolate tanh:
tanh(a * (x - b)) = 2*y - 1
3. Take the inverse tanh (or artanh) on both sides to solve for 'a' and 'b':
a * (x - b) = artanh(2*y - 1)
4. Plug in the points (x1, y1) and (x2, y2) to get two equations.
Using these, we can solve for 'a' and 'b' as:
a = (artanh(2*y2 - 1) - artanh(2*y1 - 1)) / (x2 - x1)
b = x1 - (artanh(2*y1 - 1) / a)
Parameters
----------
x1
x-coordinate of the first point.
x2
x-coordinate of the second point.
y1
y-coordinate of the first point (should be between 0 and 1).
y2
y-coordinate of the second point (should be between 0 and 1).
Returns
-------
a
Coefficient 'a' for the tanh function.
b
Coefficient 'b' for the tanh function.
Notes
-----
The values of y1 and y2 should lie between 0 and 1, inclusive.
"""
a = (math.atanh(2 * y2 - 1) - math.atanh(2 * y1 - 1)) / (x2 - x1)
b = x1 - (math.atanh(2 * y1 - 1) / a)
return a, b
def scaled_tanh(
x: float,
a: float,
b: float,
y_min: float = 0.0,
y_max: float = 100.0,
) -> float:
"""Apply a scaled and shifted tanh function to a given input.
This function represents a transformation of the tanh function that scales and shifts
the output to lie between y_min and y_max. For values of 'x' close to 'x1' and 'x2'
(used to calculate 'a' and 'b'), the output of this function will be close to 'y_min'
and 'y_max', respectively.
The equation of the function is as follows:
y = y_min + (y_max - y_min) * 0.5 * (tanh(a * (x - b)) + 1)
Parameters
----------
x
The input to the function.
a
The scale factor for the tanh function, found using 'find_a_b' function.
b
The shift factor for the tanh function, found using 'find_a_b' function.
y_min
The minimum value of the output range. Defaults to 0.
y_max
The maximum value of the output range. Defaults to 100.
Returns
-------
float: The output of the function, which lies in the range [y_min, y_max].
"""
return y_min + (y_max - y_min) * 0.5 * (math.tanh(a * (x - b)) + 1)
def lerp_color_hsv(
rgb1: tuple[float, float, float],
rgb2: tuple[float, float, float],
t: float,
) -> tuple[int, int, int]:
"""Linearly interpolate between two RGB colors in HSV color space."""
t = abs(t)
assert 0 <= t <= 1
# Convert RGB to HSV
hsv1 = colorsys.rgb_to_hsv(*[x / 255.0 for x in rgb1])
hsv2 = colorsys.rgb_to_hsv(*[x / 255.0 for x in rgb2])
# Linear interpolation in HSV space
hsv = (
hsv1[0] + t * (hsv2[0] - hsv1[0]),
hsv1[1] + t * (hsv2[1] - hsv1[1]),
hsv1[2] + t * (hsv2[2] - hsv1[2]),
)
# Convert back to RGB
rgb = tuple(int(round(x * 255)) for x in colorsys.hsv_to_rgb(*hsv))
assert all(0 <= x <= 255 for x in rgb), f"Invalid RGB color: {rgb}"
return cast(tuple[int, int, int], rgb)
def lerp(x, x1, x2, y1, y2):
"""Linearly interpolate between two values."""
return y1 + (x - x1) * (y2 - y1) / (x2 - x1)
def int_to_base36(num: int) -> str:
"""Convert an integer to its base-36 representation using numbers and uppercase letters.
Base-36 encoding uses digits 0-9 and uppercase letters A-Z, providing a case-insensitive
alphanumeric representation. The function takes an integer `num` as input and returns
its base-36 representation as a string.
Parameters
----------
num
The integer to convert to base-36.
Returns
-------
str
The base-36 representation of the input integer.
Examples
--------
>>> num = 123456
>>> base36_num = int_to_base36(num)
>>> print(base36_num)
'2N9'
"""
alphanumeric_chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if num == 0:
return alphanumeric_chars[0]
base36_str = ""
base = len(alphanumeric_chars)
while num:
num, remainder = divmod(num, base)
base36_str = alphanumeric_chars[remainder] + base36_str
return base36_str
def short_hash(string: str, length: int = 4) -> str:
"""Create a hash of 'string' with length 'length'."""
return base64.b32encode(string.encode()).decode("utf-8").zfill(length)[:length]
def remove_vowels(input_str: str, length: int = 4) -> str:
"""Remove vowels from a string and return a string of length 'length'."""
vowels = "aeiouAEIOU"
output_str = "".join([char for char in input_str if char not in vowels])
return output_str.zfill(length)[:length]
def color_difference_redmean(
rgb1: tuple[float, float, float],
rgb2: tuple[float, float, float],
) -> float:
"""Distance between colors in RGB space (redmean metric).
The maximal distance between (255, 255, 255) and (0, 0, 0) 765.
Sources:
- https://en.wikipedia.org/wiki/Color_difference#Euclidean
- https://www.compuphase.com/cmetric.htm
"""
r_hat = (rgb1[0] + rgb2[0]) / 2
delta_r, delta_g, delta_b = (
(col1 - col2) for col1, col2 in zip(rgb1, rgb2, strict=True)
)
red_term = (2 + r_hat / 256) * delta_r**2
green_term = 4 * delta_g**2
blue_term = (2 + (255 - r_hat) / 256) * delta_b**2
return math.sqrt(red_term + green_term + blue_term)

View file

@ -40,10 +40,13 @@
"sunset_time": "sunset_time: Set a fixed time (HH:MM:SS) for sunset. 🌇",
"min_sunset_time": "min_sunset_time: Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇",
"sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰",
"brightness_mode": "brightness_mode: Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈",
"brightness_mode_time_dark": "brightness_mode_time_dark: (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉",
"brightness_mode_time_light": "brightness_mode_time_light: (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.",
"only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄",
"take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒",
"detect_non_ha_changes": "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. Disable this feature if you encounter such issues.",
"autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
"only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄",
"separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
"send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
"adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️",

View file

@ -2,9 +2,7 @@
from __future__ import annotations
import asyncio
import base64
import bisect
import colorsys
import datetime
import functools
import logging
@ -12,7 +10,7 @@ import math
from copy import deepcopy
from dataclasses import dataclass
from datetime import timedelta
from typing import TYPE_CHECKING, Any, Literal, cast
from typing import TYPE_CHECKING, Any, Literal
import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util
@ -108,6 +106,9 @@ from .const import (
CONF_ADAPT_DELAY,
CONF_ADAPT_UNTIL_SLEEP,
CONF_AUTORESET_CONTROL,
CONF_BRIGHTNESS_MODE,
CONF_BRIGHTNESS_MODE_TIME_DARK,
CONF_BRIGHTNESS_MODE_TIME_LIGHT,
CONF_DETECT_NON_HA_CHANGES,
CONF_INCLUDE_CONFIG_IN_ATTRIBUTES,
CONF_INITIAL_TRANSITION,
@ -158,6 +159,17 @@ from .const import (
replace_none_str,
)
from .hass_utils import setup_service_call_interceptor
from .helpers import (
clamp,
color_difference_redmean,
find_a_b,
int_to_base36,
lerp,
lerp_color_hsv,
remove_vowels,
scaled_tanh,
short_hash,
)
if TYPE_CHECKING:
from collections.abc import Callable, Coroutine, Iterable
@ -195,56 +207,6 @@ RGB_REDMEAN_CHANGE = 80 # ≈10% of total range
_DOMAIN_SHORT = "al"
def _int_to_base36(num: int) -> str:
"""Convert an integer to its base-36 representation using numbers and uppercase letters.
Base-36 encoding uses digits 0-9 and uppercase letters A-Z, providing a case-insensitive
alphanumeric representation. The function takes an integer `num` as input and returns
its base-36 representation as a string.
Parameters
----------
num
The integer to convert to base-36.
Returns
-------
str
The base-36 representation of the input integer.
Examples
--------
>>> num = 123456
>>> base36_num = int_to_base36(num)
>>> print(base36_num)
'2N9'
"""
alphanumeric_chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if num == 0:
return alphanumeric_chars[0]
base36_str = ""
base = len(alphanumeric_chars)
while num:
num, remainder = divmod(num, base)
base36_str = alphanumeric_chars[remainder] + base36_str
return base36_str
def _short_hash(string: str, length: int = 4) -> str:
"""Create a hash of 'string' with length 'length'."""
return base64.b32encode(string.encode()).decode("utf-8").zfill(length)[:length]
def _remove_vowels(input_str: str, length: int = 4) -> str:
vowels = "aeiouAEIOU"
output_str = "".join([char for char in input_str if char not in vowels])
return output_str.zfill(length)[:length]
def create_context(
name: str,
which: str,
@ -257,11 +219,11 @@ def create_context(
# Pack index with base85 to maximize the number of contexts we can create
# before we exceed the 26-character limit and are forced to wrap.
time_stamp = ulid_transform.ulid_now()[:10] # time part of a ULID
name_hash = _short_hash(name)
which_short = _remove_vowels(which)
name_hash = short_hash(name)
which_short = remove_vowels(which)
context_id_start = f"{time_stamp}:{_DOMAIN_SHORT}:{name_hash}:{which_short}:"
chars_left = 26 - len(context_id_start)
index_packed = _int_to_base36(index).zfill(chars_left)[-chars_left:]
index_packed = int_to_base36(index).zfill(chars_left)[-chars_left:]
context_id = context_id_start + index_packed
parent_id = parent.id if parent else None
return Context(id=context_id, parent_id=parent_id)
@ -277,7 +239,7 @@ def is_our_context_id(context_id: str | None, which: str | None = None) -> bool:
return False
if which is None:
return True
return f":{_remove_vowels(which)}:" in context_id
return f":{remove_vowels(which)}:" in context_id
def is_our_context(context: Context | None, which: str | None = None) -> bool:
@ -716,28 +678,6 @@ def _supported_features(hass: HomeAssistant, light: str) -> set[str]:
return supported
def color_difference_redmean(
rgb1: tuple[float, float, float],
rgb2: tuple[float, float, float],
) -> float:
"""Distance between colors in RGB space (redmean metric).
The maximal distance between (255, 255, 255) and (0, 0, 0) 765.
Sources:
- https://en.wikipedia.org/wiki/Color_difference#Euclidean
- https://www.compuphase.com/cmetric.htm
"""
r_hat = (rgb1[0] + rgb2[0]) / 2
delta_r, delta_g, delta_b = (
(col1 - col2) for col1, col2 in zip(rgb1, rgb2, strict=True)
)
red_term = (2 + r_hat / 256) * delta_r**2
green_term = 4 * delta_g**2
blue_term = (2 + (255 - r_hat) / 256) * delta_b**2
return math.sqrt(red_term + green_term + blue_term)
# All comparisons should be done with RGB since
# converting anything to color temp is inaccurate.
def _convert_attributes(attributes: dict[str, Any]) -> dict[str, Any]:
@ -968,6 +908,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
sunset_offset=data[CONF_SUNSET_OFFSET],
sunset_time=data[CONF_SUNSET_TIME],
min_sunset_time=data[CONF_MIN_SUNSET_TIME],
brightness_mode=data[CONF_BRIGHTNESS_MODE],
brightness_mode_time_dark=data[CONF_BRIGHTNESS_MODE_TIME_DARK],
brightness_mode_time_light=data[CONF_BRIGHTNESS_MODE_TIME_LIGHT],
transition=data[CONF_TRANSITION],
)
_LOGGER.debug(
@ -1251,7 +1194,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
min_kelvin = attributes["min_color_temp_kelvin"]
max_kelvin = attributes["max_color_temp_kelvin"]
color_temp_kelvin = self._settings["color_temp_kelvin"]
color_temp_kelvin = max(min(color_temp_kelvin, max_kelvin), min_kelvin)
color_temp_kelvin = clamp(color_temp_kelvin, min_kelvin, max_kelvin)
service_data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin
elif "color" in features and adapt_color:
_LOGGER.debug("%s: Setting rgb_color of light %s", self._name, light)
@ -1614,32 +1557,6 @@ class SimpleSwitch(SwitchEntity, RestoreEntity):
self._state = False
def lerp_color_hsv(
rgb1: tuple[float, float, float],
rgb2: tuple[float, float, float],
t: float,
) -> tuple[int, int, int]:
"""Linearly interpolate between two RGB colors in HSV color space."""
t = abs(t)
assert 0 <= t <= 1
# Convert RGB to HSV
hsv1 = colorsys.rgb_to_hsv(*[x / 255.0 for x in rgb1])
hsv2 = colorsys.rgb_to_hsv(*[x / 255.0 for x in rgb2])
# Linear interpolation in HSV space
hsv = (
hsv1[0] + t * (hsv2[0] - hsv1[0]),
hsv1[1] + t * (hsv2[1] - hsv1[1]),
hsv1[2] + t * (hsv2[2] - hsv1[2]),
)
# Convert back to RGB
rgb = tuple(int(round(x * 255)) for x in colorsys.hsv_to_rgb(*hsv))
assert all(0 <= x <= 255 for x in rgb), f"Invalid RGB color: {rgb}"
return cast(tuple[int, int, int], rgb)
@dataclass(frozen=True)
class SunLightSettings:
"""Track the state of the sun and associated light settings."""
@ -1661,18 +1578,47 @@ class SunLightSettings:
sunset_offset: datetime.timedelta | None
sunset_time: datetime.time | None
min_sunset_time: datetime.time | None
brightness_mode: Literal["default", "linear", "tanh"]
brightness_mode_time_dark: datetime.timedelta | None
brightness_mode_time_light: datetime.timedelta | None
transition: int
def sunrise(self, date: datetime.datetime) -> datetime.datetime:
"""Return the (adjusted) sunrise time for the given date."""
sunrise = (
self.astral_location.sunrise(date, local=False)
if self.sunrise_time is None
else self._replace_time(date, "sunrise")
) + self.sunrise_offset
if self.max_sunrise_time is not None:
max_sunrise = self._replace_time(date, "max_sunrise")
if max_sunrise < sunrise:
sunrise = max_sunrise
return sunrise
def sunset(self, date: datetime.datetime) -> datetime.datetime:
"""Return the (adjusted) sunset time for the given date."""
sunset = (
self.astral_location.sunset(date, local=False)
if self.sunset_time is None
else self._replace_time(date, "sunset")
) + self.sunset_offset
if self.min_sunset_time is not None:
min_sunset = self._replace_time(date, "min_sunset")
if min_sunset > sunset:
sunset = min_sunset
return sunset
def _replace_time(self, date: datetime.datetime, key: str) -> datetime.datetime:
time = getattr(self, f"{key}_time")
date_time = datetime.datetime.combine(date, time)
return date_time.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(
dt_util.UTC,
)
def get_sun_events(self, date: datetime.datetime) -> list[tuple[str, float]]:
"""Get the four sun event's timestamps at 'date'."""
def _replace_time(date: datetime.datetime, key: str) -> datetime.datetime:
time = getattr(self, f"{key}_time")
date_time = datetime.datetime.combine(date, time)
return date_time.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(
dt_util.UTC,
)
def calculate_noon_and_midnight(
sunset: datetime.datetime,
sunrise: datetime.datetime,
@ -1689,27 +1635,8 @@ class SunLightSettings:
return noon, midnight
location = self.astral_location
sunrise = (
location.sunrise(date, local=False)
if self.sunrise_time is None
else _replace_time(date, "sunrise")
) + self.sunrise_offset
sunset = (
location.sunset(date, local=False)
if self.sunset_time is None
else _replace_time(date, "sunset")
) + self.sunset_offset
if self.max_sunrise_time is not None:
max_sunrise = _replace_time(date, "max_sunrise")
if max_sunrise < sunrise:
sunrise = max_sunrise
if self.min_sunset_time is not None:
min_sunset = _replace_time(date, "min_sunset")
if min_sunset > sunset:
sunset = min_sunset
sunrise = self.sunrise(date)
sunset = self.sunset(date)
if (
self.sunrise_time is None
@ -1774,11 +1701,75 @@ class SunLightSettings:
"""Calculate the brightness in %."""
if is_sleep:
return self.sleep_brightness
if percent > 0:
return self.max_brightness
delta_brightness = self.max_brightness - self.min_brightness
percent = 1 + percent
return (delta_brightness * percent) + self.min_brightness
assert self.brightness_mode in ("default", "linear", "tanh")
if self.brightness_mode == "default":
if percent > 0:
return self.max_brightness
delta_brightness = self.max_brightness - self.min_brightness
percent = 1 + percent
return (delta_brightness * percent) + self.min_brightness
now = dt_util.utcnow()
(prev_event, prev_ts), (next_event, next_ts) = self.relevant_events(now)
# at ts_event - dt_start, brightness == start_brightness
# at ts_event + dt_end, brightness == end_brightness
dark = (self.brightness_mode_time_dark or timedelta()).total_seconds()
light = (self.brightness_mode_time_light or timedelta()).total_seconds()
# Handle sunrise
if prev_event == SUN_EVENT_SUNRISE or next_event == SUN_EVENT_SUNRISE:
ts_event = prev_ts if prev_event == SUN_EVENT_SUNRISE else next_ts
if self.brightness_mode == "linear":
brightness = lerp(
now.timestamp(),
x1=ts_event - dark,
x2=ts_event + light,
y1=self.min_brightness,
y2=self.max_brightness,
)
else:
assert self.brightness_mode == "tanh"
a, b = find_a_b(
x1=-dark,
x2=+light,
y1=0.05, # be at 5% of range at x1
y2=0.95, # be at 95% of range at x2
)
brightness = scaled_tanh(
now.timestamp() - ts_event,
a=a,
b=b,
y_min=self.min_brightness,
y_max=self.max_brightness,
)
# Handle sunset
elif prev_event == SUN_EVENT_SUNSET or next_event == SUN_EVENT_SUNSET:
ts_event = prev_ts if prev_event == SUN_EVENT_SUNSET else next_ts
if self.brightness_mode == "linear":
brightness = lerp(
now.timestamp(),
x1=ts_event - light,
x2=ts_event + dark,
y1=self.max_brightness,
y2=self.min_brightness,
)
else:
assert self.brightness_mode == "tanh"
a, b = find_a_b(
x1=-light, # shifted timestamp for the start of sunset
x2=+dark, # shifted timestamp for the end of sunset
y1=0.95, # be at 95% of range at the start of sunset
y2=0.05, # be at 5% of range at the end of sunset
)
brightness = scaled_tanh(
now.timestamp() - ts_event,
a=a,
b=b,
y_min=self.min_brightness,
y_max=self.max_brightness,
)
return clamp(brightness, self.min_brightness, self.max_brightness)
def calc_color_temp_kelvin(self, percent: float) -> int:
"""Calculate the color temperature in Kelvin."""

View file

@ -41,10 +41,13 @@
"sunset_time": "sunset_time: Set a fixed time (HH:MM:SS) for sunset. 🌇",
"min_sunset_time": "min_sunset_time: Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇",
"sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰",
"brightness_mode": "brightness_mode: Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈",
"brightness_mode_time_dark": "brightness_mode_time_dark: (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉",
"brightness_mode_time_light": "brightness_mode_time_light: (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.",
"only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄",
"take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒",
"detect_non_ha_changes": "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. Disable this feature if you encounter such issues.",
"autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
"only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄",
"separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
"send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
"adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️",

View file

@ -60,6 +60,9 @@ from custom_components.adaptive_lighting.const import (
ATTR_ADAPTIVE_LIGHTING_MANAGER,
CONF_ADAPT_UNTIL_SLEEP,
CONF_AUTORESET_CONTROL,
CONF_BRIGHTNESS_MODE,
CONF_BRIGHTNESS_MODE_TIME_DARK,
CONF_BRIGHTNESS_MODE_TIME_LIGHT,
CONF_DETECT_NON_HA_CHANGES,
CONF_INITIAL_TRANSITION,
CONF_MANUAL_CONTROL,
@ -364,7 +367,11 @@ async def test_adaptive_lighting_time_zones_with_default_settings(
@pytest.mark.parametrize("lat,long,timezone", LAT_LONG_TZS)
async def test_adaptive_lighting_time_zones_and_sun_settings(
hass, lat, long, timezone, reset_time_zone # pylint: disable=redefined-outer-name
hass,
lat,
long,
timezone,
reset_time_zone, # pylint: disable=redefined-outer-name
):
"""Test setting up the Adaptive Lighting switches with different timezones.
@ -1992,3 +1999,83 @@ async def test_light_group(
]
assert ":skpp:" in events[2].context.id
assert len(events) == 3
@pytest.mark.parametrize("brightness_mode", ["linear", "tanh"])
@pytest.mark.parametrize("dark,light", ([900, 1800], [1800, 900], [1800, 1800]))
async def test_brightness_mode(hass, brightness_mode, dark, light):
"""Test brightness mode.
We are not testing the "default" mode because that is tested in all other tests.
"""
is_symmetric = dark == light
_, switch = await setup_switch(
hass,
{
CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour),
CONF_SUNSET_TIME: datetime.time(SUNSET.hour),
CONF_BRIGHTNESS_MODE: brightness_mode,
CONF_BRIGHTNESS_MODE_TIME_DARK: datetime.timedelta(seconds=dark),
CONF_BRIGHTNESS_MODE_TIME_LIGHT: datetime.timedelta(seconds=light),
},
)
context = switch.create_context("test") # needs to be passed to update method
min_brightness = switch._sun_light_settings.min_brightness
max_brightness = switch._sun_light_settings.max_brightness
brightness_range = max_brightness - min_brightness
brightness_event = min_brightness + brightness_range / 2
dark = switch._sun_light_settings.brightness_mode_time_dark
light = switch._sun_light_settings.brightness_mode_time_light
sunset = SUNSET.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(dt_util.UTC)
before_sunset = sunset - light
after_sunset = sunset + dark
sunrise = SUNRISE.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(dt_util.UTC)
before_sunrise = sunrise - dark
after_sunrise = sunrise + light
light_brightness = (
max_brightness
if brightness_mode == "linear"
else 0.95 * brightness_range + min_brightness
)
dark_brightness = (
min_brightness
if brightness_mode == "linear"
else 0.05 * brightness_range + min_brightness
)
def is_approx_equal(a, b):
return abs(a - b) < 0.01
async def patch_time_and_update(time):
with patch("homeassistant.util.dt.utcnow", return_value=time):
await switch._update_attrs_and_maybe_adapt_lights(context=context)
await hass.async_block_till_done()
if is_symmetric:
# At sunset the brightness should be 50%
await patch_time_and_update(sunset)
assert is_approx_equal(switch._settings[ATTR_BRIGHTNESS_PCT], brightness_event)
# Before sunset the brightness should be max
await patch_time_and_update(before_sunset)
assert is_approx_equal(switch._settings[ATTR_BRIGHTNESS_PCT], light_brightness)
# After sunset the brightness should be dark_brightness
await patch_time_and_update(after_sunset)
assert is_approx_equal(switch._settings[ATTR_BRIGHTNESS_PCT], dark_brightness)
if is_symmetric:
# At sunrise the brightness should be 50%
await patch_time_and_update(sunrise)
assert is_approx_equal(switch._settings[ATTR_BRIGHTNESS_PCT], brightness_event)
# Before sunrise the brightness should be min
await patch_time_and_update(before_sunrise)
assert is_approx_equal(switch._settings[ATTR_BRIGHTNESS_PCT], dark_brightness)
# After sunrise the brightness should be light_brightness
await patch_time_and_update(after_sunrise)
assert is_approx_equal(switch._settings[ATTR_BRIGHTNESS_PCT], light_brightness)