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

@ -11,17 +11,15 @@ 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
class SunEvent(str, Enum):
"""A set of sun events that happen during a day."""
@ -48,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
@ -62,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
@ -77,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
@ -113,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:
@ -208,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
@ -236,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,

View file

@ -66,7 +66,6 @@ from homeassistant.helpers.event import (
async_track_time_interval,
)
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.sun import get_astral_location
from homeassistant.util import slugify
from homeassistant.util.color import (
color_temperature_to_rgb,
@ -164,6 +163,19 @@ if TYPE_CHECKING:
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import NoEventData, VolDictType
try:
from homeassistant.helpers.sun import get_astral_observer
except ImportError: # `get_astral_observer` was added in HA 2026.7
from astral import Observer
def get_astral_observer(hass: HomeAssistant) -> Observer:
"""Get an astral observer for the current HA configuration."""
return Observer(
hass.config.latitude,
hass.config.longitude,
hass.config.elevation,
)
_LOGGER = logging.getLogger(__name__)
@ -944,11 +956,11 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
)
self._multi_light_intercept = False
self._expand_light_groups() # updates manual control timers
location, _ = get_astral_location(self.hass)
observer = get_astral_observer(self.hass)
self._sun_light_settings = SunLightSettings(
name=self._name,
astral_location=location,
astral_observer=observer,
adapt_until_sleep=data[CONF_ADAPT_UNTIL_SLEEP],
max_brightness=data[CONF_MAX_BRIGHTNESS],
max_color_temp=data[CONF_MAX_COLOR_TEMP],

View file

@ -12,9 +12,13 @@ uv pip install -r core/requirements_test.txt
# HA 2026.4+ imports aiohasupervisor from tests/components/conftest.py
# but pins it in requirements_test_all.txt instead of requirements_test.txt.
# HA 2026.8+ removed requirements_test_all.txt (home-assistant/core#171530);
# the pin lives in requirements_all.txt there.
aiohasupervisor_req=""
if [[ -f core/requirements_test_all.txt ]]; then
aiohasupervisor_req="$(grep -m1 '^aiohasupervisor' core/requirements_test_all.txt || true)"
elif [[ -f core/requirements_all.txt ]]; then
aiohasupervisor_req="$(grep -m1 '^aiohasupervisor' core/requirements_all.txt || true)"
fi
if [[ -n "${aiohasupervisor_req}" ]]; then
uv pip install "${aiohasupervisor_req}"

View file

@ -7,6 +7,10 @@ deps = defaultdict(list)
components, packages = [], []
requirements = Path("core") / "requirements_test_all.txt"
if not requirements.exists():
# Removed from HA core in 2026.8 (home-assistant/core#171530); the same
# per-integration annotations live in requirements_all.txt.
requirements = Path("core") / "requirements_all.txt"
with requirements.open() as f:
lines = f.readlines()

View file

@ -9,7 +9,7 @@ from homeassistant.components.adaptive_lighting.color_and_brightness import (
SunEvents,
)
# Create a mock astral_location object
# Create a mock astral location object (its `.observer` is passed to `SunEvents`)
location = Location(LocationInfo())
LAT_LONG_TZS = [
@ -40,7 +40,7 @@ def test_replace_time(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
@ -61,7 +61,7 @@ def test_sunrise_without_offset(tzinfo_and_location):
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
@ -79,7 +79,7 @@ def test_sun_position_no_fixed_sunset_and_sunrise(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
@ -107,7 +107,7 @@ def test_sun_position_fixed_sunset_and_sunrise(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=dt.time(6, 0),
min_sunrise_time=None,
max_sunrise_time=None,
@ -134,7 +134,7 @@ def test_noon_and_midnight(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
@ -153,7 +153,7 @@ def test_sun_events(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
@ -173,7 +173,7 @@ def test_prev_and_next_events(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
@ -193,7 +193,7 @@ def test_closest_event(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,

View file

@ -97,6 +97,7 @@ except ImportError:
# HA < 2025.8
from homeassistant.components.template.light import LightTemplate
from homeassistant.components.template import light as template_light
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
ATTR_AREA_ID,
@ -121,6 +122,10 @@ from homeassistant.util.color import color_temperature_mired_to_kelvin
from tests.common import MockConfigEntry
# HA 2026.6 removed the legacy `light: platform: template` YAML format
# (home-assistant/core#169615); use the modern `template:` format there.
LEGACY_TEMPLATE_LIGHTS = hasattr(template_light, "PLATFORM_SCHEMA")
_LOGGER = logging.getLogger(__name__)
SUNRISE = datetime.datetime(
@ -200,37 +205,65 @@ async def setup_switch(hass, extra_data) -> tuple[MockConfigEntry, AdaptiveSwitc
async def setup_lights(hass: HomeAssistant, with_group: bool = False):
"""Set up 3 light entities using the 'template' platform."""
n = 3 if not with_group else 5 # last 2 will be put in a group
template_lights = {
f"light_{i}": {
"unique_id": f"light_{i}",
"friendly_name": f"light_{i}",
"turn_on": None,
"turn_off": None,
"set_level": None,
"set_temperature": None,
"set_color": None,
}
for i in range(1, n + 1)
group_platform = {
"platform": "group",
"entities": ["light.light_4", "light.light_5"],
"name": "Light Group",
"unique_id": "light_group",
"all": "false",
}
template_lights["light_3"]["supports_transition_template"] = True
platforms = [{"platform": "template", "lights": template_lights}]
if with_group:
platforms.append(
{
"platform": "group",
"entities": ["light.light_4", "light.light_5"],
"name": "Light Group",
"unique_id": "light_group",
"all": "false",
},
if LEGACY_TEMPLATE_LIGHTS:
template_lights = {
f"light_{i}": {
"unique_id": f"light_{i}",
"friendly_name": f"light_{i}",
"turn_on": None,
"turn_off": None,
"set_level": None,
"set_temperature": None,
"set_color": None,
}
for i in range(1, n + 1)
}
template_lights["light_3"]["supports_transition_template"] = True
platforms = [{"platform": "template", "lights": template_lights}]
if with_group:
platforms.append(group_platform)
await async_setup_component(
hass,
LIGHT_DOMAIN,
{LIGHT_DOMAIN: platforms},
)
else:
if with_group:
# Setting up `template` below also sets up the `light` domain,
# after which `async_setup_component(hass, LIGHT_DOMAIN, ...)`
# would be a no-op, so the group platform must be set up first.
await async_setup_component(
hass,
LIGHT_DOMAIN,
{LIGHT_DOMAIN: [group_platform]},
)
modern_lights = [
{
"name": f"light_{i}",
"unique_id": f"light_{i}",
"turn_on": None,
"turn_off": None,
"set_level": None,
"set_temperature": None,
"set_hs": None,
}
for i in range(1, n + 1)
]
modern_lights[2]["supports_transition"] = "{{ true }}"
await async_setup_component(
hass,
"template",
{"template": {"light": modern_lights}},
)
await async_setup_component(
hass,
LIGHT_DOMAIN,
{LIGHT_DOMAIN: platforms},
)
await hass.async_block_till_done()
if with_group:

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)