From de9d9c66c19f909bbf7f73249f6de88ba2a4ed9d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 24 Jun 2026 16:00:08 +0200 Subject: [PATCH] Add additional sun conditions (#174537) --- homeassistant/components/sun/condition.py | 220 ++++++++++++- homeassistant/components/sun/conditions.yaml | 46 +++ homeassistant/components/sun/const.py | 11 + homeassistant/components/sun/entity.py | 42 +-- homeassistant/components/sun/icons.json | 26 ++ homeassistant/components/sun/strings.json | 53 ++++ homeassistant/components/sun/trigger.py | 27 +- tests/components/sun/test_condition.py | 311 +++++++++++++++++++ tests/helpers/test_condition.py | 22 +- 9 files changed, 721 insertions(+), 37 deletions(-) create mode 100644 homeassistant/components/sun/conditions.yaml diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py index 8923fc3f50ef..1c5378139857 100644 --- a/homeassistant/components/sun/condition.py +++ b/homeassistant/components/sun/condition.py @@ -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, } diff --git a/homeassistant/components/sun/conditions.yaml b/homeassistant/components/sun/conditions.yaml new file mode 100644 index 000000000000..d4c4b67625d0 --- /dev/null +++ b/homeassistant/components/sun/conditions.yaml @@ -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 diff --git a/homeassistant/components/sun/const.py b/homeassistant/components/sun/const.py index 949bd4e2fbbc..53a2e67c4950 100644 --- a/homeassistant/components/sun/const.py +++ b/homeassistant/components/sun/const.py @@ -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" diff --git a/homeassistant/components/sun/entity.py b/homeassistant/components/sun/entity.py index f93ee3961311..b4ea8fe2106e 100644 --- a/homeassistant/components/sun/entity.py +++ b/homeassistant/components/sun/entity.py @@ -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 diff --git a/homeassistant/components/sun/icons.json b/homeassistant/components/sun/icons.json index 687a5768d22a..113af595489f 100644 --- a/homeassistant/components/sun/icons.json +++ b/homeassistant/components/sun/icons.json @@ -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": { diff --git a/homeassistant/components/sun/strings.json b/homeassistant/components/sun/strings.json index 8c0c5641c7ae..a6c177b44096 100644 --- a/homeassistant/components/sun/strings.json +++ b/homeassistant/components/sun/strings.json @@ -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" diff --git a/homeassistant/components/sun/trigger.py b/homeassistant/components/sun/trigger.py index 5947d7ea8ec3..4eacc55a58c0 100644 --- a/homeassistant/components/sun/trigger.py +++ b/homeassistant/components/sun/trigger.py @@ -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 diff --git a/tests/components/sun/test_condition.py b/tests/components/sun/test_condition.py index 0375525268d4..32fa1383825e 100644 --- a/tests/components/sun/test_condition.py +++ b/tests/components/sun/test_condition.py @@ -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 diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 9b37501f08a4..8dadec50063c 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -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()