Remove upstream's Shiny demo webapp/ (not part of the integration)

This commit is contained in:
Casey 2026-09-25 19:11:16 +02:00
commit a724add0fd
9 changed files with 0 additions and 1675 deletions

View file

@ -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]

View file

@ -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.

View file

@ -1 +0,0 @@
"""Shiny webapp for Adaptive Lighting."""

View file

@ -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)

View file

@ -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))

View file

@ -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

View file

@ -1,4 +0,0 @@
astral==2.2
matplotlib
shinyswatch
shiny

View file

@ -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

View file

@ -1 +0,0 @@
astral==2.2