Simplify sun entity (#174464)

Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
Erik Montnemery
2026-06-22 20:29:59 +02:00
committed by GitHub
co-authored by Franck Nijhof
parent e1c8f3da78
commit 91f4168439
3 changed files with 140 additions and 47 deletions
+48 -31
View File
@@ -4,7 +4,8 @@ from datetime import datetime, timedelta
import logging
from typing import Any
from astral.location import Elevation, Location
from astral import Observer
import astral.sun
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -17,8 +18,8 @@ from homeassistant.helpers import event
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.sun import (
get_astral_location,
get_location_astral_event_next,
get_astral_observer,
get_observer_astral_event_next,
)
from homeassistant.util import dt as dt_util
@@ -66,6 +67,13 @@ PHASE_SMALL_DAY = "small_day"
# > 10° above horizon
PHASE_DAY = "day"
# Depression angle (degrees below the horizon) of the sun at each dawn/dusk
# phase boundary. A negative value means the sun is above the horizon.
DEPRESSION_ASTRONOMICAL = 18.0
DEPRESSION_NAUTICAL = 12.0
DEPRESSION_CIVIL = 6.0
DEPRESSION_SMALL_DAY = -10.0
# 4 mins is one degree of arc change of the sun on its circle.
# During the night and the middle of the day we don't update
# that much since it's not important.
@@ -99,8 +107,7 @@ class Sun(Entity):
_attr_name = "Sun"
entity_id = ENTITY_ID
location: Location
elevation: Elevation
observer: Observer
next_rising: datetime
next_setting: datetime
next_dawn: datetime
@@ -132,11 +139,10 @@ class Sun(Entity):
@callback
def update_location(self, _: Event | None = None, initial: bool = False) -> None:
"""Update location."""
location, elevation = get_astral_location(self.hass)
if not initial and location == self.location:
observer = get_astral_observer(self.hass)
if not initial and observer == self.observer:
return
self.location = location
self.elevation = elevation
self.observer = observer
if self._update_events_listener:
self._update_events_listener()
self.update_events()
@@ -176,10 +182,14 @@ class Sun(Entity):
}
def _check_event(
self, utc_point_in_time: datetime, sun_event: str, before: str | None
self,
utc_point_in_time: datetime,
sun_event: str,
before: str | None,
depression: float | None = None,
) -> datetime:
next_utc = get_location_astral_event_next(
self.location, self.elevation, sun_event, utc_point_in_time
next_utc = get_observer_astral_event_next(
self.observer, sun_event, utc_point_in_time, depression=depression
)
if next_utc < self._next_change:
self._next_change = next_utc
@@ -195,39 +205,46 @@ class Sun(Entity):
# Work our way around the solar cycle, figure out the next
# phase. Some of these are stored.
self.location.solar_depression = "astronomical"
self._check_event(utc_point_in_time, "dawn", PHASE_NIGHT)
self.location.solar_depression = "nautical"
self._check_event(utc_point_in_time, "dawn", PHASE_ASTRONOMICAL_TWILIGHT)
self.location.solar_depression = "civil"
self._check_event(
utc_point_in_time, "dawn", PHASE_NIGHT, DEPRESSION_ASTRONOMICAL
)
self._check_event(
utc_point_in_time, "dawn", PHASE_ASTRONOMICAL_TWILIGHT, DEPRESSION_NAUTICAL
)
self.next_dawn = self._check_event(
utc_point_in_time, "dawn", PHASE_NAUTICAL_TWILIGHT
utc_point_in_time, "dawn", PHASE_NAUTICAL_TWILIGHT, DEPRESSION_CIVIL
)
self.next_rising = self._check_event(
utc_point_in_time, SUN_EVENT_SUNRISE, PHASE_TWILIGHT
)
self.location.solar_depression = -10
self._check_event(utc_point_in_time, "dawn", PHASE_SMALL_DAY)
self._check_event(
utc_point_in_time, "dawn", PHASE_SMALL_DAY, DEPRESSION_SMALL_DAY
)
self.next_noon = self._check_event(utc_point_in_time, "noon", None)
self._check_event(utc_point_in_time, "dusk", PHASE_DAY)
self._check_event(utc_point_in_time, "dusk", PHASE_DAY, DEPRESSION_SMALL_DAY)
self.next_setting = self._check_event(
utc_point_in_time, SUN_EVENT_SUNSET, PHASE_SMALL_DAY
)
self.location.solar_depression = "civil"
self.next_dusk = self._check_event(utc_point_in_time, "dusk", PHASE_TWILIGHT)
self.location.solar_depression = "nautical"
self._check_event(utc_point_in_time, "dusk", PHASE_NAUTICAL_TWILIGHT)
self.location.solar_depression = "astronomical"
self._check_event(utc_point_in_time, "dusk", PHASE_ASTRONOMICAL_TWILIGHT)
self.next_dusk = self._check_event(
utc_point_in_time, "dusk", PHASE_TWILIGHT, DEPRESSION_CIVIL
)
self._check_event(
utc_point_in_time, "dusk", PHASE_NAUTICAL_TWILIGHT, DEPRESSION_NAUTICAL
)
self._check_event(
utc_point_in_time,
"dusk",
PHASE_ASTRONOMICAL_TWILIGHT,
DEPRESSION_ASTRONOMICAL,
)
self.next_midnight = self._check_event(utc_point_in_time, "midnight", None)
self.location.solar_depression = "civil"
# if the event was solar midday or midnight, phase will now
# be None. Solar noon doesn't always happen when the sun is
# even in the day at the poles, so we can't rely on it.
# Need to calculate phase if next is noon or midnight
if self.phase is None:
elevation = self.location.solar_elevation(self._next_change, self.elevation)
elevation = astral.sun.elevation(self.observer, self._next_change)
if elevation >= 10:
self.phase = PHASE_DAY
elif elevation >= 0:
@@ -263,10 +280,10 @@ class Sun(Entity):
# Grab current time in case system clock changed since last time we ran.
utc_point_in_time = dt_util.utcnow()
self.solar_azimuth = round(
self.location.solar_azimuth(utc_point_in_time, self.elevation), 2
astral.sun.azimuth(self.observer, utc_point_in_time), 2
)
self.solar_elevation = round(
self.location.solar_elevation(utc_point_in_time, self.elevation), 2
astral.sun.elevation(self.observer, utc_point_in_time), 2
)
_LOGGER.debug(
+50 -16
View File
@@ -8,15 +8,19 @@ from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET
from homeassistant.core import HomeAssistant, callback
from homeassistant.util import dt as dt_util
from .deprecation import deprecated_function
if TYPE_CHECKING:
import astral
import astral.location
ELEVATION_AGNOSTIC_EVENTS = ("noon", "midnight")
type _AstralSunEventCallable = Callable[..., datetime.datetime]
@deprecated_function(
"homeassistant.helpers.sun.get_astral_observer",
breaks_in_ha_version="2027.7",
)
@callback
def get_astral_location(
hass: HomeAssistant,
@@ -33,6 +37,14 @@ def get_astral_location(
return Location(LocationInfo("", "", timezone, latitude, longitude)), elevation
@callback
def get_astral_observer(hass: HomeAssistant) -> astral.Observer:
"""Get an astral observer for the current Home Assistant configuration."""
from astral import Observer # noqa: PLC0415
return Observer(hass.config.latitude, hass.config.longitude, hass.config.elevation)
@callback
def get_astral_event_next(
hass: HomeAssistant,
@@ -41,12 +53,14 @@ def get_astral_event_next(
offset: datetime.timedelta | None = None,
) -> datetime.datetime:
"""Calculate the next specified solar event."""
location, elevation = get_astral_location(hass)
return get_location_astral_event_next(
location, elevation, event, utc_point_in_time, offset
)
observer = get_astral_observer(hass)
return get_observer_astral_event_next(observer, event, utc_point_in_time, offset)
@deprecated_function(
"homeassistant.helpers.sun.get_observer_astral_event_next",
breaks_in_ha_version="2027.7",
)
@callback
def get_location_astral_event_next(
location: astral.location.Location,
@@ -56,6 +70,25 @@ def get_location_astral_event_next(
offset: datetime.timedelta | None = None,
) -> datetime.datetime:
"""Calculate the next specified solar event."""
from astral import Observer # noqa: PLC0415
observer = Observer(location.latitude, location.longitude, elevation)
depression = location.solar_depression if event in ("dawn", "dusk") else None
return get_observer_astral_event_next(
observer, event, utc_point_in_time, offset, depression
)
@callback
def get_observer_astral_event_next(
observer: astral.Observer,
event: str,
utc_point_in_time: datetime.datetime | None = None,
offset: datetime.timedelta | None = None,
depression: float | None = None,
) -> datetime.datetime:
"""Calculate the next specified solar event."""
import astral.sun # noqa: PLC0415
if offset is None:
offset = datetime.timedelta()
@@ -63,16 +96,18 @@ def get_location_astral_event_next(
if utc_point_in_time is None:
utc_point_in_time = dt_util.utcnow()
kwargs: dict[str, Any] = {"local": False}
if event not in ELEVATION_AGNOSTIC_EVENTS:
kwargs["observer_elevation"] = elevation
event_func = cast(_AstralSunEventCallable, getattr(astral.sun, event))
kwargs: dict[str, Any] = {}
if depression is not None:
kwargs["depression"] = depression
mod = -1
first_err = None
while mod < 367:
try:
next_dt = (
cast(_AstralSunEventCallable, getattr(location, event))(
event_func(
observer,
dt_util.as_local(utc_point_in_time).date()
+ datetime.timedelta(days=mod),
**kwargs,
@@ -97,7 +132,9 @@ def get_astral_event_date(
date: datetime.date | datetime.datetime | None = None,
) -> datetime.datetime | None:
"""Calculate the astral event time for the specified date."""
location, elevation = get_astral_location(hass)
import astral.sun # noqa: PLC0415
observer = get_astral_observer(hass)
if date is None:
date = dt_util.now().date()
@@ -105,12 +142,9 @@ def get_astral_event_date(
if isinstance(date, datetime.datetime):
date = dt_util.as_local(date).date()
kwargs: dict[str, Any] = {"local": False}
if event not in ELEVATION_AGNOSTIC_EVENTS:
kwargs["observer_elevation"] = elevation
event_func = cast(_AstralSunEventCallable, getattr(astral.sun, event))
try:
return cast(_AstralSunEventCallable, getattr(location, event))(date, **kwargs)
return event_func(observer, date)
except ValueError:
# Event never occurs for specified date.
return None
+42
View File
@@ -3,6 +3,7 @@
from datetime import datetime, timedelta
from astral import LocationInfo
from astral.location import Location
import astral.sun
from freezegun import freeze_time
import pytest
@@ -200,3 +201,44 @@ def test_impossible_elevation(hass: HomeAssistant) -> None:
with pytest.raises(ValueError):
sun.get_astral_event_next(hass, SUN_EVENT_SUNRISE, june)
def test_deprecated_get_astral_location(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test the deprecated get_astral_location helper."""
location, elevation = sun.get_astral_location(hass)
observer = sun.get_astral_observer(hass)
assert location.latitude == observer.latitude
assert location.longitude == observer.longitude
assert elevation == observer.elevation
assert (
"The deprecated function get_astral_location was called. It will be removed "
"in HA Core 2027.7. Use homeassistant.helpers.sun.get_astral_observer instead"
) in caplog.text
def test_deprecated_get_location_astral_event_next(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test the deprecated get_location_astral_event_next helper."""
utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC)
location = Location(
LocationInfo(
"",
"",
str(hass.config.time_zone),
hass.config.latitude,
hass.config.longitude,
)
)
assert sun.get_location_astral_event_next(
location, hass.config.elevation, SUN_EVENT_SUNRISE, utc_now
) == sun.get_astral_event_next(hass, SUN_EVENT_SUNRISE, utc_now)
assert (
"The deprecated function get_location_astral_event_next was called. It will "
"be removed in HA Core 2027.7. Use "
"homeassistant.helpers.sun.get_observer_astral_event_next instead"
) in caplog.text