1
0
mirror of https://github.com/home-assistant/core.git synced 2026-08-06 13:26:29 +01:00

Add sun event trigger offset (#176967)

This commit is contained in:
Martin Hjelmare
2026-07-29 09:37:53 +02:00
committed by GitHub
parent 8d9d053e64
commit 08f49c39fc
4 changed files with 207 additions and 13 deletions
+66
View File
@@ -2,6 +2,10 @@
"common": {
"condition_threshold_name": "Threshold type",
"trigger_for_name": "For at least",
"trigger_offset_description": "Time to offset the trigger from the solar event.",
"trigger_offset_name": "Offset",
"trigger_offset_type_description": "Whether to trigger before or after the solar event.",
"trigger_offset_type_name": "Offset type",
"trigger_threshold_name": "Threshold type",
"twilight_type_description": "The phase of twilight.",
"twilight_type_name": "Twilight type"
@@ -95,6 +99,12 @@
}
},
"selector": {
"trigger_offset_type": {
"options": {
"after": "After",
"before": "Before"
}
},
"twilight_type": {
"options": {
"any": "Any",
@@ -109,6 +119,14 @@
"dawn": {
"description": "Triggers at dawn, when civil, nautical, or astronomical twilight begins.",
"fields": {
"offset": {
"description": "[%key:component::sun::common::trigger_offset_description%]",
"name": "[%key:component::sun::common::trigger_offset_name%]"
},
"offset_type": {
"description": "[%key:component::sun::common::trigger_offset_type_description%]",
"name": "[%key:component::sun::common::trigger_offset_type_name%]"
},
"type": {
"description": "[%key:component::sun::common::twilight_type_description%]",
"name": "[%key:component::sun::common::twilight_type_name%]"
@@ -119,6 +137,14 @@
"dusk": {
"description": "Triggers at dusk, when civil, nautical, or astronomical twilight ends.",
"fields": {
"offset": {
"description": "[%key:component::sun::common::trigger_offset_description%]",
"name": "[%key:component::sun::common::trigger_offset_name%]"
},
"offset_type": {
"description": "[%key:component::sun::common::trigger_offset_type_description%]",
"name": "[%key:component::sun::common::trigger_offset_type_name%]"
},
"type": {
"description": "[%key:component::sun::common::twilight_type_description%]",
"name": "[%key:component::sun::common::twilight_type_name%]"
@@ -149,18 +175,58 @@
},
"solar_midnight": {
"description": "Triggers when the sun reaches its lowest point.",
"fields": {
"offset": {
"description": "[%key:component::sun::common::trigger_offset_description%]",
"name": "[%key:component::sun::common::trigger_offset_name%]"
},
"offset_type": {
"description": "[%key:component::sun::common::trigger_offset_type_description%]",
"name": "[%key:component::sun::common::trigger_offset_type_name%]"
}
},
"name": "Solar midnight"
},
"solar_noon": {
"description": "Triggers when the sun reaches its highest point.",
"fields": {
"offset": {
"description": "[%key:component::sun::common::trigger_offset_description%]",
"name": "[%key:component::sun::common::trigger_offset_name%]"
},
"offset_type": {
"description": "[%key:component::sun::common::trigger_offset_type_description%]",
"name": "[%key:component::sun::common::trigger_offset_type_name%]"
}
},
"name": "Solar noon"
},
"sunrise": {
"description": "Triggers when the sun rises.",
"fields": {
"offset": {
"description": "[%key:component::sun::common::trigger_offset_description%]",
"name": "[%key:component::sun::common::trigger_offset_name%]"
},
"offset_type": {
"description": "[%key:component::sun::common::trigger_offset_type_description%]",
"name": "[%key:component::sun::common::trigger_offset_type_name%]"
}
},
"name": "Sunrise"
},
"sunset": {
"description": "Triggers when the sun sets.",
"fields": {
"offset": {
"description": "[%key:component::sun::common::trigger_offset_description%]",
"name": "[%key:component::sun::common::trigger_offset_name%]"
},
"offset_type": {
"description": "[%key:component::sun::common::trigger_offset_type_description%]",
"name": "[%key:component::sun::common::trigger_offset_type_name%]"
}
},
"name": "Sunset"
}
}
+25 -9
View File
@@ -64,6 +64,19 @@ _TWILIGHT_CIVIL = "civil"
_TWILIGHT_NAUTICAL = "nautical"
_TWILIGHT_ASTRONOMICAL = "astronomical"
CONF_OFFSET_TYPE = "offset_type"
OFFSET_TYPE_BEFORE = "before"
OFFSET_TYPE_AFTER = "after"
# Offset options shared by the solar event triggers. A positive offset combined
# with an offset type of "before" fires earlier than the event; "after" later.
_OFFSET_OPTIONS: dict[vol.Marker, Any] = {
vol.Required(CONF_OFFSET, default=timedelta(0)): cv.time_period,
vol.Required(CONF_OFFSET_TYPE, default=OFFSET_TYPE_BEFORE): vol.In(
{OFFSET_TYPE_BEFORE, OFFSET_TYPE_AFTER}
),
}
# Sun elevation at each twilight boundary.
_TWILIGHT_ELEVATIONS = {
_TWILIGHT_CIVIL: ELEVATION_CIVIL,
@@ -134,7 +147,9 @@ class SunElevationCrossedTrigger(
_schema = _ELEVATION_CROSSED_TRIGGER_SCHEMA
_EVENT_TRIGGER_SCHEMA = vol.Schema({vol.Required(CONF_OPTIONS, default=dict): {}})
_EVENT_TRIGGER_SCHEMA = vol.Schema(
{vol.Required(CONF_OPTIONS, default=dict): {**_OFFSET_OPTIONS}}
)
class SunEventTrigger(Trigger):
@@ -155,10 +170,16 @@ class SunEventTrigger(Trigger):
"""Initialize the trigger."""
super().__init__(hass, config)
self._options = config.options or {}
offset = self._options.get(CONF_OFFSET) or timedelta(0)
if self._options.get(CONF_OFFSET_TYPE) == OFFSET_TYPE_BEFORE:
offset = -offset
self._offset = offset
def _get_next_event(self, utc_point_in_time: datetime) -> datetime:
"""Return the next time this solar event occurs."""
return get_astral_event_next(self._hass, self._event, utc_point_in_time)
return get_astral_event_next(
self._hass, self._event, utc_point_in_time, self._offset
)
def _action_payload(self) -> dict[str, Any]:
"""Return extra trigger payload passed to the action."""
@@ -235,6 +256,7 @@ _DAWN_DUSK_TRIGGER_SCHEMA = vol.Schema(
vol.Optional(CONF_TYPE, default=_TWILIGHT_CIVIL): vol.In(
_TWILIGHT_ELEVATIONS
),
**_OFFSET_OPTIONS,
}
}
)
@@ -257,6 +279,7 @@ class SunDawnDuskTrigger(SunEventTrigger):
get_astral_observer(self._hass),
self._event,
utc_point_in_time,
self._offset,
# astral takes a depression (degrees below the horizon), i.e. the
# negated elevation.
depression=-self._elevation,
@@ -305,13 +328,6 @@ class LegacySunTrigger(SunEventTrigger):
"""Initialize the trigger."""
super().__init__(hass, config)
self._event = self._options[CONF_EVENT]
self._offset: timedelta = self._options[CONF_OFFSET]
@override
def _get_next_event(self, utc_point_in_time: datetime) -> datetime:
return get_astral_event_next(
self._hass, self._event, utc_point_in_time, self._offset
)
@override
def _action_payload(self) -> dict[str, Any]:
+38 -4
View File
@@ -15,6 +15,27 @@
selector:
duration:
.offset: &trigger_offset
offset:
required: true
default:
days: 0
hours: 0
minutes: 0
seconds: 0
selector:
duration:
enable_day: true
offset_type:
required: true
default: before
selector:
select:
translation_key: trigger_offset_type
options:
- before
- after
.elevation_threshold_entity: &trigger_elevation_threshold_entity
- domain: input_number
unit_of_measurement: "°"
@@ -29,18 +50,31 @@
mode: box
unit_of_measurement: "°"
sunrise: {}
sunset: {}
solar_noon: {}
solar_midnight: {}
sunrise:
fields:
<<: *trigger_offset
sunset:
fields:
<<: *trigger_offset
solar_noon:
fields:
<<: *trigger_offset
solar_midnight:
fields:
<<: *trigger_offset
dawn:
fields:
type: *twilight_type
<<: *trigger_offset
dusk:
fields:
type: *twilight_type
<<: *trigger_offset
elevation_changed:
fields:
+78
View File
@@ -338,6 +338,84 @@ async def test_dawn_defaults_to_civil(
assert service_calls[0].data["type"] == "civil"
@pytest.mark.parametrize(
("trigger_key", "astral_event"),
[
("sun.sunrise", SUN_EVENT_SUNRISE),
("sun.sunset", SUN_EVENT_SUNSET),
("sun.solar_noon", "noon"),
("sun.solar_midnight", "midnight"),
],
)
@pytest.mark.parametrize(
("offset_type", "sign"),
[("before", -1), ("after", 1)],
ids=["before", "after"],
)
async def test_event_trigger_offset(
hass: HomeAssistant,
service_calls: list[ServiceCall],
trigger_key: str,
astral_event: str,
offset_type: str,
sign: int,
) -> None:
"""Test the solar event triggers apply a before/after time offset."""
offset = timedelta(hours=1)
with freeze_time(_TEST_DATETIME):
await _arm_automation(
hass,
{
"platform": trigger_key,
"options": {"offset": {"hours": 1}, "offset_type": offset_type},
},
{},
)
expected = get_astral_event_next(
hass, astral_event, _TEST_DATETIME, sign * offset
)
# The offset shifts the fire time away from the bare event time.
assert expected != get_astral_event_next(hass, astral_event, _TEST_DATETIME)
async_fire_time_changed(hass, expected + timedelta(seconds=1))
await hass.async_block_till_done()
assert len(service_calls) == 1
@pytest.mark.parametrize("trigger_key", ["sun.dawn", "sun.dusk"])
@pytest.mark.parametrize(
("offset_type", "sign"),
[("before", -1), ("after", 1)],
ids=["before", "after"],
)
async def test_dawn_dusk_trigger_offset(
hass: HomeAssistant,
service_calls: list[ServiceCall],
trigger_key: str,
offset_type: str,
sign: int,
) -> None:
"""Test the dawn and dusk triggers apply a before/after time offset."""
event = trigger_key.split(".")[1]
offset = timedelta(hours=1)
with freeze_time(_TEST_DATETIME):
await _arm_automation(
hass,
{
"platform": trigger_key,
"options": {"offset": {"hours": 1}, "offset_type": offset_type},
},
{},
)
expected = _DAWN_DUSK[event, "civil"] + sign * offset
async_fire_time_changed(hass, expected + timedelta(seconds=1))
await hass.async_block_till_done()
assert len(service_calls) == 1
# --- Edge cases: no matching solar event on the following day ----------------
# Longyearbyen, Svalbard (deep polar latitude) and Kotzebue, Alaska (above the