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
+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()