From a1cec19351426d42a8ba32c87ef5f4791bd6cbfa Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 5 Aug 2023 13:14:21 -0700 Subject: [PATCH] feat: add different brightness ramping mechanisms (#699) * feat: add different brightness ramping mechanisms * Rephrase * Fix curves * update images * link to other graphs * update images --- README.md | 97 ++++--- custom_components/adaptive_lighting/const.py | 38 +++ .../adaptive_lighting/helpers.py | 206 ++++++++++++++ .../adaptive_lighting/strings.json | 5 +- custom_components/adaptive_lighting/switch.py | 269 +++++++++--------- .../adaptive_lighting/translations/en.json | 5 +- tests/test_switch.py | 89 +++++- 7 files changed, 534 insertions(+), 175 deletions(-) create mode 100644 custom_components/adaptive_lighting/helpers.py diff --git a/README.md b/README.md index 963c4e9a..71500862 100644 --- a/README.md +++ b/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 -| 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` | @@ -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"` + +
+Enhance your control over brightness transitions during sunrise and sunset with brightness_mode (click here to learn more 🧠). + +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`. +
+ +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 πŸ“œ) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index f204e1ac..a8b53afd 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -141,6 +141,28 @@ 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 @@ def int_between(min_int, max_int): (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 @@ def timedelta_as_int(value): 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), } diff --git a/custom_components/adaptive_lighting/helpers.py b/custom_components/adaptive_lighting/helpers.py new file mode 100644 index 00000000..95c87735 --- /dev/null +++ b/custom_components/adaptive_lighting/helpers.py @@ -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) diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index a942e66b..0751500b 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -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. ⏲️", diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index defe3b85..a32406d5 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -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 @@ 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 @@ 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 @@ 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 @@ _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 @@ def _set_changeable_settings( 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 @@ async def prepare_adaptation_data( 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 @@ async def async_turn_off(self, **kwargs) -> None: # noqa: ARG002 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 @@ def calculate_noon_and_midnight( 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 @@ def calc_brightness_pct(self, percent: float, is_sleep: bool) -> float: """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.""" diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index b27ee3c4..511a11c8 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -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. ⏲️", diff --git a/tests/test_switch.py b/tests/test_switch.py index e3bc9126..1a25c7a1 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -60,6 +60,9 @@ 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)