mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-11 22:34:04 +02:00
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:
parent
9a7cff9d6a
commit
a1cec19351
7 changed files with 534 additions and 175 deletions
97
README.md
97
README.md
|
|
@ -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`
|
||||

|
||||
|
||||
### 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.
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
> [*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 📜)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
206
custom_components/adaptive_lighting/helpers.py
Normal file
206
custom_components/adaptive_lighting/helpers.py
Normal 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)
|
||||
|
|
@ -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. ⏲️",
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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. ⏲️",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue