1
0
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:
Erik Montnemery
2026-06-24 16:00:08 +02:00
committed by GitHub
parent dfcc4d1ae4
commit de9d9c66c1
9 changed files with 721 additions and 37 deletions
+217 -3
View File
@@ -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
+11
View File
@@ -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"
+22 -20
View File
@@ -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
+26
View File
@@ -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": {
+53
View File
@@ -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"
+17 -10
View File
@@ -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
+311
View File
@@ -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
+18 -4
View File
@@ -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()