adaptive-lighting/webapp/app.py
Bas Nijholt d4d3d50ada
fix: replace deprecated get_astral_location with get_astral_observer (#1482)
* fix: replace deprecated get_astral_location with get_astral_observer (#1481)

HA 2026.7 deprecates homeassistant.helpers.sun.get_astral_location
(removal planned for 2027.7) in favor of get_astral_observer, causing a
deprecation warning in the HA logs.

- Switch SunEvents/SunLightSettings from astral.location.Location to
  astral.Observer, using the astral.sun module functions (which return
  UTC times by default, matching the previous local=False calls).
- Use get_astral_observer in switch.py, with a fallback for HA < 2026.7
  that constructs the Observer directly from the HA config.
- Update tests and the webapp simulator accordingly.

* ci: handle removal of requirements_test_all.txt in HA 2026.8 dev

HA core removed requirements_test_all.txt (home-assistant/core#171530),
which made test_dependencies.py crash with FileNotFoundError and broke
the dev pytest job and the Docker builds. Fall back to
requirements_all.txt, which carries the same per-integration
'# homeassistant.components.x' annotations. Also extend the
aiohasupervisor pin lookup in scripts/setup-dependencies accordingly.

* test: support modern template light config for HA 2026.6+

HA 2026.6 removed the legacy `light: platform: template` YAML format
(home-assistant/core#169615), so setup_lights found no template platform
on HA dev and every test using it failed with IndexError. Detect legacy
support at runtime (PLATFORM_SCHEMA presence) and fall back to the
modern `template:` config format. The group platform is set up before
the template integration in the modern path, because setting up
`template` also sets up the `light` domain, which would make a later
async_setup_component(hass, LIGHT_DOMAIN, ...) a no-op.
2026-07-01 23:00:35 -07:00

344 lines
12 KiB
Python

"""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 Observer
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):
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_observer": Observer(),
"timezone": dt.timezone.utc,
}
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)