"""Support for functionality to keep track of the sun.""" from datetime import datetime, timedelta import logging from typing import Any, override from astral import Observer import astral.sun from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EVENT_CORE_CONFIG_UPDATE, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback 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_observer, get_observer_astral_event_next, ) from homeassistant.util import dt as dt_util from .const import ( ELEVATION_ASTRONOMICAL, ELEVATION_CIVIL, ELEVATION_HORIZON, ELEVATION_NAUTICAL, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED, STATE_ABOVE_HORIZON, STATE_BELOW_HORIZON, ) type SunConfigEntry = ConfigEntry[Sun] _LOGGER = logging.getLogger(__name__) ENTITY_ID = "sun.sun" STATE_ATTR_AZIMUTH = "azimuth" STATE_ATTR_ELEVATION = "elevation" STATE_ATTR_RISING = "rising" STATE_ATTR_NEXT_DAWN = "next_dawn" STATE_ATTR_NEXT_DUSK = "next_dusk" STATE_ATTR_NEXT_MIDNIGHT = "next_midnight" STATE_ATTR_NEXT_NOON = "next_noon" STATE_ATTR_NEXT_RISING = "next_rising" STATE_ATTR_NEXT_SETTING = "next_setting" # The algorithm used here is somewhat complicated. It aims to cut down # the number of sensor updates over the day. It's documented best in # the PR for the change, see the Discussion section of: # https://github.com/home-assistant/core/pull/23832 # As documented in wikipedia: https://en.wikipedia.org/wiki/Twilight # sun is: # < -18° of horizon - all stars visible PHASE_NIGHT = "night" # 18°-12° - some stars not visible PHASE_ASTRONOMICAL_TWILIGHT = "astronomical_twilight" # 12°-6° - horizon visible PHASE_NAUTICAL_TWILIGHT = "nautical_twilight" # 6°-0° - objects visible PHASE_TWILIGHT = "twilight" # 0°-10° above horizon, sun low on horizon PHASE_SMALL_DAY = "small_day" # > 10° above horizon PHASE_DAY = "day" # Sun elevation (degrees above the horizon) at the start of the "small day" phase. _ELEVATION_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. _PHASE_UPDATES = { PHASE_NIGHT: timedelta(minutes=4 * 5), PHASE_ASTRONOMICAL_TWILIGHT: timedelta(minutes=4 * 2), PHASE_NAUTICAL_TWILIGHT: timedelta(minutes=4), PHASE_TWILIGHT: timedelta(minutes=2), PHASE_SMALL_DAY: timedelta(minutes=2), PHASE_DAY: timedelta(minutes=4), } class Sun(Entity): """Representation of the Sun.""" _unrecorded_attributes = frozenset( { STATE_ATTR_AZIMUTH, STATE_ATTR_ELEVATION, STATE_ATTR_RISING, STATE_ATTR_NEXT_DAWN, STATE_ATTR_NEXT_DUSK, STATE_ATTR_NEXT_MIDNIGHT, STATE_ATTR_NEXT_NOON, STATE_ATTR_NEXT_RISING, STATE_ATTR_NEXT_SETTING, } ) _attr_name = "Sun" entity_id = ENTITY_ID observer: Observer next_rising: datetime next_setting: datetime next_dawn: datetime next_dusk: datetime next_midnight: datetime next_noon: datetime solar_elevation: float solar_azimuth: float rising: bool _next_change: datetime def __init__(self, hass: HomeAssistant) -> None: """Initialize the sun.""" self.hass = hass self.phase: str | None = None self._config_listener: CALLBACK_TYPE | None = None self._update_events_listener: CALLBACK_TYPE | None = None self._update_sun_position_listener: CALLBACK_TYPE | None = None self._config_listener = self.hass.bus.async_listen( EVENT_CORE_CONFIG_UPDATE, self.update_location ) @override async def async_added_to_hass(self) -> None: """Update after entity has been added.""" await super().async_added_to_hass() self.update_location(initial=True) @callback def update_location(self, _: Event | None = None, initial: bool = False) -> None: """Update location.""" observer = get_astral_observer(self.hass) if not initial and observer == self.observer: return self.observer = observer if self._update_events_listener: self._update_events_listener() self.update_events() @callback def remove_listeners(self) -> None: """Remove listeners.""" if self._config_listener: self._config_listener() if self._update_events_listener: self._update_events_listener() if self._update_sun_position_listener: self._update_sun_position_listener() @property @override def state(self) -> str: """Return the state of the sun.""" if self.solar_elevation > ELEVATION_HORIZON: return STATE_ABOVE_HORIZON return STATE_BELOW_HORIZON @property @override def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sun.""" return { STATE_ATTR_NEXT_DAWN: self.next_dawn.isoformat(), STATE_ATTR_NEXT_DUSK: self.next_dusk.isoformat(), STATE_ATTR_NEXT_MIDNIGHT: self.next_midnight.isoformat(), STATE_ATTR_NEXT_NOON: self.next_noon.isoformat(), STATE_ATTR_NEXT_RISING: self.next_rising.isoformat(), STATE_ATTR_NEXT_SETTING: self.next_setting.isoformat(), STATE_ATTR_ELEVATION: self.solar_elevation, STATE_ATTR_AZIMUTH: self.solar_azimuth, STATE_ATTR_RISING: self.rising, } def _check_event( self, utc_point_in_time: datetime, sun_event: str, before: str | None, elevation: float | None = None, ) -> datetime: # astral takes a depression (degrees below the horizon), i.e. the # negated elevation. depression = None if elevation is None else -elevation 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 self.phase = before return next_utc @callback def update_events(self, now: datetime | None = None) -> None: """Update the attributes containing solar events.""" # Grab current time in case system clock changed since last time we ran. utc_point_in_time = dt_util.utcnow() self._next_change = utc_point_in_time + timedelta(days=400) # Work our way around the solar cycle, figure out the next # phase. Some of these are stored. self._check_event( utc_point_in_time, "dawn", PHASE_NIGHT, ELEVATION_ASTRONOMICAL ) self._check_event( utc_point_in_time, "dawn", PHASE_ASTRONOMICAL_TWILIGHT, ELEVATION_NAUTICAL ) self.next_dawn = self._check_event( utc_point_in_time, "dawn", PHASE_NAUTICAL_TWILIGHT, ELEVATION_CIVIL ) self.next_rising = self._check_event( utc_point_in_time, SUN_EVENT_SUNRISE, PHASE_TWILIGHT ) self._check_event( utc_point_in_time, "dawn", PHASE_SMALL_DAY, _ELEVATION_SMALL_DAY ) self.next_noon = self._check_event(utc_point_in_time, "noon", None) self._check_event(utc_point_in_time, "dusk", PHASE_DAY, _ELEVATION_SMALL_DAY) self.next_setting = self._check_event( utc_point_in_time, SUN_EVENT_SUNSET, PHASE_SMALL_DAY ) self.next_dusk = self._check_event( utc_point_in_time, "dusk", PHASE_TWILIGHT, ELEVATION_CIVIL ) self._check_event( utc_point_in_time, "dusk", PHASE_NAUTICAL_TWILIGHT, ELEVATION_NAUTICAL ) self._check_event( utc_point_in_time, "dusk", PHASE_ASTRONOMICAL_TWILIGHT, ELEVATION_ASTRONOMICAL, ) self.next_midnight = self._check_event(utc_point_in_time, "midnight", None) # 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 = astral.sun.elevation(self.observer, self._next_change) if elevation >= 10: self.phase = PHASE_DAY elif elevation >= 0: self.phase = PHASE_SMALL_DAY elif elevation >= ELEVATION_CIVIL: self.phase = PHASE_TWILIGHT elif elevation >= ELEVATION_NAUTICAL: self.phase = PHASE_NAUTICAL_TWILIGHT elif elevation >= ELEVATION_ASTRONOMICAL: self.phase = PHASE_ASTRONOMICAL_TWILIGHT else: self.phase = PHASE_NIGHT self.rising = self.next_noon < self.next_midnight _LOGGER.debug( "sun phase_update@%s: phase=%s", utc_point_in_time.isoformat(), self.phase ) if self._update_sun_position_listener: self._update_sun_position_listener() self.update_sun_position() async_dispatcher_send(self.hass, SIGNAL_EVENTS_CHANGED) # Set timer for the next solar event self._update_events_listener = event.async_track_point_in_utc_time( self.hass, self.update_events, self._next_change ) _LOGGER.debug("next time: %s", self._next_change.isoformat()) @callback def update_sun_position(self, now: datetime | None = None) -> None: """Calculate the position of the sun.""" # Grab current time in case system clock changed since last time we ran. utc_point_in_time = dt_util.utcnow() self.solar_azimuth = round( astral.sun.azimuth(self.observer, utc_point_in_time), 2 ) self.solar_elevation = round( astral.sun.elevation(self.observer, utc_point_in_time), 2 ) _LOGGER.debug( "sun position_update@%s: elevation=%s azimuth=%s", utc_point_in_time.isoformat(), self.solar_elevation, self.solar_azimuth, ) self.async_write_ha_state() async_dispatcher_send(self.hass, SIGNAL_POSITION_CHANGED) # Next update as per the current phase assert self.phase delta = _PHASE_UPDATES[self.phase] # if the next update is within 1.25 of the next # position update just drop it if utc_point_in_time + delta * 1.25 > self._next_change: self._update_sun_position_listener = None return self._update_sun_position_listener = event.async_track_point_in_utc_time( self.hass, self.update_sun_position, utc_point_in_time + delta )