mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-11 14:24:03 +02:00
Split up SunLightSettings and improve https://basnijholt.github.io/adaptive-lighting/ (#719)
* 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:
parent
61d51cbf1e
commit
f8e7880a96
12 changed files with 1745 additions and 625 deletions
3
.github/workflows/deploy-webapp.yml
vendored
3
.github/workflows/deploy-webapp.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -441,7 +441,7 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark
|
|||

|
||||

|
||||
|
||||
> [*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
|
||||
|
||||
|
|
|
|||
518
custom_components/adaptive_lighting/color_and_brightness.py
Normal file
518
custom_components/adaptive_lighting/color_and_brightness.py
Normal 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))
|
||||
|
|
@ -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. 📝"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
209
tests/test_color_and_brightness.py
Normal file
209
tests/test_color_and_brightness.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
400
webapp/app.py
400
webapp/app.py
|
|
@ -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)
|
||||
|
|
|
|||
773
webapp/homeassistant_util_color.py
Normal file
773
webapp/homeassistant_util_color.py
Normal 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
|
||||
|
|
@ -1 +1,2 @@
|
|||
shinylive
|
||||
astral==2.2
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue