diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index feb68d76f6aada..381aa549ff8eb8 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -5,6 +5,7 @@ import logging from typing import Any +from astral import SunDirection from astral.location import Elevation, Location from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -25,7 +26,12 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from .const import DOMAIN, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED +from .const import ( + DOMAIN, + SIGNAL_DURATIONS_CHANGED, + SIGNAL_EVENTS_CHANGED, + SIGNAL_POSITION_CHANGED, +) _LOGGER = logging.getLogger(__name__) @@ -43,6 +49,10 @@ STATE_ATTR_NEXT_NOON = "next_noon" STATE_ATTR_NEXT_RISING = "next_rising" STATE_ATTR_NEXT_SETTING = "next_setting" +STATE_ATTR_DAYLIGHT_DURATION = "daylight_duration" +STATE_ATTR_NIGHT_DURATION = "night_duration" +STATE_ATTR_TWILIGHT_SUNRISE_DURATION = "twilight_sunrise_duration" +STATE_ATTR_TWILIGHT_SUNSET_DURATION = "twilight_sunset_duration" # 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 @@ -125,6 +135,10 @@ class Sun(Entity): STATE_ATTR_NEXT_NOON, STATE_ATTR_NEXT_RISING, STATE_ATTR_NEXT_SETTING, + STATE_ATTR_DAYLIGHT_DURATION, + STATE_ATTR_NIGHT_DURATION, + STATE_ATTR_TWILIGHT_SUNRISE_DURATION, + STATE_ATTR_TWILIGHT_SUNSET_DURATION, } ) @@ -145,6 +159,10 @@ class Sun(Entity): solar_elevation: float solar_azimuth: float rising: bool + daylight_duration: timedelta + night_duration: timedelta + twilight_sunrise_duration: timedelta + twilight_sunset_duration: timedelta _next_change: datetime def __init__(self, hass: HomeAssistant) -> None: @@ -161,6 +179,7 @@ def __init__(self, hass: HomeAssistant) -> 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._update_durations_listener: CALLBACK_TYPE | None = None self._config_listener = self.hass.bus.async_listen( EVENT_CORE_CONFIG_UPDATE, self.update_location ) @@ -174,9 +193,17 @@ def update_location(self, _: Event | None = None, initial: bool = False) -> None return self.location = location self.elevation = elevation + # The attributes need to be initialized before the first call to update_events() to avoid an AttributeError. + self.daylight_duration = timedelta() + self.night_duration = timedelta() + self.twilight_sunrise_duration = timedelta() + self.twilight_sunset_duration = timedelta() if self._update_events_listener: self._update_events_listener() self.update_events() + if self._update_durations_listener: + self._update_durations_listener() + self.update_durations() @callback def remove_listeners(self) -> None: @@ -187,6 +214,8 @@ def remove_listeners(self) -> None: self._update_events_listener() if self._update_sun_position_listener: self._update_sun_position_listener() + if self._update_durations_listener: + self._update_durations_listener() @property def state(self) -> str: @@ -210,6 +239,10 @@ def extra_state_attributes(self) -> dict[str, Any]: STATE_ATTR_ELEVATION: self.solar_elevation, STATE_ATTR_AZIMUTH: self.solar_azimuth, STATE_ATTR_RISING: self.rising, + STATE_ATTR_DAYLIGHT_DURATION: self.daylight_duration.total_seconds(), + STATE_ATTR_NIGHT_DURATION: self.night_duration.total_seconds(), + STATE_ATTR_TWILIGHT_SUNRISE_DURATION: self.twilight_sunrise_duration.total_seconds(), + STATE_ATTR_TWILIGHT_SUNSET_DURATION: self.twilight_sunset_duration.total_seconds(), } def _check_event( @@ -327,3 +360,79 @@ def update_sun_position(self, now: datetime | None = None) -> None: self._update_sun_position_listener = event.async_track_point_in_utc_time( self.hass, self.update_sun_position, utc_point_in_time + delta ) + + @callback + def update_durations(self, now: datetime | None = None) -> None: + """Calculate the daylight, night, sunrise and sunset durations.""" + # First update sun position to assure the values are up-to-date. + if self._update_sun_position_listener: + self._update_sun_position_listener() + self.update_sun_position() + + # Grab current time in case system clock changed since last time we ran. + utc_point_in_time = dt_util.utcnow() + try: + start, end = self.location.daylight( + dt_util.as_local(utc_point_in_time).date() + ) + self.daylight_duration = end - start + except ValueError: # Catch Polar day / night / twilight. + self.daylight_duration = ( + timedelta(days=1) if self.solar_elevation > -0.833 else timedelta() + ) + try: + start, end = self.location.night(dt_util.as_local(utc_point_in_time).date()) + self.night_duration = end - start + except ValueError: # Catch Polar day / night / twilight. + self.night_duration = ( + timedelta(days=1) if self.solar_elevation <= -6.0 else timedelta() + ) + try: + start, end = self.location.twilight( + dt_util.as_local(utc_point_in_time).date(), SunDirection.RISING + ) + self.twilight_sunrise_duration = end - start + except ValueError: # Catch Polar day / night / twilight. + self.twilight_sunrise_duration = ( + timedelta(hours=12) + if self.solar_elevation > -6.0 and self.solar_elevation <= -0.833 + else timedelta() + ) + try: + start, end = self.location.twilight( + dt_util.as_local(utc_point_in_time).date(), SunDirection.SETTING + ) + self.twilight_sunset_duration = end - start + except ValueError: # Catch Polar day / night / twilight. + self.twilight_sunset_duration = ( + timedelta(hours=12) + if self.solar_elevation > -6.0 and self.solar_elevation <= -0.833 + else timedelta() + ) + + _LOGGER.debug( + "sun duarations_update@%s: daylight_duration=%s night_duration=%s twilight_sunrise_duration=%s twilight_sunset_duration=%s", + utc_point_in_time.isoformat(), + self.daylight_duration.total_seconds(), + self.night_duration.total_seconds(), + self.twilight_sunrise_duration.total_seconds(), + self.twilight_sunset_duration.total_seconds(), + ) + self.async_write_ha_state() + + async_dispatcher_send(self.hass, SIGNAL_DURATIONS_CHANGED) + + # Next update will occur on the beginning of the next day. + delta = timedelta( + seconds=( + ( + (24 - dt_util.as_local(utc_point_in_time).hour) * 60 + - dt_util.as_local(utc_point_in_time).minute + ) + * 60 + - dt_util.as_local(utc_point_in_time).second + ) + ) + self._update_durations_listener = event.async_track_point_in_utc_time( + self.hass, self.update_durations, utc_point_in_time + delta + ) diff --git a/homeassistant/components/sun/const.py b/homeassistant/components/sun/const.py index 245f8ca1d58fd8..e143378b905fc3 100644 --- a/homeassistant/components/sun/const.py +++ b/homeassistant/components/sun/const.py @@ -7,3 +7,4 @@ SIGNAL_POSITION_CHANGED = f"{DOMAIN}_position_changed" SIGNAL_EVENTS_CHANGED = f"{DOMAIN}_events_changed" +SIGNAL_DURATIONS_CHANGED = f"{DOMAIN}_durations_changed" diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 2a21b9d0246f48..c45afaf9c8bb4d 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -13,7 +13,7 @@ SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEGREE, EntityCategory +from homeassistant.const import DEGREE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -21,7 +21,12 @@ from homeassistant.helpers.typing import StateType from . import Sun -from .const import DOMAIN, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED +from .const import ( + DOMAIN, + SIGNAL_DURATIONS_CHANGED, + SIGNAL_EVENTS_CHANGED, + SIGNAL_POSITION_CHANGED, +) ENTITY_ID_SENSOR_FORMAT = SENSOR_DOMAIN + ".sun_{}" @@ -102,6 +107,50 @@ class SunSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, signal=SIGNAL_EVENTS_CHANGED, ), + SunSensorEntityDescription( + key="daylight_duration", + device_class=SensorDeviceClass.DURATION, + translation_key="daylight_duration", + icon="mdi:weather-sunny", + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.daylight_duration.total_seconds(), + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTime.SECONDS, + signal=SIGNAL_DURATIONS_CHANGED, + ), + SunSensorEntityDescription( + key="night_duration", + device_class=SensorDeviceClass.DURATION, + translation_key="night_duration", + icon="mdi:weather-night", + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.night_duration.total_seconds(), + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTime.SECONDS, + signal=SIGNAL_DURATIONS_CHANGED, + ), + SunSensorEntityDescription( + key="twilight_sunrise_duration", + device_class=SensorDeviceClass.DURATION, + translation_key="twilight_sunrise_duration", + icon="mdi:weather-sunset-up", + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.twilight_sunrise_duration.total_seconds(), + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTime.SECONDS, + signal=SIGNAL_DURATIONS_CHANGED, + ), + SunSensorEntityDescription( + key="twilight_sunset_duration", + device_class=SensorDeviceClass.DURATION, + translation_key="twilight_sunset_duration", + icon="mdi:weather-sunset-down", + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.twilight_sunset_duration.total_seconds(), + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTime.SECONDS, + signal=SIGNAL_DURATIONS_CHANGED, + ), ) diff --git a/homeassistant/components/sun/strings.json b/homeassistant/components/sun/strings.json index eb538eedf09703..dbfffded1bb9c6 100644 --- a/homeassistant/components/sun/strings.json +++ b/homeassistant/components/sun/strings.json @@ -29,7 +29,11 @@ "next_setting": { "name": "Next setting" }, "solar_azimuth": { "name": "Solar azimuth" }, "solar_elevation": { "name": "Solar elevation" }, - "solar_rising": { "name": "Solar rising" } + "solar_rising": { "name": "Solar rising" }, + "daylight_duration": { "name": "Daylight duration" }, + "night_duration": { "name": "Night duration" }, + "twilight_sunrise_duration": { "name": "Twilight sunrise duration" }, + "twilight_sunset_duration": { "name": "Twilight sunset duration" } } } }