Skip to content

Commit

Permalink
Merge 9a06efe into 9a7cff9
Browse files Browse the repository at this point in the history
  • Loading branch information
basnijholt authored Aug 4, 2023
2 parents 9a7cff9 + 9a06efe commit e860f78
Show file tree
Hide file tree
Showing 7 changed files with 538 additions and 199 deletions.
69 changes: 36 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)
29 changes: 16 additions & 13 deletions custom_components/adaptive_lighting/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,36 +20,39 @@
"description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have this entry defined in YAML.",
"data": {
"lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟",
"interval": "interval: Frequency to adapt the lights, in seconds. 🔄",
"transition": "transition: Duration of transition when lights change, in seconds. 🕑",
"prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈",
"include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝",
"initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️",
"sleep_transition": "sleep_transition: Duration of transition when \"sleep mode\" is toggled in seconds. 😴",
"transition": "transition: Duration of transition when lights change, in seconds. 🕑",
"transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙",
"interval": "interval: Frequency to adapt the lights, in seconds. 🔄",
"min_brightness": "min_brightness: Minimum brightness percentage. 💡",
"max_brightness": "max_brightness: Maximum brightness percentage. 💡",
"min_color_temp": "min_color_temp: Warmest color temperature in Kelvin. 🔥",
"max_color_temp": "max_color_temp: Coldest color temperature in Kelvin. ❄️",
"prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈",
"sleep_brightness": "sleep_brightness: Brightness percentage of lights in sleep mode. 😴",
"sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp: Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙",
"sleep_color_temp": "sleep_color_temp: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴",
"sleep_rgb_color": "sleep_rgb_color: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈",
"sleep_transition": "sleep_transition: Duration of transition when \"sleep mode\" is toggled in seconds. 😴",
"transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙",
"sunrise_time": "sunrise_time: Set a fixed time (HH:MM:SS) for sunrise. 🌅",
"max_sunrise_time": "max_sunrise_time: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅",
"max_sunrise_time": "max_sunrise_time: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅",
"sunrise_offset": "sunrise_offset: Adjust sunrise time with a positive or negative offset in seconds. ⏰",
"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. 🌇",
"min_sunset_time": "min_sunset_time: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real 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. ⏲️",
"autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
"skip_redundant_commands": "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.",
"multi_light_intercept": "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.",
"include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝"
"multi_light_intercept": "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."
}
}
},
Expand Down Expand Up @@ -204,11 +207,11 @@
"name": "sunset_time"
},
"max_sunrise_time": {
"description": "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅",
"description": "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅",
"name": "max_sunrise_time"
},
"min_sunset_time": {
"description": "Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇",
"description": "Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇",
"name": "min_sunset_time"
},
"take_over_control": {
Expand Down
Loading

0 comments on commit e860f78

Please sign in to comment.