1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-07 06:46:17 +01:00
Files
core/homeassistant/components/sun/condition.py
T
2026-06-25 17:40:36 +02:00

386 lines
12 KiB
Python

"""Offer sun based automation rules."""
from datetime import datetime, timedelta
from typing import Any, Unpack, cast, override
import astral.sun
import voluptuous as vol
from homeassistant.const import (
CONF_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
CONF_TYPE,
DEGREE,
SUN_EVENT_SUNRISE,
SUN_EVENT_SUNSET,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import (
DomainSpec,
move_top_level_schema_fields_to_options,
)
from homeassistant.helpers.condition import (
ATTR_BEHAVIOR,
BEHAVIOR_ANY,
Condition,
ConditionCheckParams,
ConditionConfig,
EntityNumericalConditionBase,
condition_trace_set_result,
condition_trace_update_result,
)
from homeassistant.helpers.selector import (
NumericThresholdMode,
NumericThresholdSelector,
NumericThresholdSelectorConfig,
)
from homeassistant.helpers.sun import get_astral_event_date, get_astral_observer
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from .const import (
DOMAIN,
ELEVATION_ASTRONOMICAL,
ELEVATION_CIVIL,
ELEVATION_HORIZON,
ELEVATION_NAUTICAL,
STATE_ATTR_ELEVATION,
)
_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = {
vol.Optional("before"): cv.sun_event,
vol.Optional("before_offset"): cv.time_period,
vol.Optional("after"): cv.sun_event,
vol.Optional("after_offset"): cv.time_period,
}
_CONDITION_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS): vol.All(
_OPTIONS_SCHEMA_DICT,
cv.has_at_least_one_key("before", "after"),
)
}
)
def sun(
hass: HomeAssistant,
before: str | None = None,
after: str | None = None,
before_offset: timedelta | None = None,
after_offset: timedelta | None = None,
) -> bool:
"""Test if current time matches sun requirements."""
utcnow = dt_util.utcnow()
today = dt_util.as_local(utcnow).date()
before_offset = before_offset or timedelta(0)
after_offset = after_offset or timedelta(0)
sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, today)
sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, today)
has_sunrise_condition = SUN_EVENT_SUNRISE in (before, after)
has_sunset_condition = SUN_EVENT_SUNSET in (before, after)
after_sunrise = sunrise is not None and today > dt_util.as_local(sunrise).date()
if after_sunrise and has_sunrise_condition:
tomorrow = today + timedelta(days=1)
sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, tomorrow)
after_sunset = sunset is not None and today > dt_util.as_local(sunset).date()
if after_sunset and has_sunset_condition:
tomorrow = today + timedelta(days=1)
sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, tomorrow)
# A missing sunrise/sunset means the sun doesn't rise/set on this day, which
# happens in polar regions.
if sunrise is None and has_sunrise_condition:
# There is no sunrise today
condition_trace_set_result(False, message="no sunrise today")
return False
if sunset is None and has_sunset_condition:
# There is no sunset today
condition_trace_set_result(False, message="no sunset today")
return False
# "before: sunrise" combined with "after: sunset" describes the dark period
# around midnight, so it is evaluated as an OR (true before sunrise or after
# sunset) rather than the usual AND of the two bounds.
if before == SUN_EVENT_SUNRISE and after == SUN_EVENT_SUNSET:
wanted_time_before = cast(datetime, sunrise) + before_offset
condition_trace_update_result(wanted_time_before=wanted_time_before)
wanted_time_after = cast(datetime, sunset) + after_offset
condition_trace_update_result(wanted_time_after=wanted_time_after)
return utcnow < wanted_time_before or utcnow > wanted_time_after
if before == SUN_EVENT_SUNRISE:
wanted_time_before = cast(datetime, sunrise) + before_offset
condition_trace_update_result(wanted_time_before=wanted_time_before)
if utcnow > wanted_time_before:
return False
if before == SUN_EVENT_SUNSET:
wanted_time_before = cast(datetime, sunset) + before_offset
condition_trace_update_result(wanted_time_before=wanted_time_before)
if utcnow > wanted_time_before:
return False
if after == SUN_EVENT_SUNRISE:
wanted_time_after = cast(datetime, sunrise) + after_offset
condition_trace_update_result(wanted_time_after=wanted_time_after)
if utcnow < wanted_time_after:
return False
if after == SUN_EVENT_SUNSET:
wanted_time_after = cast(datetime, sunset) + after_offset
condition_trace_update_result(wanted_time_after=wanted_time_after)
if utcnow < wanted_time_after:
return False
return True
class SunCondition(Condition):
"""Sun condition."""
_options: dict[str, Any]
@classmethod
@override
async def async_validate_complete_config(
cls, hass: HomeAssistant, complete_config: ConfigType
) -> ConfigType:
"""Validate complete config."""
complete_config = move_top_level_schema_fields_to_options(
complete_config, _OPTIONS_SCHEMA_DICT
)
return await super().async_validate_complete_config(hass, complete_config)
@classmethod
@override
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, _CONDITION_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
super().__init__(hass, config)
assert config.options is not None
self._options = config.options
self._before = self._options.get("before")
self._after = self._options.get("after")
self._before_offset = self._options.get("before_offset")
self._after_offset = self._options.get("after_offset")
@override
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Check the condition."""
return sun(
self._hass,
self._before,
self._after,
self._before_offset,
self._after_offset,
)
# The sun is a singleton, so these conditions take no target and no options.
_STATE_CONDITION_SCHEMA = vol.Schema({vol.Required(CONF_OPTIONS, default=dict): {}})
# The sun is a singleton, so the elevation condition always targets 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)}
def _solar_position(hass: HomeAssistant) -> tuple[float, bool]:
"""Return the sun's current elevation in degrees and whether it is rising."""
observer = get_astral_observer(hass)
now = dt_util.utcnow()
elevation = astral.sun.elevation(observer, now)
rising = astral.sun.elevation(observer, now + timedelta(minutes=1)) > elevation
return elevation, rising
class _SunStateCondition(Condition):
"""Base class for the option-less sun state conditions."""
@classmethod
@override
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, _STATE_CONDITION_SCHEMA(config))
class _UpCondition(_SunStateCondition):
"""Test if the sun is up."""
@override
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Check the condition."""
elevation, _ = _solar_position(self._hass)
return elevation >= ELEVATION_HORIZON
class _SetCondition(_SunStateCondition):
"""Test if the sun is set."""
@override
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Check the condition."""
elevation, _ = _solar_position(self._hass)
return elevation < ELEVATION_HORIZON
class _AscendingCondition(_SunStateCondition):
"""Test if the sun is ascending."""
@override
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Check the condition."""
_, rising = _solar_position(self._hass)
return rising
class _DescendingCondition(_SunStateCondition):
"""Test if the sun is descending."""
@override
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Check the condition."""
_, rising = _solar_position(self._hass)
return not rising
class _NightCondition(_SunStateCondition):
"""Test if it is night (the sun is below all twilight)."""
@override
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Check the condition."""
elevation, _ = _solar_position(self._hass)
return elevation <= ELEVATION_ASTRONOMICAL
_TWILIGHT_ANY = "any"
_TWILIGHT_CIVIL = "civil"
_TWILIGHT_NAUTICAL = "nautical"
_TWILIGHT_ASTRONOMICAL = "astronomical"
# Elevation band (min, max) in degrees for each twilight type, bounded by the
# horizon and the twilight elevations.
_TWILIGHT_BANDS = {
_TWILIGHT_ANY: (ELEVATION_ASTRONOMICAL, ELEVATION_HORIZON),
_TWILIGHT_CIVIL: (ELEVATION_CIVIL, ELEVATION_HORIZON),
_TWILIGHT_NAUTICAL: (ELEVATION_NAUTICAL, ELEVATION_CIVIL),
_TWILIGHT_ASTRONOMICAL: (ELEVATION_ASTRONOMICAL, ELEVATION_NAUTICAL),
}
_TWILIGHT_CONDITION_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS, default=dict): {
vol.Optional(CONF_TYPE, default=_TWILIGHT_ANY): vol.In(_TWILIGHT_BANDS),
}
}
)
class _TwilightCondition(Condition):
"""Base class for the morning and evening twilight conditions.
The sun is in twilight when its elevation is within the selected band;
morning twilight requires the sun to be rising and evening twilight to be
descending.
"""
_rising: bool
@classmethod
@override
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, _TWILIGHT_CONDITION_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
super().__init__(hass, config)
assert config.options is not None
self._low, self._high = _TWILIGHT_BANDS[config.options[CONF_TYPE]]
@override
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Check the condition."""
elevation, rising = _solar_position(self._hass)
return rising == self._rising and self._low <= elevation <= self._high
class _MorningTwilightCondition(_TwilightCondition):
"""Test if it is morning twilight (the sun is rising through twilight)."""
_rising = True
class _EveningTwilightCondition(_TwilightCondition):
"""Test if it is evening twilight (the sun is descending through twilight)."""
_rising = False
_ELEVATION_CONDITION_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS, default=dict): {
vol.Required("threshold"): NumericThresholdSelector(
NumericThresholdSelectorConfig(mode=NumericThresholdMode.IS)
),
}
}
)
class _ElevationCondition(EntityNumericalConditionBase):
"""Test the sun's elevation against a threshold."""
_domain_specs = _ELEVATION_DOMAIN_SPECS
_valid_unit = DEGREE
_schema = _ELEVATION_CONDITION_SCHEMA
@classmethod
@override
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config and target the singleton sun entity."""
config = cast(ConfigType, cls._schema(config))
config[CONF_TARGET] = {CONF_ENTITY_ID: [_SUN_ENTITY_ID]}
# `behavior` is needed by `EntityConditionBase.__init__`.
config[CONF_OPTIONS][ATTR_BEHAVIOR] = BEHAVIOR_ANY
return config
CONDITIONS: dict[str, type[Condition]] = {
"_": SunCondition,
"is_up": _UpCondition,
"is_set": _SetCondition,
"is_ascending": _AscendingCondition,
"is_descending": _DescendingCondition,
"elevation": _ElevationCondition,
"is_night": _NightCondition,
"is_morning_twilight": _MorningTwilightCondition,
"is_evening_twilight": _EveningTwilightCondition,
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the sun conditions."""
return CONDITIONS