"""Support for functionality to keep track of the sun.""" from datetime import timedelta import logging from homeassistant.const import ( CONF_ELEVATION, EVENT_CORE_CONFIG_UPDATE, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) from homeassistant.core import callback from homeassistant.helpers import event from homeassistant.helpers.entity import Entity from homeassistant.helpers.sun import ( get_astral_location, get_location_astral_event_next, ) from homeassistant.util import dt as dt_util # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) DOMAIN = "sun" ENTITY_ID = "sun.sun" STATE_ABOVE_HORIZON = "above_horizon" STATE_BELOW_HORIZON = "below_horizon" 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" # 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 * 2), PHASE_TWILIGHT: timedelta(minutes=4), PHASE_SMALL_DAY: timedelta(minutes=2), PHASE_DAY: timedelta(minutes=4), } async def async_setup(hass, config): """Track the state of the sun.""" if config.get(CONF_ELEVATION) is not None: _LOGGER.warning( "Elevation is now configured in Home Assistant core. " "See https://www.home-assistant.io/docs/configuration/basic/" ) Sun(hass) return True class Sun(Entity): """Representation of the Sun.""" entity_id = ENTITY_ID def __init__(self, hass): """Initialize the sun.""" self.hass = hass self.location = None self.elevation = 0.0 self._state = self.next_rising = self.next_setting = None self.next_dawn = self.next_dusk = None self.next_midnight = self.next_noon = None self.solar_elevation = self.solar_azimuth = None self.rising = self.phase = None self._next_change = None def update_location(_event): location, elevation = get_astral_location(self.hass) if location == self.location: return self.location = location self.elevation = elevation self.update_events() update_location(None) self.hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, update_location) @property def name(self): """Return the name.""" return "Sun" @property def state(self): """Return the state of the sun.""" # 0.8333 is the same value as astral uses if self.solar_elevation > -0.833: return STATE_ABOVE_HORIZON return STATE_BELOW_HORIZON @property def extra_state_attributes(self): """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, sun_event, before): next_utc = get_location_astral_event_next( self.location, self.elevation, sun_event, utc_point_in_time ) if next_utc < self._next_change: self._next_change = next_utc self.phase = before return next_utc @callback def update_events(self, now=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.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.next_dawn = self._check_event( utc_point_in_time, "dawn", PHASE_NAUTICAL_TWILIGHT ) 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.next_noon = self._check_event(utc_point_in_time, "noon", None) self._check_event(utc_point_in_time, "dusk", PHASE_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_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) if elevation >= 10: self.phase = PHASE_DAY elif elevation >= 0: self.phase = PHASE_SMALL_DAY elif elevation >= -6: self.phase = PHASE_TWILIGHT elif elevation >= -12: self.phase = PHASE_NAUTICAL_TWILIGHT elif elevation >= -18: 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 ) self.update_sun_position() # Set timer for the next solar event 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=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( self.location.solar_azimuth(utc_point_in_time, self.elevation), 2 ) self.solar_elevation = round( self.location.solar_elevation(utc_point_in_time, self.elevation), 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() # Next update as per the current 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: return event.async_track_point_in_utc_time( self.hass, self.update_sun_position, utc_point_in_time + delta )