"""Provides triggers for the sun.""" from datetime import datetime, timedelta from typing import Any, cast, override import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, CONF_EVENT, CONF_FOR, CONF_OFFSET, CONF_OPTIONS, CONF_TYPE, DEGREE, EVENT_CORE_CONFIG_UPDATE, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.automation import ( DomainSpec, move_top_level_schema_fields_to_options, ) from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.selector import ( NumericThresholdMode, NumericThresholdSelector, NumericThresholdSelectorConfig, ) from homeassistant.helpers.sun import ( get_astral_event_next, get_astral_observer, get_observer_astral_event_next, ) from homeassistant.helpers.trigger import ( EntityNumericalStateChangedTriggerBase, EntityNumericalStateCrossedThresholdTriggerBase, EntityNumericalStateTriggerBase, Trigger, TriggerActionRunner, TriggerConfig, TriggerNotTriggeredReporter, ) from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from .const import ( DOMAIN, ELEVATION_ASTRONOMICAL, ELEVATION_CIVIL, ELEVATION_NAUTICAL, STATE_ATTR_ELEVATION, ) # Names of solar events supported by the astral.sun module _SUN_EVENT_SOLAR_NOON = "noon" _SUN_EVENT_SOLAR_MIDNIGHT = "midnight" _SUN_EVENT_DAWN = "dawn" _SUN_EVENT_DUSK = "dusk" _TWILIGHT_CIVIL = "civil" _TWILIGHT_NAUTICAL = "nautical" _TWILIGHT_ASTRONOMICAL = "astronomical" CONF_OFFSET_TYPE = "offset_type" OFFSET_TYPE_BEFORE = "before" OFFSET_TYPE_AFTER = "after" # Offset options shared by the solar event triggers. A positive offset combined # with an offset type of "before" fires earlier than the event; "after" later. _OFFSET_OPTIONS: dict[vol.Marker, Any] = { vol.Required(CONF_OFFSET, default=timedelta(0)): cv.time_period, vol.Required(CONF_OFFSET_TYPE, default=OFFSET_TYPE_BEFORE): vol.In( {OFFSET_TYPE_BEFORE, OFFSET_TYPE_AFTER} ), } # Sun elevation at each twilight boundary. _TWILIGHT_ELEVATIONS = { _TWILIGHT_CIVIL: ELEVATION_CIVIL, _TWILIGHT_NAUTICAL: ELEVATION_NAUTICAL, _TWILIGHT_ASTRONOMICAL: ELEVATION_ASTRONOMICAL, } # The sun is a singleton, so the elevation triggers always target sun.sun # instead of asking the user to pick an entity. _SUN_ENTITY_ID = f"{DOMAIN}.{DOMAIN}" _ELEVATION_DOMAIN_SPECS = {DOMAIN: DomainSpec(value_source=STATE_ATTR_ELEVATION)} _ELEVATION_CHANGED_TRIGGER_SCHEMA = vol.Schema( { vol.Required(CONF_OPTIONS, default=dict): { vol.Required("threshold"): NumericThresholdSelector( NumericThresholdSelectorConfig(mode=NumericThresholdMode.CHANGED) ), } } ) # Unlike the generic numerical triggers there is no behavior option: a behavior # (each/first/all) is only meaningful across multiple targeted entities. _ELEVATION_CROSSED_TRIGGER_SCHEMA = vol.Schema( { vol.Required(CONF_OPTIONS, default=dict): { vol.Required("threshold"): NumericThresholdSelector( NumericThresholdSelectorConfig(mode=NumericThresholdMode.CROSSED) ), vol.Optional(CONF_FOR): cv.positive_time_period, } } ) class SunElevationTrigger(EntityNumericalStateTriggerBase): """Trigger for the sun's elevation.""" _domain_specs = _ELEVATION_DOMAIN_SPECS _valid_unit = DEGREE def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize the trigger, targeting the singleton sun entity.""" super().__init__( hass, TriggerConfig( key=config.key, target={ATTR_ENTITY_ID: [_SUN_ENTITY_ID]}, options=config.options, ), ) class SunElevationChangedTrigger( SunElevationTrigger, EntityNumericalStateChangedTriggerBase ): """Trigger for changes to the sun's elevation.""" _schema = _ELEVATION_CHANGED_TRIGGER_SCHEMA class SunElevationCrossedTrigger( SunElevationTrigger, EntityNumericalStateCrossedThresholdTriggerBase ): """Trigger for the sun's elevation crossing a threshold.""" _schema = _ELEVATION_CROSSED_TRIGGER_SCHEMA _EVENT_TRIGGER_SCHEMA = vol.Schema( {vol.Required(CONF_OPTIONS, default=dict): {**_OFFSET_OPTIONS}} ) class SunEventTrigger(Trigger): """Trigger that fires at a solar event time.""" _event: str _schema: vol.Schema = _EVENT_TRIGGER_SCHEMA @override @classmethod async def async_validate_config( cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" return cast(ConfigType, cls._schema(config)) def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize the trigger.""" super().__init__(hass, config) self._options = config.options or {} offset = self._options.get(CONF_OFFSET) or timedelta(0) if self._options.get(CONF_OFFSET_TYPE) == OFFSET_TYPE_BEFORE: offset = -offset self._offset = offset def _get_next_event(self, utc_point_in_time: datetime) -> datetime: """Return the next time this solar event occurs.""" return get_astral_event_next( self._hass, self._event, utc_point_in_time, self._offset ) def _action_payload(self) -> dict[str, Any]: """Return extra trigger payload passed to the action.""" return {} @override async def async_attach_runner( self, run_action: TriggerActionRunner, did_not_trigger: TriggerNotTriggeredReporter | None = None, ) -> CALLBACK_TYPE: """Attach the trigger to an action runner.""" unsubs: dict[str, CALLBACK_TYPE | None] = {"event": None, "config": None} @callback def schedule_next_event() -> None: unsubs["event"] = async_track_point_in_utc_time( self._hass, handle_event, self._get_next_event(dt_util.utcnow()) ) @callback def handle_event(_now: datetime) -> None: unsubs["event"] = None schedule_next_event() run_action(self._action_payload(), f"sun event {self._event}") @callback def handle_config(_event: Event) -> None: if unsubs["event"]: unsubs["event"]() schedule_next_event() unsubs["config"] = self._hass.bus.async_listen( EVENT_CORE_CONFIG_UPDATE, handle_config ) schedule_next_event() @callback def async_remove() -> None: for unsub in unsubs.values(): if unsub: unsub() return async_remove class SunriseTrigger(SunEventTrigger): """Trigger that fires at sunrise.""" _event = SUN_EVENT_SUNRISE class SunsetTrigger(SunEventTrigger): """Trigger that fires at sunset.""" _event = SUN_EVENT_SUNSET class SolarNoonTrigger(SunEventTrigger): """Trigger that fires at solar noon.""" _event = _SUN_EVENT_SOLAR_NOON class SolarMidnightTrigger(SunEventTrigger): """Trigger that fires at solar midnight.""" _event = _SUN_EVENT_SOLAR_MIDNIGHT _DAWN_DUSK_TRIGGER_SCHEMA = vol.Schema( { vol.Required(CONF_OPTIONS, default=dict): { vol.Optional(CONF_TYPE, default=_TWILIGHT_CIVIL): vol.In( _TWILIGHT_ELEVATIONS ), **_OFFSET_OPTIONS, } } ) class SunDawnDuskTrigger(SunEventTrigger): """Trigger that fires at dawn or dusk for a configurable twilight phase.""" _schema = _DAWN_DUSK_TRIGGER_SCHEMA def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize the trigger.""" super().__init__(hass, config) self._twilight: str = self._options[CONF_TYPE] self._elevation = _TWILIGHT_ELEVATIONS[self._twilight] @override def _get_next_event(self, utc_point_in_time: datetime) -> datetime: return get_observer_astral_event_next( get_astral_observer(self._hass), self._event, utc_point_in_time, self._offset, # astral takes a depression (degrees below the horizon), i.e. the # negated elevation. depression=-self._elevation, ) @override def _action_payload(self) -> dict[str, Any]: return {CONF_TYPE: self._twilight} class DawnTrigger(SunDawnDuskTrigger): """Trigger that fires at dawn.""" _event = _SUN_EVENT_DAWN class DuskTrigger(SunDawnDuskTrigger): """Trigger that fires at dusk.""" _event = _SUN_EVENT_DUSK _LEGACY_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = { vol.Required(CONF_EVENT): cv.sun_event, vol.Optional(CONF_OFFSET, default=timedelta(0)): cv.time_period, } class LegacySunTrigger(SunEventTrigger): """Backwards compatible trigger for the legacy ``platform: sun`` config.""" _schema = vol.Schema({vol.Required(CONF_OPTIONS): _LEGACY_OPTIONS_SCHEMA_DICT}) @override @classmethod async def async_validate_complete_config( cls, hass: HomeAssistant, complete_config: ConfigType ) -> ConfigType: """Validate complete config, migrating the legacy top-level fields.""" complete_config = move_top_level_schema_fields_to_options( complete_config, _LEGACY_OPTIONS_SCHEMA_DICT ) return await super().async_validate_complete_config(hass, complete_config) def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize the trigger.""" super().__init__(hass, config) self._event = self._options[CONF_EVENT] @override def _action_payload(self) -> dict[str, Any]: return {"event": self._event, "offset": self._offset} TRIGGERS: dict[str, type[Trigger]] = { "_": LegacySunTrigger, "sunrise": SunriseTrigger, "sunset": SunsetTrigger, "solar_noon": SolarNoonTrigger, "solar_midnight": SolarMidnightTrigger, "dawn": DawnTrigger, "dusk": DuskTrigger, "elevation_changed": SunElevationChangedTrigger, "elevation_crossed_threshold": SunElevationCrossedTrigger, } async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]: """Return the triggers for the sun.""" return TRIGGERS