Skip to content

Commit

Permalink
feat: add different brightness ramping mechanisms
Browse files Browse the repository at this point in the history
  • Loading branch information
basnijholt committed Aug 4, 2023
1 parent 9a7cff9 commit 16a4f86
Show file tree
Hide file tree
Showing 7 changed files with 515 additions and 175 deletions.
70 changes: 37 additions & 33 deletions README.md

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions custom_components/adaptive_lighting/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -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),
(
Expand Down Expand Up @@ -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),
}


Expand Down
213 changes: 213 additions & 0 deletions custom_components/adaptive_lighting/helpers.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 4 additions & 1 deletion custom_components/adaptive_lighting/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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. ⏲️",
Expand Down
Loading

0 comments on commit 16a4f86

Please sign in to comment.