mirror of
https://github.com/home-assistant/core.git
synced 2026-07-01 03:36:05 +01:00
Add additional sun conditions (#174537)
This commit is contained in:
@@ -3,23 +3,52 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Unpack, cast, override
|
||||
|
||||
import astral.sun
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_OPTIONS, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET
|
||||
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 move_top_level_schema_fields_to_options
|
||||
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.sun import get_astral_event_date
|
||||
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,
|
||||
@@ -167,8 +196,193 @@ class SunCondition(Condition):
|
||||
)
|
||||
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
.type: &condition_type
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: twilight_type
|
||||
options:
|
||||
- any
|
||||
- civil
|
||||
- nautical
|
||||
- astronomical
|
||||
|
||||
.elevation_threshold_entity: &condition_elevation_threshold_entity
|
||||
- domain: input_number
|
||||
unit_of_measurement: "°"
|
||||
- domain: number
|
||||
unit_of_measurement: "°"
|
||||
- domain: sensor
|
||||
unit_of_measurement: "°"
|
||||
|
||||
.elevation_threshold_number: &condition_elevation_threshold_number
|
||||
min: -90
|
||||
max: 90
|
||||
mode: box
|
||||
unit_of_measurement: "°"
|
||||
|
||||
is_up: {}
|
||||
is_set: {}
|
||||
is_ascending: {}
|
||||
is_descending: {}
|
||||
is_night: {}
|
||||
elevation:
|
||||
fields:
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity: *condition_elevation_threshold_entity
|
||||
mode: is
|
||||
number: *condition_elevation_threshold_number
|
||||
is_morning_twilight:
|
||||
fields:
|
||||
type: *condition_type
|
||||
is_evening_twilight:
|
||||
fields:
|
||||
type: *condition_type
|
||||
@@ -2,10 +2,21 @@
|
||||
|
||||
from typing import Final
|
||||
|
||||
import astral
|
||||
|
||||
DOMAIN: Final = "sun"
|
||||
|
||||
DEFAULT_NAME: Final = "Sun"
|
||||
|
||||
# Elevation of the sun's center at the horizon, in degrees. This is the value
|
||||
# astral uses for sunrise/sunset (atmospheric refraction plus the sun's radius).
|
||||
ELEVATION_HORIZON: Final = -0.833
|
||||
|
||||
# Sun elevation, in degrees, at each twilight boundary
|
||||
ELEVATION_CIVIL: Final[float] = -astral.Depression.CIVIL.value
|
||||
ELEVATION_NAUTICAL: Final[float] = -astral.Depression.NAUTICAL.value
|
||||
ELEVATION_ASTRONOMICAL: Final[float] = -astral.Depression.ASTRONOMICAL.value
|
||||
|
||||
SIGNAL_POSITION_CHANGED = f"{DOMAIN}_position_changed"
|
||||
SIGNAL_EVENTS_CHANGED = f"{DOMAIN}_events_changed"
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ from homeassistant.helpers.sun import (
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
ELEVATION_ASTRONOMICAL,
|
||||
ELEVATION_CIVIL,
|
||||
ELEVATION_HORIZON,
|
||||
ELEVATION_NAUTICAL,
|
||||
SIGNAL_EVENTS_CHANGED,
|
||||
SIGNAL_POSITION_CHANGED,
|
||||
STATE_ABOVE_HORIZON,
|
||||
@@ -67,12 +71,8 @@ PHASE_SMALL_DAY = "small_day"
|
||||
# > 10° above horizon
|
||||
PHASE_DAY = "day"
|
||||
|
||||
# Depression angle (degrees below the horizon) of the sun at each dawn/dusk
|
||||
# phase boundary. A negative value means the sun is above the horizon.
|
||||
DEPRESSION_ASTRONOMICAL = 18.0
|
||||
DEPRESSION_NAUTICAL = 12.0
|
||||
DEPRESSION_CIVIL = 6.0
|
||||
DEPRESSION_SMALL_DAY = -10.0
|
||||
# Sun elevation (degrees above the horizon) at the start of the "small day" phase.
|
||||
_ELEVATION_SMALL_DAY = 10.0
|
||||
|
||||
# 4 mins is one degree of arc change of the sun on its circle.
|
||||
# During the night and the middle of the day we don't update
|
||||
@@ -162,8 +162,7 @@ class Sun(Entity):
|
||||
@override
|
||||
def state(self) -> str:
|
||||
"""Return the state of the sun."""
|
||||
# 0.8333 is the same value as astral uses
|
||||
if self.solar_elevation > -0.833:
|
||||
if self.solar_elevation > ELEVATION_HORIZON:
|
||||
return STATE_ABOVE_HORIZON
|
||||
|
||||
return STATE_BELOW_HORIZON
|
||||
@@ -189,8 +188,11 @@ class Sun(Entity):
|
||||
utc_point_in_time: datetime,
|
||||
sun_event: str,
|
||||
before: str | None,
|
||||
depression: float | None = None,
|
||||
elevation: float | None = None,
|
||||
) -> datetime:
|
||||
# astral takes a depression (degrees below the horizon), i.e. the
|
||||
# negated elevation.
|
||||
depression = None if elevation is None else -elevation
|
||||
next_utc = get_observer_astral_event_next(
|
||||
self.observer, sun_event, utc_point_in_time, depression=depression
|
||||
)
|
||||
@@ -209,36 +211,36 @@ class Sun(Entity):
|
||||
# Work our way around the solar cycle, figure out the next
|
||||
# phase. Some of these are stored.
|
||||
self._check_event(
|
||||
utc_point_in_time, "dawn", PHASE_NIGHT, DEPRESSION_ASTRONOMICAL
|
||||
utc_point_in_time, "dawn", PHASE_NIGHT, ELEVATION_ASTRONOMICAL
|
||||
)
|
||||
self._check_event(
|
||||
utc_point_in_time, "dawn", PHASE_ASTRONOMICAL_TWILIGHT, DEPRESSION_NAUTICAL
|
||||
utc_point_in_time, "dawn", PHASE_ASTRONOMICAL_TWILIGHT, ELEVATION_NAUTICAL
|
||||
)
|
||||
self.next_dawn = self._check_event(
|
||||
utc_point_in_time, "dawn", PHASE_NAUTICAL_TWILIGHT, DEPRESSION_CIVIL
|
||||
utc_point_in_time, "dawn", PHASE_NAUTICAL_TWILIGHT, ELEVATION_CIVIL
|
||||
)
|
||||
self.next_rising = self._check_event(
|
||||
utc_point_in_time, SUN_EVENT_SUNRISE, PHASE_TWILIGHT
|
||||
)
|
||||
self._check_event(
|
||||
utc_point_in_time, "dawn", PHASE_SMALL_DAY, DEPRESSION_SMALL_DAY
|
||||
utc_point_in_time, "dawn", PHASE_SMALL_DAY, _ELEVATION_SMALL_DAY
|
||||
)
|
||||
self.next_noon = self._check_event(utc_point_in_time, "noon", None)
|
||||
self._check_event(utc_point_in_time, "dusk", PHASE_DAY, DEPRESSION_SMALL_DAY)
|
||||
self._check_event(utc_point_in_time, "dusk", PHASE_DAY, _ELEVATION_SMALL_DAY)
|
||||
self.next_setting = self._check_event(
|
||||
utc_point_in_time, SUN_EVENT_SUNSET, PHASE_SMALL_DAY
|
||||
)
|
||||
self.next_dusk = self._check_event(
|
||||
utc_point_in_time, "dusk", PHASE_TWILIGHT, DEPRESSION_CIVIL
|
||||
utc_point_in_time, "dusk", PHASE_TWILIGHT, ELEVATION_CIVIL
|
||||
)
|
||||
self._check_event(
|
||||
utc_point_in_time, "dusk", PHASE_NAUTICAL_TWILIGHT, DEPRESSION_NAUTICAL
|
||||
utc_point_in_time, "dusk", PHASE_NAUTICAL_TWILIGHT, ELEVATION_NAUTICAL
|
||||
)
|
||||
self._check_event(
|
||||
utc_point_in_time,
|
||||
"dusk",
|
||||
PHASE_ASTRONOMICAL_TWILIGHT,
|
||||
DEPRESSION_ASTRONOMICAL,
|
||||
ELEVATION_ASTRONOMICAL,
|
||||
)
|
||||
self.next_midnight = self._check_event(utc_point_in_time, "midnight", None)
|
||||
|
||||
@@ -252,11 +254,11 @@ class Sun(Entity):
|
||||
self.phase = PHASE_DAY
|
||||
elif elevation >= 0:
|
||||
self.phase = PHASE_SMALL_DAY
|
||||
elif elevation >= -6:
|
||||
elif elevation >= ELEVATION_CIVIL:
|
||||
self.phase = PHASE_TWILIGHT
|
||||
elif elevation >= -12:
|
||||
elif elevation >= ELEVATION_NAUTICAL:
|
||||
self.phase = PHASE_NAUTICAL_TWILIGHT
|
||||
elif elevation >= -18:
|
||||
elif elevation >= ELEVATION_ASTRONOMICAL:
|
||||
self.phase = PHASE_ASTRONOMICAL_TWILIGHT
|
||||
else:
|
||||
self.phase = PHASE_NIGHT
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
{
|
||||
"conditions": {
|
||||
"elevation": {
|
||||
"condition": "mdi:sun-angle"
|
||||
},
|
||||
"is_ascending": {
|
||||
"condition": "mdi:weather-sunset-up"
|
||||
},
|
||||
"is_descending": {
|
||||
"condition": "mdi:weather-sunset-down"
|
||||
},
|
||||
"is_evening_twilight": {
|
||||
"condition": "mdi:weather-sunset-down"
|
||||
},
|
||||
"is_morning_twilight": {
|
||||
"condition": "mdi:weather-sunset-up"
|
||||
},
|
||||
"is_night": {
|
||||
"condition": "mdi:weather-night"
|
||||
},
|
||||
"is_set": {
|
||||
"condition": "mdi:weather-sunny-off"
|
||||
},
|
||||
"is_up": {
|
||||
"condition": "mdi:weather-sunny"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"solar_rising": {
|
||||
|
||||
@@ -1,10 +1,62 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_threshold_name": "Threshold type",
|
||||
"trigger_for_name": "For at least",
|
||||
"trigger_threshold_name": "Threshold type",
|
||||
"twilight_type_description": "The phase of twilight.",
|
||||
"twilight_type_name": "Twilight type"
|
||||
},
|
||||
"conditions": {
|
||||
"elevation": {
|
||||
"description": "Tests the sun's elevation against a threshold you set.",
|
||||
"fields": {
|
||||
"threshold": {
|
||||
"name": "[%key:component::sun::common::condition_threshold_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Sun elevation"
|
||||
},
|
||||
"is_ascending": {
|
||||
"description": "Tests if the sun is ascending.",
|
||||
"name": "Sun is ascending"
|
||||
},
|
||||
"is_descending": {
|
||||
"description": "Tests if the sun is descending.",
|
||||
"name": "Sun is descending"
|
||||
},
|
||||
"is_evening_twilight": {
|
||||
"description": "Tests if it is evening twilight, optionally of a specific type.",
|
||||
"fields": {
|
||||
"type": {
|
||||
"description": "[%key:component::sun::common::twilight_type_description%]",
|
||||
"name": "[%key:component::sun::common::twilight_type_name%]"
|
||||
}
|
||||
},
|
||||
"name": "It is evening twilight"
|
||||
},
|
||||
"is_morning_twilight": {
|
||||
"description": "Tests if it is morning twilight, optionally of a specific type.",
|
||||
"fields": {
|
||||
"type": {
|
||||
"description": "[%key:component::sun::common::twilight_type_description%]",
|
||||
"name": "[%key:component::sun::common::twilight_type_name%]"
|
||||
}
|
||||
},
|
||||
"name": "It is morning twilight"
|
||||
},
|
||||
"is_night": {
|
||||
"description": "Tests if it is night.",
|
||||
"name": "It is night"
|
||||
},
|
||||
"is_set": {
|
||||
"description": "Tests if the sun is set.",
|
||||
"name": "Sun is set"
|
||||
},
|
||||
"is_up": {
|
||||
"description": "Tests if the sun is up.",
|
||||
"name": "Sun is up"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
@@ -45,6 +97,7 @@
|
||||
"selector": {
|
||||
"twilight_type": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"astronomical": "Astronomical",
|
||||
"civil": "Civil",
|
||||
"nautical": "Nautical"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, cast, override
|
||||
|
||||
import astral
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
@@ -47,7 +46,13 @@ from homeassistant.helpers.trigger import (
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN, STATE_ATTR_ELEVATION
|
||||
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"
|
||||
@@ -59,11 +64,11 @@ _TWILIGHT_CIVIL = "civil"
|
||||
_TWILIGHT_NAUTICAL = "nautical"
|
||||
_TWILIGHT_ASTRONOMICAL = "astronomical"
|
||||
|
||||
# Sun depression below the horizon for each twilight phase, as defined by astral.
|
||||
_TWILIGHT_DEPRESSIONS = {
|
||||
_TWILIGHT_CIVIL: astral.Depression.CIVIL,
|
||||
_TWILIGHT_NAUTICAL: astral.Depression.NAUTICAL,
|
||||
_TWILIGHT_ASTRONOMICAL: astral.Depression.ASTRONOMICAL,
|
||||
# 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
|
||||
@@ -228,7 +233,7 @@ _DAWN_DUSK_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS, default=dict): {
|
||||
vol.Optional(CONF_TYPE, default=_TWILIGHT_CIVIL): vol.In(
|
||||
_TWILIGHT_DEPRESSIONS
|
||||
_TWILIGHT_ELEVATIONS
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -244,7 +249,7 @@ class SunDawnDuskTrigger(SunEventTrigger):
|
||||
"""Initialize the trigger."""
|
||||
super().__init__(hass, config)
|
||||
self._twilight: str = self._options[CONF_TYPE]
|
||||
self._depression = _TWILIGHT_DEPRESSIONS[self._twilight]
|
||||
self._elevation = _TWILIGHT_ELEVATIONS[self._twilight]
|
||||
|
||||
@override
|
||||
def _get_next_event(self, utc_point_in_time: datetime) -> datetime:
|
||||
@@ -252,7 +257,9 @@ class SunDawnDuskTrigger(SunEventTrigger):
|
||||
get_astral_observer(self._hass),
|
||||
self._event,
|
||||
utc_point_in_time,
|
||||
depression=self._depression,
|
||||
# astral takes a depression (degrees below the horizon), i.e. the
|
||||
# negated elevation.
|
||||
depression=-self._elevation,
|
||||
)
|
||||
|
||||
@override
|
||||
|
||||
@@ -4,16 +4,24 @@ from datetime import datetime
|
||||
|
||||
from freezegun import freeze_time
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import trace
|
||||
from homeassistant.helpers.condition import async_validate_condition_config
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
# San Diego (default test location) and Longyearbyen, Svalbard (deep polar).
|
||||
_SAN_DIEGO = (32.87336, -117.22743, "US/Pacific")
|
||||
_SVALBARD = (78.22, 15.65, "Europe/Oslo")
|
||||
|
||||
_TWILIGHT_TYPES = ("any", "civil", "nautical", "astronomical")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def prepare_condition_trace() -> None:
|
||||
@@ -1248,3 +1256,306 @@ async def test_if_action_after_sunset_no_offset_kotzebue(
|
||||
"sun",
|
||||
{"result": True, "wanted_time_after": "2015-07-23T11:22:18.467277+00:00"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "location", "now", "expected"),
|
||||
[
|
||||
# San Diego, just after solar noon (sun high, descending).
|
||||
(
|
||||
"sun.is_up",
|
||||
_SAN_DIEGO,
|
||||
datetime(2015, 9, 15, 20, tzinfo=dt_util.UTC),
|
||||
True,
|
||||
),
|
||||
(
|
||||
"sun.is_set",
|
||||
_SAN_DIEGO,
|
||||
datetime(2015, 9, 15, 20, tzinfo=dt_util.UTC),
|
||||
False,
|
||||
),
|
||||
(
|
||||
"sun.is_descending",
|
||||
_SAN_DIEGO,
|
||||
datetime(2015, 9, 15, 20, tzinfo=dt_util.UTC),
|
||||
True,
|
||||
),
|
||||
(
|
||||
"sun.is_ascending",
|
||||
_SAN_DIEGO,
|
||||
datetime(2015, 9, 15, 20, tzinfo=dt_util.UTC),
|
||||
False,
|
||||
),
|
||||
(
|
||||
"sun.is_night",
|
||||
_SAN_DIEGO,
|
||||
datetime(2015, 9, 15, 20, tzinfo=dt_util.UTC),
|
||||
False,
|
||||
),
|
||||
# San Diego, just before solar noon (sun high, rising).
|
||||
(
|
||||
"sun.is_ascending",
|
||||
_SAN_DIEGO,
|
||||
datetime(2015, 9, 15, 19, tzinfo=dt_util.UTC),
|
||||
True,
|
||||
),
|
||||
(
|
||||
"sun.is_descending",
|
||||
_SAN_DIEGO,
|
||||
datetime(2015, 9, 15, 19, tzinfo=dt_util.UTC),
|
||||
False,
|
||||
),
|
||||
# San Diego, deep night.
|
||||
(
|
||||
"sun.is_set",
|
||||
_SAN_DIEGO,
|
||||
datetime(2015, 9, 15, 8, 30, tzinfo=dt_util.UTC),
|
||||
True,
|
||||
),
|
||||
(
|
||||
"sun.is_up",
|
||||
_SAN_DIEGO,
|
||||
datetime(2015, 9, 15, 8, 30, tzinfo=dt_util.UTC),
|
||||
False,
|
||||
),
|
||||
(
|
||||
"sun.is_night",
|
||||
_SAN_DIEGO,
|
||||
datetime(2015, 9, 15, 8, 30, tzinfo=dt_util.UTC),
|
||||
True,
|
||||
),
|
||||
# Svalbard: above the horizon during midnight sun (June), below during
|
||||
# polar night (December).
|
||||
(
|
||||
"sun.is_up",
|
||||
_SVALBARD,
|
||||
datetime(2015, 6, 15, 12, tzinfo=dt_util.UTC),
|
||||
True,
|
||||
),
|
||||
(
|
||||
"sun.is_set",
|
||||
_SVALBARD,
|
||||
datetime(2015, 12, 15, 12, tzinfo=dt_util.UTC),
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_sun_state_conditions(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
condition_key: str,
|
||||
location: tuple[float, float, str],
|
||||
now: datetime,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
"""Test the option-less sun state conditions evaluate from the sun position."""
|
||||
latitude, longitude, time_zone = location
|
||||
await hass.config.async_set_time_zone(time_zone)
|
||||
hass.config.latitude = latitude
|
||||
hass.config.longitude = longitude
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {"platform": "event", "event_type": "test_event"},
|
||||
"condition": {"condition": condition_key},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
with freeze_time(now):
|
||||
hass.bus.async_fire("test_event")
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert bool(service_calls) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"condition_key",
|
||||
[
|
||||
"sun.is_up",
|
||||
"sun.is_set",
|
||||
"sun.is_ascending",
|
||||
"sun.is_descending",
|
||||
"sun.is_night",
|
||||
],
|
||||
)
|
||||
async def test_sun_state_condition_takes_no_options(
|
||||
hass: HomeAssistant, condition_key: str
|
||||
) -> None:
|
||||
"""Test the sun state conditions accept no target and reject options."""
|
||||
await async_validate_condition_config(hass, {"condition": condition_key})
|
||||
with pytest.raises(vol.Invalid):
|
||||
await async_validate_condition_config(
|
||||
hass, {"condition": condition_key, "options": {"unknown": True}}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("threshold", "elevation", "expected"),
|
||||
[
|
||||
({"type": "above", "value": {"number": 10}}, 15.0, True),
|
||||
({"type": "above", "value": {"number": 10}}, 5.0, False),
|
||||
({"type": "below", "value": {"number": 0}}, -5.0, True),
|
||||
({"type": "below", "value": {"number": 0}}, 5.0, False),
|
||||
# Negative thresholds (sun below the horizon) are valid.
|
||||
({"type": "below", "value": {"number": -6}}, -10.0, True),
|
||||
(
|
||||
{
|
||||
"type": "between",
|
||||
"value_min": {"number": -6},
|
||||
"value_max": {"number": 6},
|
||||
},
|
||||
0.0,
|
||||
True,
|
||||
),
|
||||
(
|
||||
{
|
||||
"type": "between",
|
||||
"value_min": {"number": -6},
|
||||
"value_max": {"number": 6},
|
||||
},
|
||||
10.0,
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_elevation_condition(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
threshold: dict[str, object],
|
||||
elevation: float,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
"""Test the elevation condition compares the sun's elevation to a threshold."""
|
||||
hass.states.async_set("sun.sun", "above_horizon", {"elevation": elevation})
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {"platform": "event", "event_type": "test_event"},
|
||||
"condition": {
|
||||
"condition": "sun.elevation",
|
||||
"options": {"threshold": threshold},
|
||||
},
|
||||
"action": {"service": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.bus.async_fire("test_event")
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert bool(service_calls) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "now", "expected_true"),
|
||||
[
|
||||
# San Diego morning twilight (rising). The twilight bands are mutually
|
||||
# exclusive, so at each elevation exactly one specific band matches (plus
|
||||
# "any" whenever the sun is in any twilight band at all).
|
||||
# 13:15Z ~ -4.4° (civil band).
|
||||
(
|
||||
"sun.is_morning_twilight",
|
||||
datetime(2015, 9, 15, 13, 15, tzinfo=dt_util.UTC),
|
||||
{"any", "civil"},
|
||||
),
|
||||
# 13:00Z ~ -7.6° (nautical band).
|
||||
(
|
||||
"sun.is_morning_twilight",
|
||||
datetime(2015, 9, 15, 13, 0, tzinfo=dt_util.UTC),
|
||||
{"any", "nautical"},
|
||||
),
|
||||
# 12:30Z ~ -13.8° (astronomical band).
|
||||
(
|
||||
"sun.is_morning_twilight",
|
||||
datetime(2015, 9, 15, 12, 30, tzinfo=dt_util.UTC),
|
||||
{"any", "astronomical"},
|
||||
),
|
||||
# 12:00Z ~ -19.8° (night, below all twilight bands).
|
||||
(
|
||||
"sun.is_morning_twilight",
|
||||
datetime(2015, 9, 15, 12, 0, tzinfo=dt_util.UTC),
|
||||
set(),
|
||||
),
|
||||
# Morning twilight requires the sun to be rising; an evening (descending)
|
||||
# time matches no type.
|
||||
(
|
||||
"sun.is_morning_twilight",
|
||||
datetime(2015, 9, 16, 2, 45, tzinfo=dt_util.UTC),
|
||||
set(),
|
||||
),
|
||||
# San Diego evening twilight (descending).
|
||||
# 02:15Z ~ -4.9° (civil band).
|
||||
(
|
||||
"sun.is_evening_twilight",
|
||||
datetime(2015, 9, 16, 2, 15, tzinfo=dt_util.UTC),
|
||||
{"any", "civil"},
|
||||
),
|
||||
# 02:45Z ~ -11.2° (nautical band).
|
||||
(
|
||||
"sun.is_evening_twilight",
|
||||
datetime(2015, 9, 16, 2, 45, tzinfo=dt_util.UTC),
|
||||
{"any", "nautical"},
|
||||
),
|
||||
# 03:15Z ~ -17.3° (astronomical band).
|
||||
(
|
||||
"sun.is_evening_twilight",
|
||||
datetime(2015, 9, 16, 3, 15, tzinfo=dt_util.UTC),
|
||||
{"any", "astronomical"},
|
||||
),
|
||||
# Evening twilight requires the sun to be descending; a morning (rising)
|
||||
# time matches no type.
|
||||
(
|
||||
"sun.is_evening_twilight",
|
||||
datetime(2015, 9, 15, 13, 0, tzinfo=dt_util.UTC),
|
||||
set(),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_twilight_condition_type(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
condition_key: str,
|
||||
now: datetime,
|
||||
expected_true: set[str],
|
||||
) -> None:
|
||||
"""Test the morning/evening twilight conditions honor the twilight type band.
|
||||
|
||||
At a single point in time every twilight type is checked, so the mutually
|
||||
exclusive bands are all asserted together.
|
||||
"""
|
||||
latitude, longitude, time_zone = _SAN_DIEGO
|
||||
await hass.config.async_set_time_zone(time_zone)
|
||||
hass.config.latitude = latitude
|
||||
hass.config.longitude = longitude
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: [
|
||||
{
|
||||
"trigger": {"platform": "event", "event_type": "test_event"},
|
||||
"condition": {
|
||||
"condition": condition_key,
|
||||
"options": {"type": twilight_type},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data": {"type": twilight_type},
|
||||
},
|
||||
}
|
||||
for twilight_type in _TWILIGHT_TYPES
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
with freeze_time(now):
|
||||
hass.bus.async_fire("test_event")
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert {call.data["type"] for call in service_calls} == expected_true
|
||||
|
||||
@@ -2741,6 +2741,18 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None
|
||||
)
|
||||
|
||||
|
||||
_MODERN_SUN_CONDITIONS = (
|
||||
"sun.elevation",
|
||||
"sun.is_ascending",
|
||||
"sun.is_descending",
|
||||
"sun.is_evening_twilight",
|
||||
"sun.is_morning_twilight",
|
||||
"sun.is_night",
|
||||
"sun.is_set",
|
||||
"sun.is_up",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sun_condition_descriptions",
|
||||
[
|
||||
@@ -2883,7 +2895,9 @@ async def test_async_get_all_descriptions(
|
||||
},
|
||||
"before_offset": {"selector": {"time": {}}},
|
||||
}
|
||||
}
|
||||
},
|
||||
# The modern sun conditions have no entry in the mocked conditions.yaml.
|
||||
**dict.fromkeys(_MODERN_SUN_CONDITIONS),
|
||||
}
|
||||
assert descriptions == expected_descriptions
|
||||
|
||||
@@ -3023,7 +3037,7 @@ async def test_async_get_all_descriptions_with_yaml_error(
|
||||
):
|
||||
descriptions = await condition.async_get_all_descriptions(hass)
|
||||
|
||||
assert descriptions == {SUN_DOMAIN: None}
|
||||
assert descriptions == {SUN_DOMAIN: None, **dict.fromkeys(_MODERN_SUN_CONDITIONS)}
|
||||
|
||||
assert expected_message in caplog.text
|
||||
|
||||
@@ -3056,7 +3070,7 @@ async def test_async_get_all_descriptions_with_bad_description(
|
||||
):
|
||||
descriptions = await condition.async_get_all_descriptions(hass)
|
||||
|
||||
assert descriptions == {"sun": None}
|
||||
assert descriptions == {"sun": None, **dict.fromkeys(_MODERN_SUN_CONDITIONS)}
|
||||
|
||||
assert (
|
||||
"Unable to parse conditions.yaml for the sun integration: "
|
||||
@@ -3119,7 +3133,7 @@ async def test_subscribe_conditions(
|
||||
|
||||
assert await async_setup_component(hass, "sun", {})
|
||||
|
||||
assert condition_events == [{"sun"}]
|
||||
assert condition_events == [{"sun", *_MODERN_SUN_CONDITIONS}]
|
||||
assert "Error while notifying condition platform listener" in caplog.text
|
||||
|
||||
await hass.data["entity_components"][SUN_DOMAIN]._async_reset()
|
||||
|
||||
Reference in New Issue
Block a user