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.
This commit is contained in:
Bas Nijholt 2026-07-01 23:00:35 -07:00 • committed by GitHub
commit d4d3d50ada
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 159 additions and 94 deletions

View file

@ -8,8 +8,7 @@ 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 astral import Observer
from homeassistant_util_color import color_temperature_to_rgb
from shiny import App, render, ui
@ -298,7 +297,6 @@ def time_to_float(time: dt.time | dt.datetime) -> float:
def _kw(input):
location = Location(LocationInfo(timezone=dt.timezone.utc))
return {
"name": "Adaptive Lighting Simulator",
"adapt_until_sleep": input.adapt_until_sleep(),
@ -324,8 +322,8 @@ def _kw(input):
"max_sunrise_time": None,
"min_sunset_time": None,
"max_sunset_time": None,
"astral_location": location,
"timezone": location.timezone,
"astral_observer": Observer(),
"timezone": dt.timezone.utc,
}

View file

@ -9,27 +9,30 @@ import logging
import math
from dataclasses import dataclass
from datetime import UTC, timedelta
from enum import Enum
from functools import cached_property, partial
from typing import TYPE_CHECKING, Any, Literal, cast
from typing import Any, Literal, cast
import astral.sun
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"
class SunEvent(str, Enum):
"""A set of sun events that happen during a day."""
SUN_EVENT_NOON = "solar_noon"
SUN_EVENT_MIDNIGHT = "solar_midnight"
# 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.
SUNRISE = "sunrise"
SUNSET = "sunset"
NOON = "solar_noon"
MIDNIGHT = "solar_midnight"
_ORDER = (SUN_EVENT_SUNRISE, SUN_EVENT_NOON, SUN_EVENT_SUNSET, SUN_EVENT_MIDNIGHT)
_ORDER = (SunEvent.SUNRISE, SunEvent.NOON, SunEvent.SUNSET, SunEvent.MIDNIGHT)
_ALLOWED_ORDERS = {_ORDER[i:] + _ORDER[:i] for i in range(len(_ORDER))}
utcnow: partial[datetime.datetime] = partial(datetime.datetime.now, UTC)
@ -43,7 +46,7 @@ class SunEvents:
"""Track the state of the sun and associated light settings."""
name: str
astral_location: astral.location.Location
astral_observer: astral.Observer
sunrise_time: datetime.time | None
min_sunrise_time: datetime.time | None
max_sunrise_time: datetime.time | None
@ -57,7 +60,7 @@ class SunEvents:
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)
astral.sun.sunrise(self.astral_observer, dt)
if self.sunrise_time is None
else self._replace_time(dt, self.sunrise_time)
) + self.sunrise_offset
@ -72,7 +75,7 @@ class SunEvents:
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)
astral.sun.sunset(self.astral_observer, dt)
if self.sunset_time is None
else self._replace_time(dt, self.sunset_time)
) + self.sunset_offset
@ -108,8 +111,8 @@ class SunEvents:
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)
solar_noon = astral.sun.noon(self.astral_observer, dt)
solar_midnight = astral.sun.midnight(self.astral_observer, dt)
return solar_noon, solar_midnight
if sunset is None:
@ -126,21 +129,21 @@ class SunEvents:
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]]:
def sun_events(self, dt: datetime.datetime) -> list[tuple[SunEvent, 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()),
events: list[tuple[SunEvent, float]] = [
(SunEvent.SUNRISE, sunrise.timestamp()),
(SunEvent.SUNSET, sunset.timestamp()),
(SunEvent.NOON, solar_noon.timestamp()),
(SunEvent.MIDNIGHT, solar_midnight.timestamp()),
]
self._validate_sun_event_order(events)
return events
def _validate_sun_event_order(self, events: list[tuple[str, float]]) -> None:
def _validate_sun_event_order(self, events: list[tuple[SunEvent, 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)
@ -154,7 +157,10 @@ class SunEvents:
_LOGGER.error(msg)
raise ValueError(msg)
def prev_and_next_events(self, dt: datetime.datetime) -> list[tuple[str, float]]:
def prev_and_next_events(
self,
dt: datetime.datetime,
) -> list[tuple[SunEvent, float]]:
"""Get the previous and next sun event."""
events = [
event
@ -171,23 +177,26 @@ class SunEvents:
(_, 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)
if next_event in (SunEvent.SUNSET, SunEvent.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
k = 1 if next_event in (SunEvent.SUNSET, SunEvent.NOON) else -1
return k * (1 - ((target_ts - h) / (h - x)) ** 2)
def closest_event(self, dt: datetime.datetime) -> tuple[str, float]:
def closest_event(
self,
dt: datetime.datetime,
) -> tuple[Literal[SunEvent.SUNRISE, SunEvent.SUNSET], 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
if SunEvent.SUNRISE in (prev_event, next_event):
ts_event = prev_ts if prev_event == SunEvent.SUNRISE else next_ts
return SunEvent.SUNRISE, ts_event
if SunEvent.SUNSET in (prev_event, next_event):
ts_event = prev_ts if prev_event == SunEvent.SUNSET else next_ts
return SunEvent.SUNSET, ts_event
msg = "No sunrise or sunset event found."
raise ValueError(msg)
@ -197,7 +206,7 @@ class SunLightSettings:
"""Track the state of the sun and associated light settings."""
name: str
astral_location: astral.location.Location
astral_observer: astral.Observer
adapt_until_sleep: bool
max_brightness: int
max_color_temp: int
@ -225,7 +234,7 @@ class SunLightSettings:
"""Return the SunEvents object."""
return SunEvents(
name=self.name,
astral_location=self.astral_location,
astral_observer=self.astral_observer,
sunrise_time=self.sunrise_time,
sunrise_offset=self.sunrise_offset,
min_sunrise_time=self.min_sunrise_time,
@ -249,7 +258,7 @@ class SunLightSettings:
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:
if event == SunEvent.SUNRISE:
brightness = scaled_tanh(
dt.timestamp() - ts_event,
x1=-dark,
@ -259,7 +268,7 @@ class SunLightSettings:
y_min=self.min_brightness,
y_max=self.max_brightness,
)
elif event == SUN_EVENT_SUNSET:
elif event == SunEvent.SUNSET:
brightness = scaled_tanh(
dt.timestamp() - ts_event,
x1=-light, # shifted timestamp for the start of sunset
@ -269,6 +278,9 @@ class SunLightSettings:
y_min=self.min_brightness,
y_max=self.max_brightness,
)
else:
msg = "Unsupported sun event"
raise ValueError(msg)
return clamp(brightness, self.min_brightness, self.max_brightness)
def _brightness_pct_linear(self, dt: datetime.datetime) -> float:
@ -277,7 +289,7 @@ class SunLightSettings:
# 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:
if event == SunEvent.SUNRISE:
brightness = lerp(
dt.timestamp() - ts_event,
x1=-dark,
@ -285,7 +297,7 @@ class SunLightSettings:
y1=self.min_brightness,
y2=self.max_brightness,
)
elif event == SUN_EVENT_SUNSET:
elif event == SunEvent.SUNSET:
brightness = lerp(
dt.timestamp() - ts_event,
x1=-light,
@ -293,6 +305,9 @@ class SunLightSettings:
y1=self.max_brightness,
y2=self.min_brightness,
)
else:
msg = "Unsupported sun event"
raise ValueError(msg)
return clamp(brightness, self.min_brightness, self.max_brightness)
def brightness_pct(self, dt: datetime.datetime, is_sleep: bool) -> float | None:
@ -356,7 +371,8 @@ class SunLightSettings:
force_rgb_color = True
else:
color_temp_kelvin = self.color_temp_kelvin(sun_position)
rgb_color = color_temperature_to_rgb(color_temp_kelvin)
r, g, b = color_temperature_to_rgb(color_temp_kelvin)
rgb_color = (round(r), round(g), round(b))
# 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)