From 16a4f86c8a32fbdd19c7da816607bd61f0a74bf6 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 4 Aug 2023 15:22:05 -0700 Subject: [PATCH] feat: add different brightness ramping mechanisms --- README.md | 70 ++--- custom_components/adaptive_lighting/const.py | 40 +++ .../adaptive_lighting/helpers.py | 213 ++++++++++++++ .../adaptive_lighting/strings.json | 5 +- custom_components/adaptive_lighting/switch.py | 269 +++++++++--------- .../adaptive_lighting/translations/en.json | 5 +- tests/test_switch.py | 88 +++++- 7 files changed, 515 insertions(+), 175 deletions(-) create mode 100644 custom_components/adaptive_lighting/helpers.py diff --git a/README.md b/README.md index 963c4e9a5..c56a2b03e 100644 --- a/README.md +++ b/README.md @@ -96,39 +96,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 sunrise/sunset. The brightness starts changing at this many seconds before the sunset/sunrise πŸ“ˆπŸ“‰ | `1800` | `int` | +| `brightness_mode_time_light` | (Ignored if `brightness_mode='default'`) The duration (in seconds) to ramp up/down the brightness after sunrise/sunset. The brightness starts changing at this many seconds after the sunset/sunrise πŸ“ˆπŸ“‰. | `1800` | `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` | diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index f204e1ac3..344169631 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -141,6 +141,30 @@ 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", + 1800, +) +DOCS[CONF_BRIGHTNESS_MODE_TIME_DARK] = ( + "(Ignored if `brightness_mode='default'`) The duration (in seconds) to ramp up/down " + "the brightness before sunrise/sunset. The brightness starts changing at this many seconds " + "before the sunset/sunrise πŸ“ˆπŸ“‰" +) +CONF_BRIGHTNESS_MODE_TIME_LIGHT, DEFAULT_BRIGHTNESS_MODE_TIME_LIGHT = ( + "brightness_mode_time_light", + 1800, +) +DOCS[CONF_BRIGHTNESS_MODE_TIME_LIGHT] = ( + "(Ignored if `brightness_mode='default'`) The duration (in seconds) to ramp up/down " + "the brightness after sunrise/sunset. The brightness starts changing at this many seconds " + "after the sunset/sunrise πŸ“ˆπŸ“‰." +) + 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 +306,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 +359,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 000000000..17a0b6c67 --- /dev/null +++ b/custom_components/adaptive_lighting/helpers.py @@ -0,0 +1,213 @@ +"""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]: + """Find 'a' and 'b' for a scaled and shifted tanh function. + + Given two points (x1, y1) and (x2, y2), this function solves for 'a' and 'b' such that + the scaled and shifted tanh function passes through these points. The function is + defined as: y = 0.5 * (tanh(a * (x - b)) + 1) + + The steps to find 'a' and 'b' are as follows: + 1. Set up two equations based on the definition of the scaled and shifted tanh function: + y1 = 0.5 * (tanh(a * (x1 - b)) + 1) + y2 = 0.5 * (tanh(a * (x2 - b)) + 1) + 2. Rearrange these equations: + tanh(a * (x1 - b)) = 2*y1 - 1 + tanh(a * (x2 - b)) = 2*y2 - 1 + 3. Take the inverse tanh (or artanh) of both sides: + a * (x1 - b) = artanh(2*y1 - 1) + a * (x2 - b) = artanh(2*y2 - 1) + 4. Solve these linear equations to find the values of 'a' and 'b'. + + 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 + ------- + tuple + A tuple containing the values of 'a' and 'b'. + + Raises + ------ + ValueError + If 'y1' or 'y2' is not between 0 and 1. + """ + # Check the y values + if not (0 <= y1 <= 1) or not (0 <= y2 <= 1): + msg = "y1 and y2 should be between 0 and 1." + raise ValueError(msg) + + # Calculate the inverse tanh values + z1 = math.atanh(2 * y1 - 1) + z2 = math.atanh(2 * y2 - 1) + + # Solve the equations to find 'a' and 'b' + a = (z2 - z1) / (x2 - x1) + b = (x1 * z2 - x2 * z1) / (x1 - x2) + + 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 a942e66b9..922857689 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 sunrise/sunset. The brightness starts changing at this many seconds before the sunset/sunrise πŸ“ˆπŸ“‰", + "brightness_mode_time_light": "brightness_mode_time_light: (Ignored if `brightness_mode='default'`) The duration (in seconds) to ramp up/down the brightness after sunrise/sunset. The brightness starts changing at this many seconds after the sunset/sunrise πŸ“ˆπŸ“‰.", + "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 defe3b85d..a32406d5e 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 b27ee3c4e..51c39a1c9 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 sunrise/sunset. The brightness starts changing at this many seconds before the sunset/sunrise πŸ“ˆπŸ“‰", + "brightness_mode_time_light": "brightness_mode_time_light: (Ignored if `brightness_mode='default'`) The duration (in seconds) to ramp up/down the brightness after sunrise/sunset. The brightness starts changing at this many seconds after the sunset/sunrise πŸ“ˆπŸ“‰.", + "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 e3bc9126a..52177763c 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,82 @@ async def test_light_group( ] assert ":skpp:" in events[2].context.id assert len(events) == 3 + + +@pytest.mark.parametrize("brightness_mode", ["linear", "tanh"]) +async def test_brightness_mode(hass, brightness_mode): + """Test brightness mode. + + We are not testing the "default" mode because that is tested in all other tests. + """ + # only test symmetric case + dark = 1800 + light = 1800 + _, 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_mid = 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() + + # At sunset the brightness should be 50% + await patch_time_and_update(sunset) + assert is_approx_equal(switch._settings[ATTR_BRIGHTNESS_PCT], brightness_mid) + + # One hour 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) + + # One hour 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) + + # At sunrise the brightness should be 50% + await patch_time_and_update(sunrise) + assert is_approx_equal(switch._settings[ATTR_BRIGHTNESS_PCT], brightness_mid) + + # One hour 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) + + # One hour 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)