Files

613 lines
20 KiB
Python

"""Provides triggers for the sun."""
from datetime import datetime, timedelta
from typing import Any, Final, Literal, cast, override
import astral.sun
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_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[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`` 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: 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 | 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 = 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
_GOLDEN_BLUE_HOUR_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS, default=dict): {
vol.Optional(CONF_PERIOD, default=_PERIOD_ANY): vol.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[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,
"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