mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-10-28 22:27:06 +01:00
Remove upstream's Shiny demo webapp/ (not part of the integration)
This commit is contained in:
parent
350887ec22
commit
a724add0fd
9 changed files with 0 additions and 1675 deletions
|
|
@ -47,8 +47,6 @@ ignore = [
|
|||
"TD003", # Missing issue link on the line following this TODO
|
||||
]
|
||||
".github/*py" = ["INP001"]
|
||||
"webapp/homeassistant_util_color.py" = ["ALL"]
|
||||
"webapp/app.py" = ["INP001", "DTZ011", "A002"]
|
||||
"custom_components/adaptive_lighting/homeassistant_util_color.py" = ["ALL"]
|
||||
|
||||
[lint.flake8-pytest-style]
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
> ### ⚠️ Not used by the CDiT fork
|
||||
>
|
||||
> This Shiny webapp is the **upstream** Adaptive Lighting simulator. It
|
||||
> models the upstream curve (with `brightness_mode`, `brightness_mode_time_*`,
|
||||
> sleep mode, etc.) — features the CDiT fork has **removed**.
|
||||
>
|
||||
> The CDiT fork uses a fixed-tanh curve with a hardcoded 30-minute half-width,
|
||||
> anchored at the user's configured `sunrise_entity` / `sunset_entity`.
|
||||
> The simulator's outputs will not match what the integration computes.
|
||||
>
|
||||
> Kept here for upstream-merge hygiene. Not built or deployed by the fork's
|
||||
> CI. See [`../README.md`](../README.md) for the fork's actual behavior.
|
||||
|
||||
---
|
||||
|
||||
To run this app locally, install the requirements and run `shiny run`.
|
||||
|
||||
```
|
||||
$ cd webapp
|
||||
$ pip install -r requirements-dev.txt
|
||||
$ shiny run
|
||||
```
|
||||
|
||||
After the server starts, which should take only a moment, you can open
|
||||
[http://127.0.0.1:8000](http://127.0.0.1:8000) to see the interface.
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""Shiny webapp for Adaptive Lighting."""
|
||||
346
webapp/app.py
346
webapp/app.py
|
|
@ -1,346 +0,0 @@
|
|||
"""Simple web app to visualize brightness over time."""
|
||||
|
||||
import datetime as dt
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import shinyswatch
|
||||
from astral import LocationInfo
|
||||
from astral.location import Location
|
||||
from homeassistant_util_color import color_temperature_to_rgb
|
||||
from shiny import App, render, ui
|
||||
|
||||
|
||||
def date_range(tzinfo: dt.tzinfo) -> list[dt.datetime]:
|
||||
"""Return a list of datetimes for the current day."""
|
||||
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=1))
|
||||
return hours_range[:-1]
|
||||
|
||||
|
||||
def copy_color_and_brightness_module() -> None:
|
||||
"""Copy the color_and_brightness module to the webapp folder."""
|
||||
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)
|
||||
|
||||
|
||||
copy_color_and_brightness_module()
|
||||
|
||||
from color_and_brightness import SunLightSettings # noqa: E402
|
||||
|
||||
|
||||
def plot_brightness(inputs: dict[str, Any], sleep_mode: bool):
|
||||
"""Plot the brightness over time for different modes."""
|
||||
# Define the time range for our simulation
|
||||
sun_linear = SunLightSettings(**inputs, brightness_mode="linear")
|
||||
sun_tanh = SunLightSettings(**inputs, brightness_mode="tanh")
|
||||
sun = SunLightSettings(**inputs, 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 = [
|
||||
sun_linear.brightness_pct(dt, sleep_mode) for dt in dt_range
|
||||
]
|
||||
brightness_tanh_values = [
|
||||
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
|
||||
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", c="C5")
|
||||
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.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}",
|
||||
),
|
||||
)
|
||||
|
||||
ax.legend()
|
||||
|
||||
ax.text(
|
||||
0.4,
|
||||
0.55,
|
||||
textstr,
|
||||
transform=ax.transAxes,
|
||||
fontsize=10,
|
||||
verticalalignment="center",
|
||||
bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.5},
|
||||
)
|
||||
ax.grid(visible=True)
|
||||
|
||||
return fig
|
||||
|
||||
|
||||
def plot_color_temp(inputs: dict[str, Any], sleep_mode: bool) -> plt.Figure:
|
||||
"""Plot the color temperature over time for different modes."""
|
||||
sun = SunLightSettings(**inputs, 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]
|
||||
if sleep_mode and sun.sleep_rgb_or_color_temp == "color_temp":
|
||||
colors = [
|
||||
color_temperature_to_rgb(setting["color_temp_kelvin"])
|
||||
for setting in settings
|
||||
]
|
||||
else:
|
||||
colors = [setting["rgb_color"] for setting in settings]
|
||||
color_temp_values = np.array([(*col, 255) for col in colors]) / 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")
|
||||
ax.grid(visible=False)
|
||||
return fig
|
||||
|
||||
|
||||
SEC_PER_HR = 60 * 60
|
||||
desc_top = """
|
||||
**Experience the Dynamics of [Adaptive Lighting](https://github.com/basnijholt/adaptive-lighting) in Real-Time.**
|
||||
|
||||
Have you ever wondered how the intricate settings of [Adaptive Lighting](https://github.com/basnijholt/adaptive-lighting) impact your home ambiance? The Adaptive Lighting Simulator WebApp is here to demystify just that.
|
||||
|
||||
(More text below the plots)
|
||||
"""
|
||||
|
||||
desc_bottom = """
|
||||
Harnessing the technology of the popular Adaptive Lighting integration for Home Assistant, this webapp provides a hands-on, visual platform to explore, tweak, and understand the myriad of parameters that dictate the behavior of your smart lights. Whether you're aiming for a subtle morning glow or a cozy evening warmth, observe firsthand how each tweak changes the ambiance.
|
||||
|
||||
**Why Use the Simulator?**
|
||||
- **Interactive Exploration**: No more guesswork. See in real-time how changes to settings influence the lighting dynamics.
|
||||
- **Circadian Cycle Preview**: Understand how Adaptive Lighting adjusts throughout the day based on specific parameters, ensuring your lighting aligns with your circadian rhythms.
|
||||
- **Tailored Testing**: Play with parameters and find the perfect combination that suits your personal or family's needs.
|
||||
- **Educational Experience**: For both newbies and experts, delve deep into the intricacies of Adaptive Lighting's logic and potential.
|
||||
|
||||
Dive into the simulator, experiment with different settings, and fine-tune the behavior of Adaptive Lighting to perfection. Whether you're setting it up for the first time or optimizing an existing setup, this tool ensures you get the most out of your smart lighting experience.
|
||||
"""
|
||||
|
||||
# Shiny UI
|
||||
app_ui = ui.page_fluid(
|
||||
ui.panel_title("🌞 Adaptive Lighting Simulator WebApp 🌛"),
|
||||
ui.layout_sidebar(
|
||||
ui.sidebar(
|
||||
ui.input_switch("adapt_until_sleep", "adapt_until_sleep", value=False),
|
||||
ui.input_switch("sleep_mode", "sleep_mode", value=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(
|
||||
"brightness_mode_time_dark",
|
||||
"brightness_mode_time_dark",
|
||||
1,
|
||||
5 * SEC_PER_HR,
|
||||
3 * SEC_PER_HR,
|
||||
post=" sec",
|
||||
),
|
||||
ui.input_slider(
|
||||
"brightness_mode_time_light",
|
||||
"brightness_mode_time_light",
|
||||
1,
|
||||
5 * SEC_PER_HR,
|
||||
0.5 * SEC_PER_HR,
|
||||
post=" sec",
|
||||
),
|
||||
ui.input_slider(
|
||||
"sunrise_time",
|
||||
"sunrise_time",
|
||||
0,
|
||||
24,
|
||||
6,
|
||||
step=0.5,
|
||||
post=" hr",
|
||||
),
|
||||
ui.input_slider(
|
||||
"sunset_time",
|
||||
"sunset_time",
|
||||
0,
|
||||
24,
|
||||
18,
|
||||
step=0.5,
|
||||
post=" hr",
|
||||
),
|
||||
),
|
||||
ui.markdown(desc_top),
|
||||
ui.output_plot(id="brightness_plot"),
|
||||
ui.output_plot(id="color_temp_plot"),
|
||||
ui.markdown(desc_bottom),
|
||||
),
|
||||
theme=shinyswatch.theme.sandstone,
|
||||
)
|
||||
|
||||
|
||||
def float_to_time(value: float) -> dt.time:
|
||||
"""Convert a float to a time object."""
|
||||
hours = int(value)
|
||||
minutes = int((value - hours) * 60)
|
||||
return dt.time(hours, minutes)
|
||||
|
||||
|
||||
def time_to_float(time: dt.time | dt.datetime) -> float:
|
||||
"""Convert a time object to a float."""
|
||||
return time.hour + time.minute / 60
|
||||
|
||||
|
||||
def _kw(input):
|
||||
location = Location(LocationInfo(timezone=dt.UTC))
|
||||
return {
|
||||
"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): # noqa: ARG001
|
||||
"""Shiny server."""
|
||||
|
||||
@output
|
||||
@render.plot
|
||||
def brightness_plot():
|
||||
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)
|
||||
|
|
@ -1,516 +0,0 @@
|
|||
"""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 UTC, 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.location
|
||||
|
||||
# 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))}
|
||||
|
||||
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.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)
|
||||
sunrise = max(min_sunrise, sunrise)
|
||||
if self.max_sunrise_time is not None:
|
||||
max_sunrise = self._replace_time(dt, self.max_sunrise_time)
|
||||
sunrise = min(max_sunrise, 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)
|
||||
sunset = max(min_sunset, sunset)
|
||||
if self.max_sunset_time is not None:
|
||||
max_sunset = self._replace_time(dt, self.max_sunset_time)
|
||||
sunset = min(max_sunset, 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 SUN_EVENT_SUNRISE in (prev_event, next_event):
|
||||
ts_event = prev_ts if prev_event == SUN_EVENT_SUNRISE else next_ts
|
||||
return SUN_EVENT_SUNRISE, ts_event
|
||||
if SUN_EVENT_SUNSET in (prev_event, next_event):
|
||||
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.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 | None:
|
||||
"""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[int, int, int]
|
||||
# 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: bool,
|
||||
transition: float | None,
|
||||
) -> 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(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: float, x1: float, x2: float, y1: float, y2: float) -> float:
|
||||
"""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))
|
||||
|
|
@ -1,774 +0,0 @@
|
|||
"""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,4 +0,0 @@
|
|||
astral==2.2
|
||||
matplotlib
|
||||
shinyswatch
|
||||
shiny
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
# This file was autogenerated by uv via the following command:
|
||||
# uv pip compile requirements.txt.in --output-file requirements.txt
|
||||
astral==2.2
|
||||
# via -r requirements.txt.in
|
||||
pytz==2023.3.post1
|
||||
# via astral
|
||||
|
|
@ -1 +0,0 @@
|
|||
astral==2.2
|
||||
Loading…
Add table
Add a link
Reference in a new issue