From f8e7880a968651e5d4bbede8dacfec8e20230052 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 8 Aug 2023 14:31:17 -0700 Subject: [PATCH] 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 --- .github/workflows/deploy-webapp.yml | 3 + .ruff.toml | 1 + README.md | 2 +- .../adaptive_lighting/color_and_brightness.py | 518 ++++++++++++ custom_components/adaptive_lighting/const.py | 2 - .../adaptive_lighting/helpers.py | 122 --- custom_components/adaptive_lighting/switch.py | 314 +------ tests/test_color_and_brightness.py | 209 +++++ tests/test_switch.py | 23 +- webapp/app.py | 402 ++++----- webapp/homeassistant_util_color.py | 773 ++++++++++++++++++ webapp/requirements.txt | 1 + 12 files changed, 1745 insertions(+), 625 deletions(-) create mode 100644 custom_components/adaptive_lighting/color_and_brightness.py create mode 100644 tests/test_color_and_brightness.py create mode 100644 webapp/homeassistant_util_color.py diff --git a/.github/workflows/deploy-webapp.yml b/.github/workflows/deploy-webapp.yml index 7c4ac13e..04504522 100644 --- a/.github/workflows/deploy-webapp.yml +++ b/.github/workflows/deploy-webapp.yml @@ -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 diff --git a/.ruff.toml b/.ruff.toml index ead4d843..8ece9b9c 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -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 diff --git a/README.md b/README.md index 56d9f05d..faa43e9b 100644 --- a/README.md +++ b/README.md @@ -441,7 +441,7 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark ![image](https://github.com/basnijholt/adaptive-lighting/assets/6897215/e5fc5d27-3c37-4e3d-93d1-6e7cf4b48e7c) ![image](https://github.com/basnijholt/adaptive-lighting/assets/6897215/3dcbdc42-63c4-49df-8651-d2fae53dd08d) -> [*Code to make the plots*](https://github.com/basnijholt/adaptive-lighting/pull/699#issuecomment-1666232555) +> Check out the interactive webapp on https://basnijholt.github.io/adaptive-lighting/ to play with the parameters and see how the brightness changes! ## :eyes: See also diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py new file mode 100644 index 00000000..2968fcc0 --- /dev/null +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -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)) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 0608d3da..c747528d 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -12,8 +12,6 @@ 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. 📝"} diff --git a/custom_components/adaptive_lighting/helpers.py b/custom_components/adaptive_lighting/helpers.py index 95c87735..2e7ba23e 100644 --- a/custom_components/adaptive_lighting/helpers.py +++ b/custom_components/adaptive_lighting/helpers.py @@ -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. diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 07eb0a97..35b1d7e5 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -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 @@ 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.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 @@ ServiceData, prepare_adaptation_data, ) +from .color_and_brightness import SunLightSettings from .const import ( ADAPT_BRIGHTNESS_SWITCH, ADAPT_COLOR_SWITCH, @@ -153,8 +148,6 @@ 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 .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 @@ "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 @@ def _set_changeable_settings( 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 @@ async def async_turn_off(self, **kwargs) -> None: # noqa: ARG002 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.""" diff --git a/tests/test_color_and_brightness.py b/tests/test_color_and_brightness.py new file mode 100644 index 00000000..a199f939 --- /dev/null +++ b/tests/test_color_and_brightness.py @@ -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() diff --git a/tests/test_switch.py b/tests/test_switch.py index 79dc9b20..6ffd1a21 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -100,8 +100,8 @@ 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 @@ def is_approx_equal(a, b): 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() diff --git a/webapp/app.py b/webapp/app.py index 208ea242..91a33d0c 100644 --- a/webapp/app.py +++ b/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 date_range(tzinfo): + start_of_day = dt.datetime.now(tzinfo).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + # 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 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" + ) + 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 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) +copy_color_and_brightness_module() -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) +from color_and_brightness import SunLightSettings -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, - ) - 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) - - -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 - ) - 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) - - -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 -): +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} - - plt.legend() - plt.grid(True) + ax.legend() + ax.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 @@ def plot_brightness( 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 @@ def plot_brightness( 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) diff --git a/webapp/homeassistant_util_color.py b/webapp/homeassistant_util_color.py new file mode 100644 index 00000000..33df5cf3 --- /dev/null +++ b/webapp/homeassistant_util_color.py @@ -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 diff --git a/webapp/requirements.txt b/webapp/requirements.txt index f832a57a..ee8c6966 100644 --- a/webapp/requirements.txt +++ b/webapp/requirements.txt @@ -1 +1,2 @@ shinylive +astral==2.2