* Split up SunLightSettings

* Renames

* factor out SunEvents

* more renames

* rewrite

* rewrite more

* simpler

* refactor

* refact

* raise

* refact

* rename

* move method

* clean

* Move to new module 'sun.py'

* make sun independent of HA

* rename

* Move to webapp/homeassistant_util_color.py

* Rework app

* Add link

* new plotting

* app changes

* fix tests

* test clean

* tz fixes

* fix

* use sed

* verbose

* fix tz

* fix

* tiem
This commit is contained in:
Bas Nijholt 2023-08-08 14:31:17 -07:00 committed by GitHub
commit f8e7880a96
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1745 additions and 625 deletions

View file

@ -45,6 +45,9 @@ jobs:
- name: Build the WebAssembly app
run: |
set -ex
cp custom_components/adaptive_lighting/color_and_brightness.py webapp/color_and_brightness.py
sed -i 's/homeassistant.util.color/homeassistant_util_color/g' "webapp/color_and_brightness.py"
shinylive export webapp site
- name: Setup Pages

View file

@ -27,6 +27,7 @@ ignore = [
"tests/*.py" = ["ALL"]
".github/*py" = ["INP001"]
"webapp/*py" = ["ALL"]
"custom_components/adaptive_lighting/homeassistant_util_color.py" = ["ALL"]
[flake8-pytest-style]
fixture-parentheses = false

View file

@ -441,7 +441,7 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark
![image](https://github.com/basnijholt/adaptive-lighting/assets/6897215/e5fc5d27-3c37-4e3d-93d1-6e7cf4b48e7c)
![image](https://github.com/basnijholt/adaptive-lighting/assets/6897215/3dcbdc42-63c4-49df-8651-d2fae53dd08d)
> [*Code to make the plots*](https://github.com/basnijholt/adaptive-lighting/pull/699#issuecomment-1666232555)
> Check out the interactive webapp on https://basnijholt.github.io/adaptive-lighting/ to play with the parameters and see how the brightness changes!
## :eyes: See also

View file

@ -0,0 +1,518 @@
"""Switch for the Adaptive Lighting integration."""
from __future__ import annotations
import bisect
import colorsys
import datetime
import logging
import math
from dataclasses import dataclass
from datetime import timedelta
from functools import cached_property, partial
from typing import TYPE_CHECKING, Any, Literal, cast
from homeassistant.util.color import (
color_RGB_to_xy,
color_temperature_to_rgb,
color_xy_to_hs,
)
if TYPE_CHECKING:
import astral
# Same as homeassistant.const.SUN_EVENT_SUNRISE and homeassistant.const.SUN_EVENT_SUNSET
# We re-define them here to not depend on homeassistant in this file.
SUN_EVENT_SUNRISE = "sunrise"
SUN_EVENT_SUNSET = "sunset"
SUN_EVENT_NOON = "solar_noon"
SUN_EVENT_MIDNIGHT = "solar_midnight"
_ORDER = (SUN_EVENT_SUNRISE, SUN_EVENT_NOON, SUN_EVENT_SUNSET, SUN_EVENT_MIDNIGHT)
_ALLOWED_ORDERS = {_ORDER[i:] + _ORDER[:i] for i in range(len(_ORDER))}
UTC = datetime.timezone.utc
utcnow: partial[datetime.datetime] = partial(datetime.datetime.now, UTC)
utcnow.__doc__ = "Get now in UTC time."
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True)
class SunEvents:
"""Track the state of the sun and associated light settings."""
name: str
astral_location: astral.Location
sunrise_time: datetime.time | None
min_sunrise_time: datetime.time | None
max_sunrise_time: datetime.time | None
sunset_time: datetime.time | None
min_sunset_time: datetime.time | None
max_sunset_time: datetime.time | None
sunrise_offset: datetime.timedelta = datetime.timedelta()
sunset_offset: datetime.timedelta = datetime.timedelta()
timezone: datetime.tzinfo = UTC
def sunrise(self, dt: datetime.date) -> datetime.datetime:
"""Return the (adjusted) sunrise time for the given datetime."""
sunrise = (
self.astral_location.sunrise(dt, local=False)
if self.sunrise_time is None
else self._replace_time(dt, self.sunrise_time)
) + self.sunrise_offset
if self.min_sunrise_time is not None:
min_sunrise = self._replace_time(dt, self.min_sunrise_time)
if min_sunrise > sunrise:
sunrise = min_sunrise
if self.max_sunrise_time is not None:
max_sunrise = self._replace_time(dt, self.max_sunrise_time)
if max_sunrise < sunrise:
sunrise = max_sunrise
return sunrise
def sunset(self, dt: datetime.date) -> datetime.datetime:
"""Return the (adjusted) sunset time for the given datetime."""
sunset = (
self.astral_location.sunset(dt, local=False)
if self.sunset_time is None
else self._replace_time(dt, self.sunset_time)
) + self.sunset_offset
if self.min_sunset_time is not None:
min_sunset = self._replace_time(dt, self.min_sunset_time)
if min_sunset > sunset:
sunset = min_sunset
if self.max_sunset_time is not None:
max_sunset = self._replace_time(dt, self.max_sunset_time)
if max_sunset < sunset:
sunset = max_sunset
return sunset
def _replace_time(
self,
dt: datetime.date,
time: datetime.time,
) -> datetime.datetime:
date_time = datetime.datetime.combine(dt, time)
dt_with_tz = date_time.replace(tzinfo=self.timezone)
return dt_with_tz.astimezone(UTC)
def noon_and_midnight(
self,
dt: datetime.datetime,
sunset: datetime.datetime | None = None,
sunrise: datetime.datetime | None = None,
) -> tuple[datetime.datetime, datetime.datetime]:
"""Return the (adjusted) noon and midnight times for the given datetime."""
if (
self.sunrise_time is None
and self.sunset_time is None
and self.min_sunrise_time is None
and self.max_sunrise_time is None
and self.min_sunset_time is None
and self.max_sunset_time is None
):
solar_noon = self.astral_location.noon(dt, local=False)
solar_midnight = self.astral_location.midnight(dt, local=False)
return solar_noon, solar_midnight
if sunset is None:
sunset = self.sunset(dt)
if sunrise is None:
sunrise = self.sunrise(dt)
middle = abs(sunset - sunrise) / 2
if sunset > sunrise:
noon = sunrise + middle
midnight = noon + timedelta(hours=12) * (1 if noon.hour < 12 else -1)
else:
midnight = sunset + middle
noon = midnight + timedelta(hours=12) * (1 if midnight.hour < 12 else -1)
return noon, midnight
def sun_events(self, dt: datetime.datetime) -> list[tuple[str, float]]:
"""Get the four sun event's timestamps at 'dt'."""
sunrise = self.sunrise(dt)
sunset = self.sunset(dt)
solar_noon, solar_midnight = self.noon_and_midnight(dt, sunset, sunrise)
events = [
(SUN_EVENT_SUNRISE, sunrise.timestamp()),
(SUN_EVENT_SUNSET, sunset.timestamp()),
(SUN_EVENT_NOON, solar_noon.timestamp()),
(SUN_EVENT_MIDNIGHT, solar_midnight.timestamp()),
]
self._validate_sun_event_order(events)
return events
def _validate_sun_event_order(self, events: list[tuple[str, float]]) -> None:
"""Check if the sun events are in the expected order."""
events = sorted(events, key=lambda x: x[1])
events_names, _ = zip(*events, strict=True)
if events_names not in _ALLOWED_ORDERS:
msg = (
f"{self.name}: The sun events {events_names} are not in the expected"
" order. The Adaptive Lighting integration will not work!"
" This might happen if your sunrise/sunset offset is too large or"
" your manually set sunrise/sunset time is past/before noon/midnight."
)
_LOGGER.error(msg)
raise ValueError(msg)
def prev_and_next_events(self, dt: datetime.datetime) -> list[tuple[str, float]]:
"""Get the previous and next sun event."""
events = [
event
for days in [-1, 0, 1]
for event in self.sun_events(dt + timedelta(days=days))
]
events = sorted(events, key=lambda x: x[1])
i_now = bisect.bisect([ts for _, ts in events], dt.timestamp())
return events[i_now - 1 : i_now + 1]
def sun_position(self, dt: datetime.datetime) -> float:
"""Calculate the position of the sun, between [-1, 1]."""
target_ts = dt.timestamp()
(_, prev_ts), (next_event, next_ts) = self.prev_and_next_events(dt)
h, x = (
(prev_ts, next_ts)
if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE)
else (next_ts, prev_ts)
)
# k = -1 between sunset and sunrise (sun below horizon)
# k = 1 between sunrise and sunset (sun above horizon)
k = 1 if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_NOON) else -1
return k * (1 - ((target_ts - h) / (h - x)) ** 2)
def closest_event(self, dt: datetime.datetime) -> tuple[str, float]:
"""Get the closest sunset or sunrise event."""
(prev_event, prev_ts), (next_event, next_ts) = self.prev_and_next_events(dt)
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
return SUN_EVENT_SUNRISE, ts_event
if prev_event == SUN_EVENT_SUNSET or next_event == SUN_EVENT_SUNSET:
ts_event = prev_ts if prev_event == SUN_EVENT_SUNSET else next_ts
return SUN_EVENT_SUNSET, ts_event
msg = "No sunrise or sunset event found."
raise ValueError(msg)
@dataclass(frozen=True)
class SunLightSettings:
"""Track the state of the sun and associated light settings."""
name: str
astral_location: astral.Location
adapt_until_sleep: bool
max_brightness: int
max_color_temp: int
min_brightness: int
min_color_temp: int
sleep_brightness: int
sleep_rgb_or_color_temp: Literal["color_temp", "rgb_color"]
sleep_color_temp: int
sleep_rgb_color: tuple[int, int, int]
sunrise_time: datetime.time | None
min_sunrise_time: datetime.time | None
max_sunrise_time: datetime.time | None
sunset_time: datetime.time | None
min_sunset_time: datetime.time | None
max_sunset_time: datetime.time | None
brightness_mode_time_dark: datetime.timedelta
brightness_mode_time_light: datetime.timedelta
brightness_mode: Literal["default", "linear", "tanh"] = "default"
sunrise_offset: datetime.timedelta = datetime.timedelta()
sunset_offset: datetime.timedelta = datetime.timedelta()
timezone: datetime.tzinfo = UTC
@cached_property
def sun(self) -> SunEvents:
"""Return the SunEvents object."""
return SunEvents(
name=self.name,
astral_location=self.astral_location,
sunrise_time=self.sunrise_time,
sunrise_offset=self.sunrise_offset,
min_sunrise_time=self.min_sunrise_time,
max_sunrise_time=self.max_sunrise_time,
sunset_time=self.sunset_time,
sunset_offset=self.sunset_offset,
min_sunset_time=self.min_sunset_time,
max_sunset_time=self.max_sunset_time,
timezone=self.timezone,
)
def _brightness_pct_default(self, dt: datetime.datetime) -> float:
"""Calculate the brightness percentage using the default method."""
sun_position = self.sun.sun_position(dt)
if sun_position > 0:
return self.max_brightness
delta_brightness = self.max_brightness - self.min_brightness
return (delta_brightness * (1 + sun_position)) + self.min_brightness
def _brightness_pct_tanh(self, dt: datetime.datetime) -> float:
event, ts_event = self.sun.closest_event(dt)
dark = self.brightness_mode_time_dark.total_seconds()
light = self.brightness_mode_time_light.total_seconds()
if event == SUN_EVENT_SUNRISE:
brightness = scaled_tanh(
dt.timestamp() - ts_event,
x1=-dark,
x2=+light,
y1=0.05, # be at 5% of range at x1
y2=0.95, # be at 95% of range at x2
y_min=self.min_brightness,
y_max=self.max_brightness,
)
elif event == SUN_EVENT_SUNSET:
brightness = scaled_tanh(
dt.timestamp() - ts_event,
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
y_min=self.min_brightness,
y_max=self.max_brightness,
)
return clamp(brightness, self.min_brightness, self.max_brightness)
def _brightness_pct_linear(self, dt: datetime.datetime) -> float:
event, ts_event = self.sun.closest_event(dt)
# at ts_event - dt_start, brightness == start_brightness
# at ts_event + dt_end, brightness == end_brightness
dark = self.brightness_mode_time_dark.total_seconds()
light = self.brightness_mode_time_light.total_seconds()
if event == SUN_EVENT_SUNRISE:
brightness = lerp(
dt.timestamp() - ts_event,
x1=-dark,
x2=+light,
y1=self.min_brightness,
y2=self.max_brightness,
)
elif event == SUN_EVENT_SUNSET:
brightness = lerp(
dt.timestamp() - ts_event,
x1=-light,
x2=+dark,
y1=self.max_brightness,
y2=self.min_brightness,
)
return clamp(brightness, self.min_brightness, self.max_brightness)
def brightness_pct(self, dt: datetime.datetime, is_sleep: bool) -> float:
"""Calculate the brightness in %."""
if is_sleep:
return self.sleep_brightness
assert self.brightness_mode in ("default", "linear", "tanh")
if self.brightness_mode == "default":
return self._brightness_pct_default(dt)
if self.brightness_mode == "linear":
return self._brightness_pct_linear(dt)
if self.brightness_mode == "tanh":
return self._brightness_pct_tanh(dt)
return None
def color_temp_kelvin(self, sun_position: float) -> int:
"""Calculate the color temperature in Kelvin."""
if sun_position > 0:
delta = self.max_color_temp - self.min_color_temp
ct = (delta * sun_position) + self.min_color_temp
return 5 * round(ct / 5) # round to nearest 5
if sun_position == 0 or not self.adapt_until_sleep:
return self.min_color_temp
if self.adapt_until_sleep and sun_position < 0:
delta = abs(self.min_color_temp - self.sleep_color_temp)
ct = (delta * abs(1 + sun_position)) + self.sleep_color_temp
return 5 * round(ct / 5) # round to nearest 5
msg = "Should not happen"
raise ValueError(msg)
def brightness_and_color(
self,
dt: datetime.datetime,
is_sleep: bool,
) -> dict[str, Any]:
"""Calculate the brightness and color."""
sun_position = self.sun.sun_position(dt)
rgb_color: tuple[float, float, float]
# Variable `force_rgb_color` is needed for RGB color after sunset (if enabled)
force_rgb_color = False
brightness_pct = self.brightness_pct(dt, is_sleep)
if is_sleep:
color_temp_kelvin = self.sleep_color_temp
rgb_color = self.sleep_rgb_color
elif (
self.sleep_rgb_or_color_temp == "rgb_color"
and self.adapt_until_sleep
and sun_position < 0
):
# Feature requested in
# https://github.com/basnijholt/adaptive-lighting/issues/624
# This will result in a perceptible jump in color at sunset and sunrise
# because the `color_temperature_to_rgb` function is not 100% accurate.
min_color_rgb = color_temperature_to_rgb(self.min_color_temp)
rgb_color = lerp_color_hsv(
min_color_rgb,
self.sleep_rgb_color,
sun_position,
)
color_temp_kelvin = self.color_temp_kelvin(sun_position)
force_rgb_color = True
else:
color_temp_kelvin = self.color_temp_kelvin(sun_position)
rgb_color = color_temperature_to_rgb(color_temp_kelvin)
# backwards compatibility for versions < 1.3.1 - see #403
color_temp_mired: float = math.floor(1000000 / color_temp_kelvin)
xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color)
hs_color: tuple[float, float] = color_xy_to_hs(*xy_color)
return {
"brightness_pct": brightness_pct,
"color_temp_kelvin": color_temp_kelvin,
"color_temp_mired": color_temp_mired,
"rgb_color": rgb_color,
"xy_color": xy_color,
"hs_color": hs_color,
"sun_position": sun_position,
"force_rgb_color": force_rgb_color,
}
def get_settings(
self,
is_sleep,
transition,
) -> dict[str, float | int | tuple[float, float] | tuple[float, float, float]]:
"""Get all light settings.
Calculating all values takes <0.5ms.
"""
dt = utcnow() + timedelta(seconds=transition or 0)
return self.brightness_and_color(dt, is_sleep)
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,
x1: float,
x2: float,
y1: float = 0.05,
y2: float = 0.95,
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.
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). Defaults to 0.05.
y2
y-coordinate of the second point (should be between 0 and 1). Defaults to 0.95.
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].
"""
a, b = find_a_b(x1, x2, y1, y2)
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 clamp(value: float, minimum: float, maximum: float) -> float:
"""Clamp value between minimum and maximum."""
return max(minimum, min(value, maximum))

View file

@ -12,8 +12,6 @@ ICON_COLOR_TEMP = "mdi:sun-thermometer"
ICON_SLEEP = "mdi:sleep"
DOMAIN = "adaptive_lighting"
SUN_EVENT_NOON = "solar_noon"
SUN_EVENT_MIDNIGHT = "solar_midnight"
DOCS = {CONF_ENTITY_ID: "Entity ID of the switch. 📝"}

View file

@ -3,12 +3,7 @@
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:
@ -16,123 +11,6 @@ def clamp(value: float, minimum: float, maximum: float) -> float:
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.

View file

@ -2,12 +2,10 @@
from __future__ import annotations
import asyncio
import bisect
import datetime
import logging
import math
import zoneinfo
from copy import deepcopy
from dataclasses import dataclass
from datetime import timedelta
from typing import TYPE_CHECKING, Any, Literal
@ -60,8 +58,6 @@ from homeassistant.const import (
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
SUN_EVENT_SUNRISE,
SUN_EVENT_SUNSET,
)
from homeassistant.core import (
CALLBACK_TYPE,
@ -83,9 +79,7 @@ from homeassistant.helpers.template import area_entities
from homeassistant.loader import bind_hass
from homeassistant.util import slugify
from homeassistant.util.color import (
color_RGB_to_xy,
color_temperature_to_rgb,
color_xy_to_hs,
color_xy_to_RGB,
)
@ -96,6 +90,7 @@ from .adaptation_utils import (
ServiceData,
prepare_adaptation_data,
)
from .color_and_brightness import SunLightSettings
from .const import (
ADAPT_BRIGHTNESS_SWITCH,
ADAPT_COLOR_SWITCH,
@ -153,8 +148,6 @@ from .const import (
SERVICE_SET_MANUAL_CONTROL,
SET_MANUAL_CONTROL_SCHEMA,
SLEEP_MODE_SWITCH,
SUN_EVENT_MIDNIGHT,
SUN_EVENT_NOON,
TURNING_OFF_DELAY,
VALIDATION_TUPLES,
apply_service_schema,
@ -164,19 +157,14 @@ 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
import astral
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -187,8 +175,6 @@ _SUPPORT_OPTS = {
"transition": SUPPORT_TRANSITION,
}
_ORDER = (SUN_EVENT_SUNRISE, SUN_EVENT_NOON, SUN_EVENT_SUNSET, SUN_EVENT_MIDNIGHT)
_ALLOWED_ORDERS = {_ORDER[i:] + _ORDER[:i] for i in range(len(_ORDER))}
_LOGGER = logging.getLogger(__name__)
@ -923,7 +909,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
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],
timezone=zoneinfo.ZoneInfo(self.hass.config.time_zone),
)
_LOGGER.debug(
"%s: Set switch settings for lights '%s'. now using data: '%s'",
@ -1595,300 +1581,6 @@ class SimpleSwitch(SwitchEntity, RestoreEntity):
self._state = False
@dataclass(frozen=True)
class SunLightSettings:
"""Track the state of the sun and associated light settings."""
name: str
astral_location: astral.Location
adapt_until_sleep: bool
max_brightness: int
max_color_temp: int
min_brightness: int
min_color_temp: int
sleep_brightness: int
sleep_rgb_or_color_temp: Literal["color_temp", "rgb_color"]
sleep_color_temp: int
sleep_rgb_color: tuple[int, int, int]
sunrise_time: datetime.time | None
sunrise_offset: datetime.timedelta | None
min_sunrise_time: datetime.time | None
max_sunrise_time: datetime.time | None
sunset_time: datetime.time | None
sunset_offset: datetime.timedelta | None
min_sunset_time: datetime.time | None
max_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.min_sunrise_time is not None:
min_sunrise = self._replace_time(date, "min_sunrise")
if min_sunrise > sunrise:
sunrise = min_sunrise
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
if self.max_sunset_time is not None:
max_sunset = self._replace_time(date, "max_sunset")
if max_sunset < sunset:
sunset = max_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 calculate_noon_and_midnight(
sunset: datetime.datetime,
sunrise: datetime.datetime,
) -> tuple[datetime.datetime, datetime.datetime]:
middle = abs(sunset - sunrise) / 2
if sunset > sunrise:
noon = sunrise + middle
midnight = noon + timedelta(hours=12) * (1 if noon.hour < 12 else -1)
else:
midnight = sunset + middle
noon = midnight + timedelta(hours=12) * (
1 if midnight.hour < 12 else -1
)
return noon, midnight
location = self.astral_location
sunrise = self.sunrise(date)
sunset = self.sunset(date)
if (
self.sunrise_time is None
and self.sunset_time is None
and self.min_sunrise_time is None
and self.max_sunrise_time is None
and self.min_sunset_time is None
and self.max_sunset_time is None
):
solar_noon = location.noon(date, local=False)
solar_midnight = location.midnight(date, local=False)
else:
solar_noon, solar_midnight = calculate_noon_and_midnight(sunset, sunrise)
events = [
(SUN_EVENT_SUNRISE, sunrise.timestamp()),
(SUN_EVENT_SUNSET, sunset.timestamp()),
(SUN_EVENT_NOON, solar_noon.timestamp()),
(SUN_EVENT_MIDNIGHT, solar_midnight.timestamp()),
]
# Check whether order is correct
events = sorted(events, key=lambda x: x[1])
events_names, _ = zip(*events, strict=True)
if events_names not in _ALLOWED_ORDERS:
msg = (
f"{self.name}: The sun events {events_names} are not in the expected"
" order. The Adaptive Lighting integration will not work!"
" This might happen if your sunrise/sunset offset is too large or"
" your manually set sunrise/sunset time is past/before noon/midnight."
)
_LOGGER.error(msg)
raise ValueError(msg)
return events
def relevant_events(self, now: datetime.datetime) -> list[tuple[str, float]]:
"""Get the previous and next sun event."""
events = [
event
for days in [-1, 0, 1]
for event in self.get_sun_events(now + timedelta(days=days))
]
events = sorted(events, key=lambda x: x[1])
i_now = bisect.bisect([ts for _, ts in events], now.timestamp())
return events[i_now - 1 : i_now + 1]
def calc_percent(self, transition: int) -> float:
"""Calculate the position of the sun in %."""
now = dt_util.utcnow()
target_time = now + timedelta(seconds=transition)
target_ts = target_time.timestamp()
today = self.relevant_events(target_time)
(_, prev_ts), (next_event, next_ts) = today
h, x = ( # pylint: disable=invalid-name
(prev_ts, next_ts)
if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE)
else (next_ts, prev_ts)
)
k = 1 if next_event in (SUN_EVENT_SUNSET, SUN_EVENT_NOON) else -1
return (0 - k) * ((target_ts - h) / (h - x)) ** 2 + k
def calc_brightness_pct(self, percent: float, is_sleep: bool) -> float:
"""Calculate the brightness in %."""
if is_sleep:
return self.sleep_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."""
if percent > 0:
delta = self.max_color_temp - self.min_color_temp
ct = (delta * percent) + self.min_color_temp
return 5 * round(ct / 5) # round to nearest 5
if percent == 0 or not self.adapt_until_sleep:
return self.min_color_temp
if self.adapt_until_sleep and percent < 0:
delta = abs(self.min_color_temp - self.sleep_color_temp)
ct = (delta * abs(1 + percent)) + self.sleep_color_temp
return 5 * round(ct / 5) # round to nearest 5
msg = "Should not happen"
raise ValueError(msg)
def get_settings(
self,
is_sleep,
transition,
) -> dict[str, float | int | tuple[float, float] | tuple[float, float, float]]:
"""Get all light settings.
Calculating all values takes <0.5ms.
"""
percent = (
self.calc_percent(transition)
if transition is not None
else self.calc_percent(0)
)
rgb_color: tuple[float, float, float]
# Variable `force_rgb_color` is needed for RGB color after sunset (if enabled)
force_rgb_color = False
brightness_pct = self.calc_brightness_pct(percent, is_sleep)
if is_sleep:
color_temp_kelvin = self.sleep_color_temp
rgb_color = self.sleep_rgb_color
elif (
self.sleep_rgb_or_color_temp == "rgb_color"
and self.adapt_until_sleep
and percent < 0
):
# Feature requested in
# https://github.com/basnijholt/adaptive-lighting/issues/624
# This will result in a perceptible jump in color at sunset and sunrise
# because the `color_temperature_to_rgb` function is not 100% accurate.
min_color_rgb = color_temperature_to_rgb(self.min_color_temp)
rgb_color = lerp_color_hsv(min_color_rgb, self.sleep_rgb_color, percent)
color_temp_kelvin = self.calc_color_temp_kelvin(percent)
force_rgb_color = True
else:
color_temp_kelvin = self.calc_color_temp_kelvin(percent)
rgb_color = color_temperature_to_rgb(color_temp_kelvin)
# backwards compatibility for versions < 1.3.1 - see #403
color_temp_mired: float = math.floor(1000000 / color_temp_kelvin)
xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color)
hs_color: tuple[float, float] = color_xy_to_hs(*xy_color)
return {
"brightness_pct": brightness_pct,
"color_temp_kelvin": color_temp_kelvin,
"color_temp_mired": color_temp_mired,
"rgb_color": rgb_color,
"xy_color": xy_color,
"hs_color": hs_color,
"sun_position": percent,
"force_rgb_color": force_rgb_color,
}
class AdaptiveLightingManager:
"""Track 'light.turn_off' and 'light.turn_on' service calls."""

View file

@ -0,0 +1,209 @@
import pytest
from custom_components.adaptive_lighting.color_and_brightness import (
SunEvents,
SUN_EVENT_SUNRISE,
SUN_EVENT_NOON,
)
import datetime as dt
from astral import LocationInfo
from astral.location import Location
import zoneinfo
# Create a mock astral_location object
location = Location(LocationInfo())
LAT_LONG_TZS = [
(52.379189, 4.899431, "Europe/Amsterdam"),
(32.87336, -117.22743, "US/Pacific"),
(60, 50, "GMT"),
(60, 50, "UTC"),
]
@pytest.fixture(params=LAT_LONG_TZS)
def tzinfo_and_location(request):
lat, long, timezone = request.param
tzinfo = zoneinfo.ZoneInfo(timezone)
location = Location(
LocationInfo(
name="name",
region="region",
timezone=timezone,
latitude=lat,
longitude=long,
)
)
return tzinfo, location
def test_replace_time(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=None,
min_sunset_time=None,
max_sunset_time=None,
timezone=tzinfo,
)
new_time = dt.time(5, 30)
datetime = dt.datetime(2022, 1, 1)
replaced_time_utc = sun_events._replace_time(datetime.date(), new_time)
assert replaced_time_utc.astimezone(tzinfo).time() == new_time
def test_sunrise_without_offset(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=None,
min_sunset_time=None,
max_sunset_time=None,
timezone=tzinfo,
)
date = dt.datetime(2022, 1, 1).date()
result = sun_events.sunrise(date)
assert result == location.sunrise(date)
def test_sun_position_no_fixed_sunset_and_sunrise(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=None,
min_sunset_time=None,
max_sunset_time=None,
timezone=tzinfo,
)
date = dt.datetime(2022, 1, 1).date()
sunset = location.sunset(date)
position = sun_events.sun_position(sunset)
assert position == 0
sunrise = location.sunrise(date)
position = sun_events.sun_position(sunrise)
assert position == 0
noon = location.noon(date)
position = sun_events.sun_position(noon)
assert position == 1
midnight = location.midnight(date)
position = sun_events.sun_position(midnight)
assert position == -1
def test_sun_position_fixed_sunset_and_sunrise(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
sunrise_time=dt.time(6, 0),
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=dt.time(18, 0),
min_sunset_time=None,
max_sunset_time=None,
timezone=tzinfo,
)
date = dt.datetime(2022, 1, 1).date()
sunset = sun_events.sunset(date)
position = sun_events.sun_position(sunset)
assert position == 0
sunrise = sun_events.sunrise(date)
position = sun_events.sun_position(sunrise)
assert position == 0
noon, midnight = sun_events.noon_and_midnight(date)
position = sun_events.sun_position(noon)
assert position == 1
position = sun_events.sun_position(midnight)
assert position == -1
def test_noon_and_midnight(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=None,
min_sunset_time=None,
max_sunset_time=None,
timezone=tzinfo,
)
date = dt.datetime(2022, 1, 1)
noon, midnight = sun_events.noon_and_midnight(date)
assert noon == location.noon(date)
assert midnight == location.midnight(date)
def test_sun_events(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=None,
min_sunset_time=None,
max_sunset_time=None,
timezone=tzinfo,
)
date = dt.datetime(2022, 1, 1)
events = sun_events.sun_events(date)
assert len(events) == 4
assert (SUN_EVENT_SUNRISE, location.sunrise(date).timestamp()) in events
def test_prev_and_next_events(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=None,
min_sunset_time=None,
max_sunset_time=None,
timezone=tzinfo,
)
datetime = dt.datetime(2022, 1, 1, 10, 0)
after_sunrise = sun_events.sunrise(datetime.date()) + dt.timedelta(hours=1)
prev_event, next_event = sun_events.prev_and_next_events(after_sunrise)
assert prev_event[0] == SUN_EVENT_SUNRISE
assert next_event[0] == SUN_EVENT_NOON
def test_closest_event(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=None,
min_sunset_time=None,
max_sunset_time=None,
timezone=tzinfo,
)
datetime = dt.datetime(2022, 1, 1, 6, 0)
sunrise = sun_events.sunrise(datetime.date())
event_name, ts = sun_events.closest_event(sunrise)
assert event_name == SUN_EVENT_SUNRISE
assert ts == location.sunrise(sunrise.date()).timestamp()

View file

@ -100,8 +100,8 @@ from custom_components.adaptive_lighting.switch import (
AdaptiveLightingManager,
is_our_context,
is_our_context_id,
lerp_color_hsv,
)
from custom_components.adaptive_lighting.color_and_brightness import lerp_color_hsv
_LOGGER = logging.getLogger(__name__)
@ -394,6 +394,7 @@ async def test_adaptive_lighting_time_zones_and_sun_settings(
min_color_temp = switch._sun_light_settings.min_color_temp
sunset = SUNSET.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(dt_util.UTC)
before_sunset = sunset - datetime.timedelta(hours=1)
after_sunset = sunset + datetime.timedelta(hours=1)
sunrise = SUNRISE.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(dt_util.UTC)
@ -401,7 +402,10 @@ async def test_adaptive_lighting_time_zones_and_sun_settings(
after_sunrise = sunrise + datetime.timedelta(hours=1)
async def patch_time_and_update(time):
with patch("homeassistant.util.dt.utcnow", return_value=time):
with patch(
"custom_components.adaptive_lighting.color_and_brightness.utcnow",
return_value=time,
):
await switch._update_attrs_and_maybe_adapt_lights(context=context)
await hass.async_block_till_done()
@ -490,7 +494,10 @@ async def test_light_settings(hass):
context = switch.create_context("test") # needs to be passed to update method
async def patch_time_and_get_updated_states(time):
with patch("homeassistant.util.dt.utcnow", return_value=time):
with patch(
"custom_components.adaptive_lighting.color_and_brightness.utcnow",
return_value=time,
):
await switch._update_attrs_and_maybe_adapt_lights(
context=context, transition=0, force=True
)
@ -1818,7 +1825,10 @@ async def test_adapt_until_sleep_and_rgb_colors(hass):
after_sunrise = sunrise + datetime.timedelta(hours=1)
async def patch_time_and_update(time):
with patch("homeassistant.util.dt.utcnow", return_value=time):
with patch(
"custom_components.adaptive_lighting.color_and_brightness.utcnow",
return_value=time,
):
await switch._update_attrs_and_maybe_adapt_lights(context=context)
await hass.async_block_till_done()
@ -2072,7 +2082,10 @@ async def test_brightness_mode(hass, brightness_mode, dark, light):
return abs(a - b) < 0.01
async def patch_time_and_update(time):
with patch("homeassistant.util.dt.utcnow", return_value=time):
with patch(
"custom_components.adaptive_lighting.color_and_brightness.utcnow",
return_value=time,
):
await switch._update_attrs_and_maybe_adapt_lights(context=context)
await hass.async_block_till_done()

View file

@ -1,206 +1,181 @@
"""Simple web app to visualize brightness over time."""
import math
import matplotlib.pyplot as plt
import numpy as np
from shiny import App, render, ui
from pathlib import Path
from contextlib import suppress
import datetime as dt
from astral import LocationInfo
from astral.location import Location
def lerp(x, x1, x2, y1, y2):
"""Linearly interpolate between two values."""
return y1 + (x - x1) * (y2 - y1) / (x2 - x1)
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]:
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 = 1.0,
) -> float:
"""Apply a scaled and shifted tanh function to a given input."""
return y_min + (y_max - y_min) * 0.5 * (math.tanh(a * (x - b)) + 1)
def is_closer_to_sunrise_than_sunset(time, sunrise_time, sunset_time):
"""Return True if the time is closer to sunrise than sunset."""
return abs(time - sunrise_time) < abs(time - sunset_time)
def brightness_linear(
time,
sunrise_time,
sunset_time,
time_light,
time_dark,
max_brightness,
min_brightness,
):
"""Calculate the brightness for the 'linear' mode."""
closer_to_sunrise = is_closer_to_sunrise_than_sunset(
time,
sunrise_time,
sunset_time,
def date_range(tzinfo):
start_of_day = dt.datetime.now(tzinfo).replace(
hour=0, minute=0, second=0, microsecond=0
)
if closer_to_sunrise:
brightness = lerp(
time,
x1=sunrise_time - time_dark,
x2=sunrise_time + time_light,
y1=min_brightness,
y2=max_brightness,
)
else:
brightness = lerp(
time,
x1=sunset_time - time_light,
x2=sunset_time + time_dark,
y1=max_brightness,
y2=min_brightness,
)
return clamp(brightness, min_brightness, max_brightness)
# one second before the next day
end_of_day = start_of_day + dt.timedelta(days=1) - dt.timedelta(seconds=1)
hours_range = [start_of_day]
while hours_range[-1] < end_of_day:
hours_range.append(hours_range[-1] + dt.timedelta(minutes=5))
return hours_range[:-1]
def brightness_tanh(
time,
sunrise_time,
sunset_time,
time_light,
time_dark,
max_brightness,
min_brightness,
):
"""Calculate the brightness for the 'tanh' mode."""
closer_to_sunrise = is_closer_to_sunrise_than_sunset(
time,
sunrise_time,
sunset_time,
)
if closer_to_sunrise:
a, b = find_a_b(
x1=-time_dark,
x2=time_light,
y1=0.05, # be at 5% of range at x1
y2=0.95, # be at 95% of range at x2
def copy_color_and_brightness_module():
with suppress(Exception):
webapp_folder = Path(__file__).parent.absolute()
module = (
webapp_folder.parent
/ "custom_components"
/ "adaptive_lighting"
/ "color_and_brightness.py"
)
brightness = scaled_tanh(
time - sunrise_time,
a=a,
b=b,
y_min=min_brightness,
y_max=max_brightness,
)
else:
a, b = find_a_b(
x1=-time_light, # shifted timestamp for the start of sunset
x2=time_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(
time - sunset_time,
a=a,
b=b,
y_min=min_brightness,
y_max=max_brightness,
)
return clamp(brightness, min_brightness, max_brightness)
new_module = webapp_folder / module.name
with module.open() as f:
lines = [
line.replace("homeassistant.util.color", "homeassistant_util_color")
for line in f.readlines()
]
with new_module.open("r") as f:
existing_lines = f.readlines()
if existing_lines != lines:
with new_module.open("w") as f:
f.writelines(lines)
def plot_brightness(
min_brightness,
max_brightness,
brightness_mode_time_dark,
brightness_mode_time_light,
sunrise_time=6, # 6 AM
sunset_time=18, # 6 PM
):
copy_color_and_brightness_module()
from color_and_brightness import SunLightSettings
def plot_brightness(kw, sleep_mode: bool):
# Define the time range for our simulation
time_range = np.linspace(0, 24, 1000) # From 0 to 24 hours
# Calculate the brightness for each time in the time range for both modes
sun_linear = SunLightSettings(**kw, brightness_mode="linear")
sun_tanh = SunLightSettings(**kw, brightness_mode="tanh")
sun = SunLightSettings(**kw, brightness_mode="default")
# Calculate the brightness for each time in the time range for all modes
dt_range = date_range(sun.timezone)
time_range = [time_to_float(dt) for dt in dt_range]
brightness_linear_values = [
brightness_linear(
time,
sunrise_time,
sunset_time,
brightness_mode_time_light,
brightness_mode_time_dark,
max_brightness,
min_brightness,
)
for time in time_range
sun_linear.brightness_pct(dt, sleep_mode) for dt in dt_range
]
brightness_tanh_values = [
brightness_tanh(
time,
sunrise_time,
sunset_time,
brightness_mode_time_light,
brightness_mode_time_dark,
max_brightness,
min_brightness,
)
for time in time_range
sun_tanh.brightness_pct(dt, sleep_mode) for dt in dt_range
]
brightness_default_values = [sun.brightness_pct(dt, sleep_mode) for dt in dt_range]
# Plot the brightness over time for both modes
plt.figure(figsize=(10, 6))
plt.plot(time_range, brightness_linear_values, label="Linear Mode")
plt.plot(time_range, brightness_tanh_values, label="Tanh Mode")
plt.vlines(sunrise_time, 0, 1, color="C2", label="Sunrise", linestyles="dashed")
plt.vlines(sunset_time, 0, 1, color="C3", label="Sunset", linestyles="dashed")
plt.xlim(0, 24)
plt.xticks(np.arange(0, 25, 1))
yticks = np.arange(0, 1.05, 0.05)
ytick_labels = [f"{100*label:.0f}%" for label in yticks]
plt.yticks(yticks, ytick_labels)
plt.xlabel("Time (hours)")
plt.ylabel("Brightness")
plt.title("Brightness over Time for Different Modes")
fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(time_range, brightness_linear_values, label="Linear Mode")
ax.plot(time_range, brightness_tanh_values, label="Tanh Mode")
ax.plot(time_range, brightness_default_values, label="Default Mode")
sunrise_time = sun.sun.sunrise(dt.date.today())
sunset_time = sun.sun.sunset(dt.date.today())
ax.vlines(
time_to_float(sunrise_time),
0,
100,
color="C2",
label="Sunrise",
linestyles="dashed",
)
ax.vlines(
time_to_float(sunset_time),
0,
100,
color="C3",
label="Sunset",
linestyles="dashed",
)
ax.set_xlim(0, 24)
ax.set_xticks(np.arange(0, 25, 1))
yticks = np.arange(0, 105, 5)
ytick_labels = [f"{label:.0f}%" for label in yticks]
ax.set_yticks(yticks, ytick_labels)
ax.set_xlabel("Time (hours)")
ax.set_ylabel("Brightness")
ax.set_title("Brightness over Time for Different Modes")
# Add text box
textstr = "\n".join(
(
f"Sunrise Time = {sunrise_time}:00:00",
f"Sunset Time = {sunset_time}:00:00",
f"Max Brightness = {max_brightness*100:.0f}%",
f"Min Brightness = {min_brightness*100:.0f}%",
f"Time Light = {brightness_mode_time_light:.1f} hours",
f"Time Dark = {brightness_mode_time_dark:.1f} hours",
f"Sunrise Time = {sunrise_time.time()}",
f"Sunset Time = {sunset_time.time()}",
f"Max Brightness = {sun.max_brightness:.0f}%",
f"Min Brightness = {sun.min_brightness:.0f}%",
f"Time Light = {sun.brightness_mode_time_light}",
f"Time Dark = {sun.brightness_mode_time_dark}",
),
)
# these are matplotlib.patch.Patch properties
props = {"boxstyle": "round", "facecolor": "wheat", "alpha": 0.5}
ax.legend()
ax.grid(True)
plt.legend()
plt.grid(True)
# place a text box in upper left in axes coords
plt.gca().text(
ax.text(
0.4,
0.55,
textstr,
transform=plt.gca().transAxes,
transform=ax.transAxes,
fontsize=10,
verticalalignment="center",
bbox=props,
bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.5},
)
return plt.gcf()
return fig
def plot_color_temp(kw, sleep_mode: bool):
sun = SunLightSettings(**kw, brightness_mode="default")
dt_range = date_range(tzinfo=sun.timezone)
time_range = [time_to_float(dt) for dt in dt_range]
settings = [sun.brightness_and_color(dt, sleep_mode) for dt in dt_range]
color_temp_values = (
np.array([(*setting["rgb_color"], 255) for setting in settings]) / 255
)
color_temp_values = color_temp_values.reshape(-1, 1, 4)
sun_position = [setting["sun_position"] for setting in settings]
fig, ax = plt.subplots(figsize=(10, 6))
# Display as a horizontal bar
ax.imshow(
np.rot90(color_temp_values)[:, ::1],
aspect="auto",
extent=[0, 24, -1, 1],
origin="upper",
)
# Plot a curve on top of the imshow
ax.plot(time_range, sun_position, color="k", label="Sun Position")
sunrise_time = sun.sun.sunrise(dt.date.today())
sunset_time = sun.sun.sunset(dt.date.today())
ax.vlines(
time_to_float(sunrise_time),
-1,
1,
color="C2",
label="Sunrise",
linestyles="dashed",
)
ax.vlines(
time_to_float(sunset_time),
-1,
1,
color="C3",
label="Sunset",
linestyles="dashed",
)
ax.set_xlim(0, 24)
ax.set_xticks(np.arange(0, 25, 1))
yticks = np.arange(-1, 1.1, 0.1)
ax.set_yticks(yticks, [f"{label*100:.0f}%" for label in yticks])
ax.set_xlabel("Time (hours)")
ax.legend()
ax.set_ylabel("Sun position (%)")
ax.set_title("RGB Color Intensity over Time")
return fig
SEC_PER_HR = 60 * 60
@ -225,20 +200,34 @@ app_ui = ui.page_fluid(
ui.panel_title("🌞 Adaptive Lighting Simulator WebApp 🌛"),
ui.layout_sidebar(
ui.panel_sidebar(
ui.input_slider("min_brightness", "min_brightness", 0, 100, 30, post="%"),
ui.input_slider("max_brightness", "max_brightness", 0, 100, 100, post="%"),
ui.input_switch("adapt_until_sleep", "adapt_until_sleep", False),
ui.input_switch("sleep_mode", "sleep_mode", False),
ui.input_slider("min_brightness", "min_brightness", 1, 100, 30, post="%"),
ui.input_slider("max_brightness", "max_brightness", 1, 100, 100, post="%"),
ui.input_numeric("min_color_temp", "min_color_temp", 2000),
ui.input_numeric("max_color_temp", "max_color_temp", 6666),
ui.input_slider(
"sleep_brightness", "sleep_brightness", 1, 100, 1, post="%"
),
ui.input_radio_buttons(
"sleep_rgb_or_color_temp",
"sleep_rgb_or_color_temp",
["rgb_color", "color_temp"],
),
ui.input_numeric("sleep_color_temp", "sleep_color_temp", 2000),
ui.input_text("sleep_rgb_color", "sleep_rgb_color", "255,0,0"),
ui.input_slider(
"dark_time",
"brightness_mode_time_dark",
0,
"brightness_mode_time_dark",
1,
5 * SEC_PER_HR,
3 * SEC_PER_HR,
post=" sec",
),
ui.input_slider(
"light_time",
"brightness_mode_time_light",
0,
"brightness_mode_time_light",
1,
5 * SEC_PER_HR,
0.5 * SEC_PER_HR,
post=" sec",
@ -262,23 +251,68 @@ app_ui = ui.page_fluid(
post=" hr",
),
),
ui.panel_main(ui.markdown(desc), ui.output_plot(id="brightness_plot")),
ui.panel_main(
ui.markdown(desc),
ui.output_plot(id="brightness_plot"),
ui.output_plot(id="color_temp_plot"),
),
),
)
def float_to_time(value: float) -> dt.time:
hours = int(value)
minutes = int((value - hours) * 60)
time = dt.time(hours, minutes)
return time
def time_to_float(time: dt.time | dt.datetime) -> float:
return time.hour + time.minute / 60
def _kw(input):
location = Location(LocationInfo(timezone=dt.timezone.utc))
return dict(
name="Adaptive Lighting Simulator",
adapt_until_sleep=input.adapt_until_sleep(),
max_brightness=input.max_brightness(),
min_brightness=input.min_brightness(),
min_color_temp=input.min_color_temp(),
max_color_temp=input.max_color_temp(),
sleep_brightness=input.sleep_brightness(),
sleep_rgb_or_color_temp=input.sleep_rgb_or_color_temp(),
sleep_color_temp=input.sleep_color_temp(),
sleep_rgb_color=[int(x) for x in input.sleep_rgb_color().split(",")],
sunrise_time=float_to_time(input.sunrise_time()),
sunset_time=float_to_time(input.sunset_time()),
brightness_mode_time_dark=dt.timedelta(
seconds=input.brightness_mode_time_dark()
),
brightness_mode_time_light=dt.timedelta(
seconds=input.brightness_mode_time_light()
),
sunrise_offset=dt.timedelta(0),
sunset_offset=dt.timedelta(0),
min_sunrise_time=None,
max_sunrise_time=None,
min_sunset_time=None,
max_sunset_time=None,
astral_location=location,
timezone=location.timezone,
)
def server(input, output, session):
@output
@render.plot
def brightness_plot():
return plot_brightness(
min_brightness=input.min_brightness() / 100,
max_brightness=input.max_brightness() / 100,
brightness_mode_time_dark=input.dark_time() / SEC_PER_HR,
brightness_mode_time_light=input.light_time() / SEC_PER_HR,
sunrise_time=input.sunrise_time(),
sunset_time=input.sunset_time(),
)
return plot_brightness(_kw(input), sleep_mode=input.sleep_mode())
@output
@render.plot
def color_temp_plot():
return plot_color_temp(_kw(input), sleep_mode=input.sleep_mode())
app = App(app_ui, server)

View file

@ -0,0 +1,773 @@
"""Color util methods."""
# Slightly modified from homeassistant.util.color at
# https://github.com/home-assistant/core/blob/798fb3e31a6ba87358adc93a4c5b772b64451712/homeassistant/util/color.py#L14
# to remove the dependency on homeassistant.util.color in sun.py
from __future__ import annotations
import colorsys
import math
from dataclasses import dataclass
from typing import NamedTuple
class RGBColor(NamedTuple):
"""RGB hex values."""
r: int
g: int
b: int
# Official CSS3 colors from w3.org:
# https://www.w3.org/TR/2010/PR-css3-color-20101028/#html4
# names do not have spaces in them so that we can compare against
# requests more easily (by removing spaces from the requests as well).
# This lets "dark seagreen" and "dark sea green" both match the same
# color "darkseagreen".
COLORS = {
"aliceblue": RGBColor(240, 248, 255),
"antiquewhite": RGBColor(250, 235, 215),
"aqua": RGBColor(0, 255, 255),
"aquamarine": RGBColor(127, 255, 212),
"azure": RGBColor(240, 255, 255),
"beige": RGBColor(245, 245, 220),
"bisque": RGBColor(255, 228, 196),
"black": RGBColor(0, 0, 0),
"blanchedalmond": RGBColor(255, 235, 205),
"blue": RGBColor(0, 0, 255),
"blueviolet": RGBColor(138, 43, 226),
"brown": RGBColor(165, 42, 42),
"burlywood": RGBColor(222, 184, 135),
"cadetblue": RGBColor(95, 158, 160),
"chartreuse": RGBColor(127, 255, 0),
"chocolate": RGBColor(210, 105, 30),
"coral": RGBColor(255, 127, 80),
"cornflowerblue": RGBColor(100, 149, 237),
"cornsilk": RGBColor(255, 248, 220),
"crimson": RGBColor(220, 20, 60),
"cyan": RGBColor(0, 255, 255),
"darkblue": RGBColor(0, 0, 139),
"darkcyan": RGBColor(0, 139, 139),
"darkgoldenrod": RGBColor(184, 134, 11),
"darkgray": RGBColor(169, 169, 169),
"darkgreen": RGBColor(0, 100, 0),
"darkgrey": RGBColor(169, 169, 169),
"darkkhaki": RGBColor(189, 183, 107),
"darkmagenta": RGBColor(139, 0, 139),
"darkolivegreen": RGBColor(85, 107, 47),
"darkorange": RGBColor(255, 140, 0),
"darkorchid": RGBColor(153, 50, 204),
"darkred": RGBColor(139, 0, 0),
"darksalmon": RGBColor(233, 150, 122),
"darkseagreen": RGBColor(143, 188, 143),
"darkslateblue": RGBColor(72, 61, 139),
"darkslategray": RGBColor(47, 79, 79),
"darkslategrey": RGBColor(47, 79, 79),
"darkturquoise": RGBColor(0, 206, 209),
"darkviolet": RGBColor(148, 0, 211),
"deeppink": RGBColor(255, 20, 147),
"deepskyblue": RGBColor(0, 191, 255),
"dimgray": RGBColor(105, 105, 105),
"dimgrey": RGBColor(105, 105, 105),
"dodgerblue": RGBColor(30, 144, 255),
"firebrick": RGBColor(178, 34, 34),
"floralwhite": RGBColor(255, 250, 240),
"forestgreen": RGBColor(34, 139, 34),
"fuchsia": RGBColor(255, 0, 255),
"gainsboro": RGBColor(220, 220, 220),
"ghostwhite": RGBColor(248, 248, 255),
"gold": RGBColor(255, 215, 0),
"goldenrod": RGBColor(218, 165, 32),
"gray": RGBColor(128, 128, 128),
"green": RGBColor(0, 128, 0),
"greenyellow": RGBColor(173, 255, 47),
"grey": RGBColor(128, 128, 128),
"honeydew": RGBColor(240, 255, 240),
"hotpink": RGBColor(255, 105, 180),
"indianred": RGBColor(205, 92, 92),
"indigo": RGBColor(75, 0, 130),
"ivory": RGBColor(255, 255, 240),
"khaki": RGBColor(240, 230, 140),
"lavender": RGBColor(230, 230, 250),
"lavenderblush": RGBColor(255, 240, 245),
"lawngreen": RGBColor(124, 252, 0),
"lemonchiffon": RGBColor(255, 250, 205),
"lightblue": RGBColor(173, 216, 230),
"lightcoral": RGBColor(240, 128, 128),
"lightcyan": RGBColor(224, 255, 255),
"lightgoldenrodyellow": RGBColor(250, 250, 210),
"lightgray": RGBColor(211, 211, 211),
"lightgreen": RGBColor(144, 238, 144),
"lightgrey": RGBColor(211, 211, 211),
"lightpink": RGBColor(255, 182, 193),
"lightsalmon": RGBColor(255, 160, 122),
"lightseagreen": RGBColor(32, 178, 170),
"lightskyblue": RGBColor(135, 206, 250),
"lightslategray": RGBColor(119, 136, 153),
"lightslategrey": RGBColor(119, 136, 153),
"lightsteelblue": RGBColor(176, 196, 222),
"lightyellow": RGBColor(255, 255, 224),
"lime": RGBColor(0, 255, 0),
"limegreen": RGBColor(50, 205, 50),
"linen": RGBColor(250, 240, 230),
"magenta": RGBColor(255, 0, 255),
"maroon": RGBColor(128, 0, 0),
"mediumaquamarine": RGBColor(102, 205, 170),
"mediumblue": RGBColor(0, 0, 205),
"mediumorchid": RGBColor(186, 85, 211),
"mediumpurple": RGBColor(147, 112, 219),
"mediumseagreen": RGBColor(60, 179, 113),
"mediumslateblue": RGBColor(123, 104, 238),
"mediumspringgreen": RGBColor(0, 250, 154),
"mediumturquoise": RGBColor(72, 209, 204),
"mediumvioletred": RGBColor(199, 21, 133),
"midnightblue": RGBColor(25, 25, 112),
"mintcream": RGBColor(245, 255, 250),
"mistyrose": RGBColor(255, 228, 225),
"moccasin": RGBColor(255, 228, 181),
"navajowhite": RGBColor(255, 222, 173),
"navy": RGBColor(0, 0, 128),
"navyblue": RGBColor(0, 0, 128),
"oldlace": RGBColor(253, 245, 230),
"olive": RGBColor(128, 128, 0),
"olivedrab": RGBColor(107, 142, 35),
"orange": RGBColor(255, 165, 0),
"orangered": RGBColor(255, 69, 0),
"orchid": RGBColor(218, 112, 214),
"palegoldenrod": RGBColor(238, 232, 170),
"palegreen": RGBColor(152, 251, 152),
"paleturquoise": RGBColor(175, 238, 238),
"palevioletred": RGBColor(219, 112, 147),
"papayawhip": RGBColor(255, 239, 213),
"peachpuff": RGBColor(255, 218, 185),
"peru": RGBColor(205, 133, 63),
"pink": RGBColor(255, 192, 203),
"plum": RGBColor(221, 160, 221),
"powderblue": RGBColor(176, 224, 230),
"purple": RGBColor(128, 0, 128),
"red": RGBColor(255, 0, 0),
"rosybrown": RGBColor(188, 143, 143),
"royalblue": RGBColor(65, 105, 225),
"saddlebrown": RGBColor(139, 69, 19),
"salmon": RGBColor(250, 128, 114),
"sandybrown": RGBColor(244, 164, 96),
"seagreen": RGBColor(46, 139, 87),
"seashell": RGBColor(255, 245, 238),
"sienna": RGBColor(160, 82, 45),
"silver": RGBColor(192, 192, 192),
"skyblue": RGBColor(135, 206, 235),
"slateblue": RGBColor(106, 90, 205),
"slategray": RGBColor(112, 128, 144),
"slategrey": RGBColor(112, 128, 144),
"snow": RGBColor(255, 250, 250),
"springgreen": RGBColor(0, 255, 127),
"steelblue": RGBColor(70, 130, 180),
"tan": RGBColor(210, 180, 140),
"teal": RGBColor(0, 128, 128),
"thistle": RGBColor(216, 191, 216),
"tomato": RGBColor(255, 99, 71),
"turquoise": RGBColor(64, 224, 208),
"violet": RGBColor(238, 130, 238),
"wheat": RGBColor(245, 222, 179),
"white": RGBColor(255, 255, 255),
"whitesmoke": RGBColor(245, 245, 245),
"yellow": RGBColor(255, 255, 0),
"yellowgreen": RGBColor(154, 205, 50),
# And...
"homeassistant": RGBColor(3, 169, 244),
}
@dataclass
class XYPoint:
"""Represents a CIE 1931 XY coordinate pair."""
x: float
y: float
@dataclass
class GamutType:
"""Represents the Gamut of a light."""
red: XYPoint
green: XYPoint
blue: XYPoint
def color_name_to_rgb(color_name: str) -> RGBColor:
"""Convert color name to RGB hex value."""
# COLORS map has no spaces in it, so make the color_name have no
# spaces in it as well for matching purposes
hex_value = COLORS.get(color_name.replace(" ", "").lower())
if not hex_value:
msg = "Unknown color"
raise ValueError(msg)
return hex_value
# pylint: disable=invalid-name
def color_RGB_to_xy(
iR: int,
iG: int,
iB: int,
Gamut: GamutType | None = None,
) -> tuple[float, float]:
"""Convert from RGB color to XY color."""
return color_RGB_to_xy_brightness(iR, iG, iB, Gamut)[:2]
# Taken from:
# https://github.com/PhilipsHue/PhilipsHueSDK-iOS-OSX/blob/00187a3/ApplicationDesignNotes/RGB%20to%20xy%20Color%20conversion.md
# License: Code is given as is. Use at your own risk and discretion.
def color_RGB_to_xy_brightness(
iR: int,
iG: int,
iB: int,
Gamut: GamutType | None = None,
) -> tuple[float, float, int]:
"""Convert from RGB color to XY color."""
if iR + iG + iB == 0:
return 0.0, 0.0, 0
R = iR / 255
B = iB / 255
G = iG / 255
# Gamma correction
R = pow((R + 0.055) / (1.0 + 0.055), 2.4) if (R > 0.04045) else (R / 12.92)
G = pow((G + 0.055) / (1.0 + 0.055), 2.4) if (G > 0.04045) else (G / 12.92)
B = pow((B + 0.055) / (1.0 + 0.055), 2.4) if (B > 0.04045) else (B / 12.92)
# Wide RGB D65 conversion formula
X = R * 0.664511 + G * 0.154324 + B * 0.162028
Y = R * 0.283881 + G * 0.668433 + B * 0.047685
Z = R * 0.000088 + G * 0.072310 + B * 0.986039
# Convert XYZ to xy
x = X / (X + Y + Z)
y = Y / (X + Y + Z)
# Brightness
Y = 1 if Y > 1 else Y
brightness = round(Y * 255)
# Check if the given xy value is within the color-reach of the lamp.
if Gamut:
in_reach = check_point_in_lamps_reach((x, y), Gamut)
if not in_reach:
xy_closest = get_closest_point_to_point((x, y), Gamut)
x = xy_closest[0]
y = xy_closest[1]
return round(x, 3), round(y, 3), brightness
def color_xy_to_RGB(
vX: float,
vY: float,
Gamut: GamutType | None = None,
) -> tuple[int, int, int]:
"""Convert from XY to a normalized RGB."""
return color_xy_brightness_to_RGB(vX, vY, 255, Gamut)
# Converted to Python from Obj-C, original source from:
# https://github.com/PhilipsHue/PhilipsHueSDK-iOS-OSX/blob/00187a3/ApplicationDesignNotes/RGB%20to%20xy%20Color%20conversion.md
def color_xy_brightness_to_RGB(
vX: float,
vY: float,
ibrightness: int,
Gamut: GamutType | None = None,
) -> tuple[int, int, int]:
"""Convert from XYZ to RGB."""
if Gamut and not check_point_in_lamps_reach((vX, vY), Gamut):
xy_closest = get_closest_point_to_point((vX, vY), Gamut)
vX = xy_closest[0]
vY = xy_closest[1]
brightness = ibrightness / 255.0
if brightness == 0.0:
return (0, 0, 0)
Y = brightness
if vY == 0.0:
vY += 0.00000000001
X = (Y / vY) * vX
Z = (Y / vY) * (1 - vX - vY)
# Convert to RGB using Wide RGB D65 conversion.
r = X * 1.656492 - Y * 0.354851 - Z * 0.255038
g = -X * 0.707196 + Y * 1.655397 + Z * 0.036152
b = X * 0.051713 - Y * 0.121364 + Z * 1.011530
# Apply reverse gamma correction.
r, g, b = (
12.92 * x if (x <= 0.0031308) else ((1.0 + 0.055) * pow(x, (1.0 / 2.4)) - 0.055)
for x in (r, g, b)
)
# Bring all negative components to zero.
r, g, b = (max(0, x) for x in (r, g, b))
# If one component is greater than 1, weight components by that value.
max_component = max(r, g, b)
if max_component > 1:
r, g, b = (x / max_component for x in (r, g, b))
ir, ig, ib = (int(x * 255) for x in (r, g, b))
return (ir, ig, ib)
def color_hsb_to_RGB(fH: float, fS: float, fB: float) -> tuple[int, int, int]:
"""Convert a hsb into its rgb representation."""
if fS == 0.0:
fV = int(fB * 255)
return fV, fV, fV
r = g = b = 0
h = fH / 60
f = h - float(math.floor(h))
p = fB * (1 - fS)
q = fB * (1 - fS * f)
t = fB * (1 - (fS * (1 - f)))
if int(h) == 0:
r = int(fB * 255)
g = int(t * 255)
b = int(p * 255)
elif int(h) == 1:
r = int(q * 255)
g = int(fB * 255)
b = int(p * 255)
elif int(h) == 2:
r = int(p * 255)
g = int(fB * 255)
b = int(t * 255)
elif int(h) == 3:
r = int(p * 255)
g = int(q * 255)
b = int(fB * 255)
elif int(h) == 4:
r = int(t * 255)
g = int(p * 255)
b = int(fB * 255)
elif int(h) == 5:
r = int(fB * 255)
g = int(p * 255)
b = int(q * 255)
return (r, g, b)
def color_RGB_to_hsv(iR: float, iG: float, iB: float) -> tuple[float, float, float]:
"""Convert an rgb color to its hsv representation.
Hue is scaled 0-360
Sat is scaled 0-100
Val is scaled 0-100
"""
fHSV = colorsys.rgb_to_hsv(iR / 255.0, iG / 255.0, iB / 255.0)
return round(fHSV[0] * 360, 3), round(fHSV[1] * 100, 3), round(fHSV[2] * 100, 3)
def color_RGB_to_hs(iR: float, iG: float, iB: float) -> tuple[float, float]:
"""Convert an rgb color to its hs representation."""
return color_RGB_to_hsv(iR, iG, iB)[:2]
def color_hsv_to_RGB(iH: float, iS: float, iV: float) -> tuple[int, int, int]:
"""Convert an hsv color into its rgb representation.
Hue is scaled 0-360
Sat is scaled 0-100
Val is scaled 0-100
"""
fRGB = colorsys.hsv_to_rgb(iH / 360, iS / 100, iV / 100)
return (int(fRGB[0] * 255), int(fRGB[1] * 255), int(fRGB[2] * 255))
def color_hs_to_RGB(iH: float, iS: float) -> tuple[int, int, int]:
"""Convert an hsv color into its rgb representation."""
return color_hsv_to_RGB(iH, iS, 100)
def color_xy_to_hs(
vX: float,
vY: float,
Gamut: GamutType | None = None,
) -> tuple[float, float]:
"""Convert an xy color to its hs representation."""
h, s, _ = color_RGB_to_hsv(*color_xy_to_RGB(vX, vY, Gamut))
return h, s
def color_hs_to_xy(
iH: float,
iS: float,
Gamut: GamutType | None = None,
) -> tuple[float, float]:
"""Convert an hs color to its xy representation."""
return color_RGB_to_xy(*color_hs_to_RGB(iH, iS), Gamut)
def match_max_scale(
input_colors: tuple[int, ...],
output_colors: tuple[float, ...],
) -> tuple[int, ...]:
"""Match the maximum value of the output to the input."""
max_in = max(input_colors)
max_out = max(output_colors)
factor = 0.0 if max_out == 0 else max_in / max_out
return tuple(int(round(i * factor)) for i in output_colors)
def color_rgb_to_rgbw(r: int, g: int, b: int) -> tuple[int, int, int, int]:
"""Convert an rgb color to an rgbw representation."""
# Calculate the white channel as the minimum of input rgb channels.
# Subtract the white portion from the remaining rgb channels.
w = min(r, g, b)
rgbw = (r - w, g - w, b - w, w)
# Match the output maximum value to the input. This ensures the full
# channel range is used.
return match_max_scale((r, g, b), rgbw) # type: ignore[return-value]
def color_rgbw_to_rgb(r: int, g: int, b: int, w: int) -> tuple[int, int, int]:
"""Convert an rgbw color to an rgb representation."""
# Add the white channel to the rgb channels.
rgb = (r + w, g + w, b + w)
# Match the output maximum value to the input. This ensures the
# output doesn't overflow.
return match_max_scale((r, g, b, w), rgb) # type: ignore[return-value]
def color_rgb_to_rgbww(
r: int,
g: int,
b: int,
min_kelvin: int,
max_kelvin: int,
) -> tuple[int, int, int, int, int]:
"""Convert an rgb color to an rgbww representation."""
# Find the color temperature when both white channels have equal brightness
max_mireds = color_temperature_kelvin_to_mired(min_kelvin)
min_mireds = color_temperature_kelvin_to_mired(max_kelvin)
mired_range = max_mireds - min_mireds
mired_midpoint = min_mireds + mired_range / 2
color_temp_kelvin = color_temperature_mired_to_kelvin(mired_midpoint)
w_r, w_g, w_b = color_temperature_to_rgb(color_temp_kelvin)
# Find the ratio of the midpoint white in the input rgb channels
white_level = min(
r / w_r if w_r else 0,
g / w_g if w_g else 0,
b / w_b if w_b else 0,
)
# Subtract the white portion from the rgb channels.
rgb = (r - w_r * white_level, g - w_g * white_level, b - w_b * white_level)
rgbww = (*rgb, round(white_level * 255), round(white_level * 255))
# Match the output maximum value to the input. This ensures the full
# channel range is used.
return match_max_scale((r, g, b), rgbww) # type: ignore[return-value]
def color_rgbww_to_rgb(
r: int,
g: int,
b: int,
cw: int,
ww: int,
min_kelvin: int,
max_kelvin: int,
) -> tuple[int, int, int]:
"""Convert an rgbww color to an rgb representation."""
# Calculate color temperature of the white channels
max_mireds = color_temperature_kelvin_to_mired(min_kelvin)
min_mireds = color_temperature_kelvin_to_mired(max_kelvin)
mired_range = max_mireds - min_mireds
try:
ct_ratio = ww / (cw + ww)
except ZeroDivisionError:
ct_ratio = 0.5
color_temp_mired = min_mireds + ct_ratio * mired_range
if color_temp_mired:
color_temp_kelvin = color_temperature_mired_to_kelvin(color_temp_mired)
else:
color_temp_kelvin = 0
w_r, w_g, w_b = color_temperature_to_rgb(color_temp_kelvin)
white_level = max(cw, ww) / 255
# Add the white channels to the rgb channels.
rgb = (r + w_r * white_level, g + w_g * white_level, b + w_b * white_level)
# Match the output maximum value to the input. This ensures the
# output doesn't overflow.
return match_max_scale((r, g, b, cw, ww), rgb) # type: ignore[return-value]
def color_rgb_to_hex(r: int, g: int, b: int) -> str:
"""Return a RGB color from a hex color string."""
return f"{round(r):02x}{round(g):02x}{round(b):02x}"
def rgb_hex_to_rgb_list(hex_string: str) -> list[int]:
"""Return an RGB color value list from a hex color string."""
return [
int(hex_string[i : i + len(hex_string) // 3], 16)
for i in range(0, len(hex_string), len(hex_string) // 3)
]
def color_temperature_to_hs(color_temperature_kelvin: float) -> tuple[float, float]:
"""Return an hs color from a color temperature in Kelvin."""
return color_RGB_to_hs(*color_temperature_to_rgb(color_temperature_kelvin))
def color_temperature_to_rgb(
color_temperature_kelvin: float,
) -> tuple[float, float, float]:
"""Return an RGB color from a color temperature in Kelvin.
This is a rough approximation based on the formula provided by T. Helland
http://www.tannerhelland.com/4435/convert-temperature-rgb-algorithm-code/
"""
# range check
if color_temperature_kelvin < 1000:
color_temperature_kelvin = 1000
elif color_temperature_kelvin > 40000:
color_temperature_kelvin = 40000
tmp_internal = color_temperature_kelvin / 100.0
red = _get_red(tmp_internal)
green = _get_green(tmp_internal)
blue = _get_blue(tmp_internal)
return red, green, blue
def color_temperature_to_rgbww(
temperature: int,
brightness: int,
min_kelvin: int,
max_kelvin: int,
) -> tuple[int, int, int, int, int]:
"""Convert color temperature in kelvin to rgbcw.
Returns a (r, g, b, cw, ww) tuple.
"""
max_mireds = color_temperature_kelvin_to_mired(min_kelvin)
min_mireds = color_temperature_kelvin_to_mired(max_kelvin)
temperature = color_temperature_kelvin_to_mired(temperature)
mired_range = max_mireds - min_mireds
cold = ((max_mireds - temperature) / mired_range) * brightness
warm = brightness - cold
return (0, 0, 0, round(cold), round(warm))
def rgbww_to_color_temperature(
rgbww: tuple[int, int, int, int, int],
min_kelvin: int,
max_kelvin: int,
) -> tuple[int, int]:
"""Convert rgbcw to color temperature in kelvin.
Returns a tuple (color_temperature, brightness).
"""
_, _, _, cold, warm = rgbww
return _white_levels_to_color_temperature(cold, warm, min_kelvin, max_kelvin)
def _white_levels_to_color_temperature(
cold: int,
warm: int,
min_kelvin: int,
max_kelvin: int,
) -> tuple[int, int]:
"""Convert whites to color temperature in kelvin.
Returns a tuple (color_temperature, brightness).
"""
max_mireds = color_temperature_kelvin_to_mired(min_kelvin)
min_mireds = color_temperature_kelvin_to_mired(max_kelvin)
brightness = warm / 255 + cold / 255
if brightness == 0:
# Return the warmest color if brightness is 0
return (min_kelvin, 0)
return round(
color_temperature_mired_to_kelvin(
((cold / 255 / brightness) * (min_mireds - max_mireds)) + max_mireds,
),
), min(255, round(brightness * 255))
def _clamp(color_component: float, minimum: float = 0, maximum: float = 255) -> float:
"""Clamp the given color component value between the given min and max values.
The range defined by the minimum and maximum values is inclusive, i.e. given a
color_component of 0 and a minimum of 10, the returned value is 10.
"""
color_component_out = max(color_component, minimum)
return min(color_component_out, maximum)
def _get_red(temperature: float) -> float:
"""Get the red component of the temperature in RGB space."""
if temperature <= 66:
return 255
tmp_red = 329.698727446 * math.pow(temperature - 60, -0.1332047592)
return _clamp(tmp_red)
def _get_green(temperature: float) -> float:
"""Get the green component of the given color temp in RGB space."""
if temperature <= 66:
green = 99.4708025861 * math.log(temperature) - 161.1195681661
else:
green = 288.1221695283 * math.pow(temperature - 60, -0.0755148492)
return _clamp(green)
def _get_blue(temperature: float) -> float:
"""Get the blue component of the given color temperature in RGB space."""
if temperature >= 66:
return 255
if temperature <= 19:
return 0
blue = 138.5177312231 * math.log(temperature - 10) - 305.0447927307
return _clamp(blue)
def color_temperature_mired_to_kelvin(mired_temperature: float) -> int:
"""Convert absolute mired shift to degrees kelvin."""
return math.floor(1000000 / mired_temperature)
def color_temperature_kelvin_to_mired(kelvin_temperature: float) -> int:
"""Convert degrees kelvin to mired shift."""
return math.floor(1000000 / kelvin_temperature)
# The following 5 functions are adapted from rgbxy provided by Benjamin Knight
# License: The MIT License (MIT), 2014.
# https://github.com/benknight/hue-python-rgb-converter
def cross_product(p1: XYPoint, p2: XYPoint) -> float:
"""Calculate the cross product of two XYPoints."""
return float(p1.x * p2.y - p1.y * p2.x)
def get_distance_between_two_points(one: XYPoint, two: XYPoint) -> float:
"""Calculate the distance between two XYPoints."""
dx = one.x - two.x
dy = one.y - two.y
return math.sqrt(dx * dx + dy * dy)
def get_closest_point_to_line(A: XYPoint, B: XYPoint, P: XYPoint) -> XYPoint:
"""Find the closest point from P to a line defined by A and B.
This point will be reproducible by the lamp
as it is on the edge of the gamut.
"""
AP = XYPoint(P.x - A.x, P.y - A.y)
AB = XYPoint(B.x - A.x, B.y - A.y)
ab2 = AB.x * AB.x + AB.y * AB.y
ap_ab = AP.x * AB.x + AP.y * AB.y
t = ap_ab / ab2
if t < 0.0:
t = 0.0
elif t > 1.0:
t = 1.0
return XYPoint(A.x + AB.x * t, A.y + AB.y * t)
def get_closest_point_to_point(
xy_tuple: tuple[float, float],
Gamut: GamutType,
) -> tuple[float, float]:
"""Get the closest matching color within the gamut of the light.
Should only be used if the supplied color is outside of the color gamut.
"""
xy_point = XYPoint(xy_tuple[0], xy_tuple[1])
# find the closest point on each line in the CIE 1931 'triangle'.
pAB = get_closest_point_to_line(Gamut.red, Gamut.green, xy_point)
pAC = get_closest_point_to_line(Gamut.blue, Gamut.red, xy_point)
pBC = get_closest_point_to_line(Gamut.green, Gamut.blue, xy_point)
# Get the distances per point and see which point is closer to our Point.
dAB = get_distance_between_two_points(xy_point, pAB)
dAC = get_distance_between_two_points(xy_point, pAC)
dBC = get_distance_between_two_points(xy_point, pBC)
lowest = dAB
closest_point = pAB
if dAC < lowest:
lowest = dAC
closest_point = pAC
if dBC < lowest:
lowest = dBC
closest_point = pBC
# Change the xy value to a value which is within the reach of the lamp.
cx = closest_point.x
cy = closest_point.y
return (cx, cy)
def check_point_in_lamps_reach(p: tuple[float, float], Gamut: GamutType) -> bool:
"""Check if the provided XYPoint can be recreated by a Hue lamp."""
v1 = XYPoint(Gamut.green.x - Gamut.red.x, Gamut.green.y - Gamut.red.y)
v2 = XYPoint(Gamut.blue.x - Gamut.red.x, Gamut.blue.y - Gamut.red.y)
q = XYPoint(p[0] - Gamut.red.x, p[1] - Gamut.red.y)
s = cross_product(q, v2) / cross_product(v1, v2)
t = cross_product(v1, q) / cross_product(v1, v2)
return (s >= 0.0) and (t >= 0.0) and (s + t <= 1.0)
def check_valid_gamut(Gamut: GamutType) -> bool:
"""Check if the supplied gamut is valid."""
# Check if the three points of the supplied gamut are not on the same line.
v1 = XYPoint(Gamut.green.x - Gamut.red.x, Gamut.green.y - Gamut.red.y)
v2 = XYPoint(Gamut.blue.x - Gamut.red.x, Gamut.blue.y - Gamut.red.y)
not_on_line = cross_product(v1, v2) > 0.0001
# Check if all six coordinates of the gamut lie between 0 and 1.
red_valid = (
Gamut.red.x >= 0 and Gamut.red.x <= 1 and Gamut.red.y >= 0 and Gamut.red.y <= 1
)
green_valid = (
Gamut.green.x >= 0
and Gamut.green.x <= 1
and Gamut.green.y >= 0
and Gamut.green.y <= 1
)
blue_valid = (
Gamut.blue.x >= 0
and Gamut.blue.x <= 1
and Gamut.blue.y >= 0
and Gamut.blue.y <= 1
)
return not_on_line and red_valid and green_valid and blue_valid

View file

@ -1 +1,2 @@
shinylive
astral==2.2