mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-15 16:24: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
|
|
@ -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. ⏲️",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue