Files
core/homeassistant/components/sun/trigger.py
T

615 lines
21 KiB
Python

"""Provides triggers for the sun."""
from datetime import datetime, timedelta
from typing import Any, Final, Literal, cast, override
import astral.sun
import probatio
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_BLUE_HOUR_HIGH,
ELEVATION_BLUE_HOUR_LOW,
ELEVATION_CIVIL,
ELEVATION_GOLDEN_HOUR_HIGH,
ELEVATION_GOLDEN_HOUR_LOW,
ELEVATION_HORIZON,
ELEVATION_NAUTICAL,
STATE_ATTR_ELEVATION,
)
# Names of solar events supported by the astral.sun module
_SUN_EVENT_SOLAR_NOON: Final = "noon"
_SUN_EVENT_SOLAR_MIDNIGHT: Final = "midnight"
_SUN_EVENT_DAWN = "dawn"
_SUN_EVENT_DUSK = "dusk"
_TWILIGHT_CIVIL = "civil"
_TWILIGHT_NAUTICAL = "nautical"
_TWILIGHT_ASTRONOMICAL = "astronomical"
CONF_PERIOD = "period"
_PERIOD_ANY = "any"
_PERIOD_MORNING = "morning"
_PERIOD_EVENING = "evening"
_PERIODS = (_PERIOD_ANY, _PERIOD_MORNING, _PERIOD_EVENING)
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[probatio.Marker, Any] = {
probatio.Required(CONF_OFFSET, default=timedelta(0)): cv.time_period,
probatio.Required(CONF_OFFSET_TYPE, default=OFFSET_TYPE_BEFORE): probatio.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 = probatio.Schema(
{
probatio.Required(CONF_OPTIONS, default=dict): {
probatio.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 = probatio.Schema(
{
probatio.Required(CONF_OPTIONS, default=dict): {
probatio.Required("threshold"): NumericThresholdSelector(
NumericThresholdSelectorConfig(mode=NumericThresholdMode.CROSSED)
),
probatio.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 = probatio.Schema(
{probatio.Required(CONF_OPTIONS, default=dict): {**_OFFSET_OPTIONS}}
)
class SunEventTrigger(Trigger):
"""Trigger that fires at a solar event time.
``_event`` is the astral event the trigger schedules on. ``trigger.description``
defaults to ``sun event {_event}``; a subclass whose scheduling is not a single
astral event sets ``_context`` to name itself there instead.
"""
_event: str
_context: str | None = None
_schema: probatio.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 | None:
"""Return the next time this solar event occurs.
Subclasses may return ``None`` when the event never occurs at the current
location (e.g. a midnight sun trigger outside the polar regions), in which
case the trigger stays armed but unscheduled until the location changes.
"""
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:
next_event = self._get_next_event(dt_util.utcnow())
if next_event is None:
return
unsubs["event"] = async_track_point_in_utc_time(
self._hass, handle_event, next_event
)
@callback
def handle_event(_now: datetime) -> None:
unsubs["event"] = None
schedule_next_event()
run_action(
self._action_payload(), f"sun event {self._context or 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 = probatio.Schema(
{
probatio.Required(CONF_OPTIONS, default=dict): {
probatio.Optional(CONF_TYPE, default=_TWILIGHT_CIVIL): probatio.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
_GOLDEN_BLUE_HOUR_TRIGGER_SCHEMA = probatio.Schema(
{
probatio.Required(CONF_OPTIONS, default=dict): {
probatio.Optional(CONF_PERIOD, default=_PERIOD_ANY): probatio.In(_PERIODS),
**_OFFSET_OPTIONS,
}
}
)
class _GoldenBlueHourTrigger(SunEventTrigger):
"""Trigger that fires at a golden or blue hour boundary crossing.
Each boundary is a solar elevation the sun crosses twice a day: once while
rising (the morning crossing) and once while descending (the evening one).
The rising crossing is found as a ``dawn`` at the boundary elevation and the
descending crossing as a ``dusk``; ``period`` selects morning, evening, or
both (firing at whichever comes next). There is no single astral ``_event``;
scheduling is done in ``_get_next_event`` and ``_context`` names the trigger.
"""
_rising_elevation: float
_setting_elevation: float
_schema = _GOLDEN_BLUE_HOUR_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the trigger."""
super().__init__(hass, config)
self._period: str = self._options[CONF_PERIOD]
@override
def _get_next_event(self, utc_point_in_time: datetime) -> datetime | None:
observer = get_astral_observer(self._hass)
crossings: list[tuple[str, float]] = []
if self._period in (_PERIOD_ANY, _PERIOD_MORNING):
crossings.append((_SUN_EVENT_DAWN, self._rising_elevation))
if self._period in (_PERIOD_ANY, _PERIOD_EVENING):
crossings.append((_SUN_EVENT_DUSK, self._setting_elevation))
next_events: list[datetime] = []
for event, elevation in crossings:
try:
next_events.append(
get_observer_astral_event_next(
observer,
event,
utc_point_in_time,
self._offset,
# astral takes a depression (degrees below the horizon),
# i.e. the negated elevation.
depression=-elevation,
)
)
except ValueError:
# The sun never reaches this boundary at the current latitude
# (e.g. during a polar night); ignore this crossing.
continue
if next_events:
return min(next_events)
return None
@override
def _action_payload(self) -> dict[str, Any]:
return {CONF_PERIOD: self._period}
class GoldenHourStartedTrigger(_GoldenBlueHourTrigger):
"""Trigger that fires when golden hour starts."""
_context = "golden_hour_started"
_rising_elevation = ELEVATION_GOLDEN_HOUR_LOW
_setting_elevation = ELEVATION_GOLDEN_HOUR_HIGH
class GoldenHourEndedTrigger(_GoldenBlueHourTrigger):
"""Trigger that fires when golden hour ends."""
_context = "golden_hour_ended"
_rising_elevation = ELEVATION_GOLDEN_HOUR_HIGH
_setting_elevation = ELEVATION_GOLDEN_HOUR_LOW
class BlueHourStartedTrigger(_GoldenBlueHourTrigger):
"""Trigger that fires when blue hour starts."""
_context = "blue_hour_started"
_rising_elevation = ELEVATION_BLUE_HOUR_LOW
_setting_elevation = ELEVATION_BLUE_HOUR_HIGH
class BlueHourEndedTrigger(_GoldenBlueHourTrigger):
"""Trigger that fires when blue hour ends."""
_context = "blue_hour_ended"
_rising_elevation = ELEVATION_BLUE_HOUR_HIGH
_setting_elevation = ELEVATION_BLUE_HOUR_LOW
# A midnight sun or polar night - the sun's daily extreme staying above / below
# the horizon - only happens inside the polar circles. Under the same apparent
# elevation vs ELEVATION_HORIZON test the sun entity uses for its above/below
# horizon state, a midnight sun first becomes possible at ~65.4° of latitude (a
# polar night higher still), so below this the scan never finds a crossing and is
# skipped. 65.0° stays a safe margin under that minimum.
_MIN_POLAR_LATITUDE = 65.0
def _next_polar_transition(
observer: astral.Observer,
event: Literal["noon", "midnight"],
utc_point_in_time: datetime,
target_above: bool,
offset: timedelta,
) -> datetime | None:
"""Return the next solar noon/midnight that starts or ends a polar period.
A midnight sun or polar night begins/ends at the solar midnight/noon whose
elevation crosses the horizon. ``event`` is ``midnight`` (the midnight sun is
bounded by the sun's daily low) or ``noon`` (the polar night by its daily
high); ``target_above`` selects the crossing direction - ``True`` for the day
the extreme first rises above the horizon, ``False`` for the day it first
drops below. Returns ``None`` when no such crossing exists, which is the case
at any latitude that never has a midnight sun or polar night: there the sun
rises and sets every day, so the solar midnight stays below and the solar noon
above the horizon and neither ever crosses it.
"""
# Outside the polar circles neither event can occur; skip the ~year-long scan
# (which would otherwise run in the event loop on every scheduling attempt).
if abs(observer.latitude) < _MIN_POLAR_LATITUDE:
return None
event_func = getattr(astral.sun, event)
# The fire time is event_time + offset, so a crossing only matters once its
# event_time passes utc_point_in_time - offset (the same threshold the
# fire-time guard applies below). Anchoring the scan there - two days back for
# the first crossing's prior sample - covers the relevant crossings for either
# offset sign: a positive ("after") offset reaches back to a crossing whose
# delayed fire is still pending, a negative ("before") offset forward to one
# whose advanced fire has not yet arrived. The window stays bounded regardless
# of the offset magnitude.
anchor = utc_point_in_time - offset
local_date = dt_util.as_local(anchor).date() - timedelta(days=2)
prev_above: bool | None = None
# A couple of days of prior samples plus a bit over a year, so the next annual
# crossing is always reached (e.g. when rescheduling from inside a period).
for _ in range(400):
event_time: datetime = event_func(observer, local_date)
above = astral.sun.elevation(observer, event_time) > ELEVATION_HORIZON
if (
prev_above is not None
and above == target_above
and above != prev_above
and (fire_time := event_time + offset) > utc_point_in_time
):
return fire_time
prev_above = above
local_date += timedelta(days=1)
return None
class _MidnightSunPolarNightTrigger(SunEventTrigger):
"""Trigger for the start or end of the midnight sun or polar night.
The transition happens at the solar noon or midnight whose elevation crosses
the horizon: the midnight sun starts/ends when the sun's daily low (solar
midnight) rises above/drops below the horizon, and the polar night when its
daily high (solar noon) drops below/rises above it.
The daily solar extreme is used rather than the actual sunrise/sunset event
because astral's rise/set calculations are numerically unstable where the sun
only grazes the horizon, and a polar night has no sunrise/sunset event at all
(its sun clears the horizon around noon, not at a normal sunrise). Firing at
the extreme places the event where the sun is unambiguously up or down.
``_event`` is the astral solar extreme scanned for the crossing; ``_context``
names the trigger for ``trigger.description``.
"""
_event: Literal["noon", "midnight"]
_target_above: bool
@override
def _get_next_event(self, utc_point_in_time: datetime) -> datetime | None:
return _next_polar_transition(
get_astral_observer(self._hass),
self._event,
utc_point_in_time,
self._target_above,
self._offset,
)
class MidnightSunStartedTrigger(_MidnightSunPolarNightTrigger):
"""Trigger that fires when the midnight sun period starts."""
_event = _SUN_EVENT_SOLAR_MIDNIGHT
_context = "midnight_sun_started"
_target_above = True
class MidnightSunEndedTrigger(_MidnightSunPolarNightTrigger):
"""Trigger that fires when the midnight sun period ends."""
_event = _SUN_EVENT_SOLAR_MIDNIGHT
_context = "midnight_sun_ended"
_target_above = False
class PolarNightStartedTrigger(_MidnightSunPolarNightTrigger):
"""Trigger that fires when the polar night period starts."""
_event = _SUN_EVENT_SOLAR_NOON
_context = "polar_night_started"
_target_above = False
class PolarNightEndedTrigger(_MidnightSunPolarNightTrigger):
"""Trigger that fires when the polar night period ends."""
_event = _SUN_EVENT_SOLAR_NOON
_context = "polar_night_ended"
_target_above = True
_LEGACY_OPTIONS_SCHEMA_DICT: dict[probatio.Marker, Any] = {
probatio.Required(CONF_EVENT): cv.sun_event,
probatio.Optional(CONF_OFFSET, default=timedelta(0)): cv.time_period,
}
class LegacySunTrigger(SunEventTrigger):
"""Backwards compatible trigger for the legacy ``platform: sun`` config."""
_schema = probatio.Schema(
{probatio.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,
"golden_hour_started": GoldenHourStartedTrigger,
"golden_hour_ended": GoldenHourEndedTrigger,
"blue_hour_started": BlueHourStartedTrigger,
"blue_hour_ended": BlueHourEndedTrigger,
"midnight_sun_started": MidnightSunStartedTrigger,
"midnight_sun_ended": MidnightSunEndedTrigger,
"polar_night_started": PolarNightStartedTrigger,
"polar_night_ended": PolarNightEndedTrigger,
"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