feat: add different brightness ramping mechanisms (#699)

* feat: add different brightness ramping mechanisms

* Rephrase

* Fix curves

* update images

* link to other graphs

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

View file

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

View file

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

View file

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

View file

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

View file

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