diff --git a/homeassistant/components/sun/strings.json b/homeassistant/components/sun/strings.json index a6c177b44096..bb619b8fede5 100644 --- a/homeassistant/components/sun/strings.json +++ b/homeassistant/components/sun/strings.json @@ -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" } } diff --git a/homeassistant/components/sun/trigger.py b/homeassistant/components/sun/trigger.py index 4eacc55a58c0..6ffd759d752a 100644 --- a/homeassistant/components/sun/trigger.py +++ b/homeassistant/components/sun/trigger.py @@ -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]: diff --git a/homeassistant/components/sun/triggers.yaml b/homeassistant/components/sun/triggers.yaml index c51ffa942fdf..7342180247ab 100644 --- a/homeassistant/components/sun/triggers.yaml +++ b/homeassistant/components/sun/triggers.yaml @@ -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: diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index 50a0d6ba831a..f4f8149d669c 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -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