From 82ce5e56b5e610ced30838e805b0bf7b49a1740e Mon Sep 17 00:00:00 2001 From: Jean-Marie White Date: Sat, 11 Nov 2023 02:25:25 +1300 Subject: [PATCH] Fix DST handling in TOD (#84931) Co-authored-by: J. Nick Koston --- homeassistant/components/tod/binary_sensor.py | 23 ++- tests/components/tod/test_binary_sensor.py | 195 +++++++++++++++++- 2 files changed, 208 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py index f72aa742f56..c3f2c75e07b 100644 --- a/homeassistant/components/tod/binary_sensor.py +++ b/homeassistant/components/tod/binary_sensor.py @@ -226,6 +226,21 @@ class TodSensor(BinarySensorEntity): self._time_after += self._after_offset self._time_before += self._before_offset + def _add_one_dst_aware_day(self, a_date: datetime, target_time: time) -> datetime: + """Add 24 hours (1 day) but account for DST.""" + tentative_new_date = a_date + timedelta(days=1) + tentative_new_date = dt_util.as_local(tentative_new_date) + tentative_new_date = tentative_new_date.replace( + hour=target_time.hour, minute=target_time.minute + ) + # The following call addresses missing time during DST jumps + return dt_util.find_next_time_expression_time( + tentative_new_date, + dt_util.parse_time_expression("*", 0, 59), + dt_util.parse_time_expression("*", 0, 59), + dt_util.parse_time_expression("*", 0, 23), + ) + def _turn_to_next_day(self) -> None: """Turn to to the next day.""" if TYPE_CHECKING: @@ -238,7 +253,9 @@ class TodSensor(BinarySensorEntity): self._time_after += self._after_offset else: # Offset is already there - self._time_after += timedelta(days=1) + self._time_after = self._add_one_dst_aware_day( + self._time_after, self._after + ) if _is_sun_event(self._before): self._time_before = get_astral_event_next( @@ -247,7 +264,9 @@ class TodSensor(BinarySensorEntity): self._time_before += self._before_offset else: # Offset is already there - self._time_before += timedelta(days=1) + self._time_before = self._add_one_dst_aware_day( + self._time_before, self._before + ) async def async_added_to_hass(self) -> None: """Call when entity about to be added to Home Assistant.""" diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index c1823c23f8b..c7979b884d4 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -614,21 +614,62 @@ async def test_sun_offset( assert state.state == STATE_ON -async def test_dst( +async def test_dst1( hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: - """Test sun event with offset.""" + """Test DST when time falls in non-existent hour. Also check 48 hours later.""" hass.config.time_zone = "CET" dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) - test_time = datetime(2019, 3, 30, 3, 0, 0, tzinfo=hass_tz_info) + test_time1 = datetime(2019, 3, 30, 3, 0, 0, tzinfo=dt_util.get_time_zone("CET")) + test_time2 = datetime(2019, 3, 31, 3, 0, 0, tzinfo=dt_util.get_time_zone("CET")) config = { "binary_sensor": [ {"platform": "tod", "name": "Day", "after": "2:30", "before": "2:40"} ] } - # Test DST: + # Test DST #1: # after 2019-03-30 03:00 CET the next update should ge scheduled - # at 3:30 not 2:30 local time + # at 2:30am, but on 2019-03-31, that hour does not exist. That means + # the start/end will end up happning on the next available second (3am) + # Essentially, the ToD sensor never turns on that day. + entity_id = "binary_sensor.day" + freezer.move_to(test_time1) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["before"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T03:00:00+02:00" + assert state.state == STATE_OFF + + # But the following day, the sensor should resume it normal operation. + freezer.move_to(test_time2) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-04-01T02:30:00+02:00" + assert state.attributes["before"] == "2019-04-01T02:40:00+02:00" + assert state.attributes["next_update"] == "2019-04-01T02:30:00+02:00" + + assert state.state == STATE_OFF + + +async def test_dst2(hass, freezer, hass_tz_info): + """Test DST when there's a time switch in the East.""" + hass.config.time_zone = "CET" + dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) + test_time = datetime(2019, 3, 30, 5, 0, 0, tzinfo=dt_util.get_time_zone("CET")) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "4:30", "before": "4:40"} + ] + } + # Test DST #2: + # after 2019-03-30 05:00 CET the next update should ge scheduled + # at 4:30+02 not 4:30+01 entity_id = "binary_sensor.day" freezer.move_to(test_time) await async_setup_component(hass, "binary_sensor", config) @@ -636,12 +677,150 @@ async def test_dst( await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.attributes["after"] == "2019-03-31T03:30:00+02:00" - assert state.attributes["before"] == "2019-03-31T03:40:00+02:00" - assert state.attributes["next_update"] == "2019-03-31T03:30:00+02:00" + assert state.attributes["after"] == "2019-03-31T04:30:00+02:00" + assert state.attributes["before"] == "2019-03-31T04:40:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T04:30:00+02:00" assert state.state == STATE_OFF +async def test_dst3(hass, freezer, hass_tz_info): + """Test DST when there's a time switch forward in the West.""" + hass.config.time_zone = "US/Pacific" + dt_util.set_default_time_zone(dt_util.get_time_zone("US/Pacific")) + test_time = datetime( + 2023, 3, 11, 5, 0, 0, tzinfo=dt_util.get_time_zone("US/Pacific") + ) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "4:30", "before": "4:40"} + ] + } + # Test DST #3: + # after 2023-03-11 05:00 Pacific the next update should ge scheduled + # at 4:30-07 not 4:30-08 + entity_id = "binary_sensor.day" + freezer.move_to(test_time) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2023-03-12T04:30:00-07:00" + assert state.attributes["before"] == "2023-03-12T04:40:00-07:00" + assert state.attributes["next_update"] == "2023-03-12T04:30:00-07:00" + assert state.state == STATE_OFF + + +async def test_dst4(hass, freezer, hass_tz_info): + """Test DST when there's a time switch backward in the West.""" + hass.config.time_zone = "US/Pacific" + dt_util.set_default_time_zone(dt_util.get_time_zone("US/Pacific")) + test_time = datetime( + 2023, 11, 4, 5, 0, 0, tzinfo=dt_util.get_time_zone("US/Pacific") + ) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "4:30", "before": "4:40"} + ] + } + # Test DST #4: + # after 2023-11-04 05:00 Pacific the next update should ge scheduled + # at 4:30-08 not 4:30-07 + entity_id = "binary_sensor.day" + freezer.move_to(test_time) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2023-11-05T04:30:00-08:00" + assert state.attributes["before"] == "2023-11-05T04:40:00-08:00" + assert state.attributes["next_update"] == "2023-11-05T04:30:00-08:00" + assert state.state == STATE_OFF + + +async def test_dst5( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: + """Test DST when end time falls in non-existent hour (1:50am-2:10am).""" + hass.config.time_zone = "CET" + dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) + test_time1 = datetime(2019, 3, 30, 3, 0, 0, tzinfo=dt_util.get_time_zone("CET")) + test_time2 = datetime(2019, 3, 31, 1, 51, 0, tzinfo=dt_util.get_time_zone("CET")) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "1:50", "before": "2:10"} + ] + } + # Test DST #5: + # Test the case where the end time does not exist (roll out to the next available time) + # First test before the sensor is turned on + entity_id = "binary_sensor.day" + freezer.move_to(test_time1) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T01:50:00+01:00" + assert state.attributes["before"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T01:50:00+01:00" + assert state.state == STATE_OFF + + # Seconds, test state when sensor is ON but end time has rolled out to next available time. + freezer.move_to(test_time2) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T01:50:00+01:00" + assert state.attributes["before"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T03:00:00+02:00" + + assert state.state == STATE_ON + + +async def test_dst6( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: + """Test DST when start time falls in non-existent hour (2:50am 3:10am).""" + hass.config.time_zone = "CET" + dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) + test_time1 = datetime(2019, 3, 30, 4, 0, 0, tzinfo=dt_util.get_time_zone("CET")) + test_time2 = datetime(2019, 3, 31, 3, 1, 0, tzinfo=dt_util.get_time_zone("CET")) + config = { + "binary_sensor": [ + {"platform": "tod", "name": "Day", "after": "2:50", "before": "3:10"} + ] + } + # Test DST #6: + # Test the case where the end time does not exist (roll out to the next available time) + # First test before the sensor is turned on + entity_id = "binary_sensor.day" + freezer.move_to(test_time1) + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["before"] == "2019-03-31T03:10:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T03:00:00+02:00" + assert state.state == STATE_OFF + + # Seconds, test state when sensor is ON but end time has rolled out to next available time. + freezer.move_to(test_time2) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes["after"] == "2019-03-31T03:00:00+02:00" + assert state.attributes["before"] == "2019-03-31T03:10:00+02:00" + assert state.attributes["next_update"] == "2019-03-31T03:10:00+02:00" + + assert state.state == STATE_ON + + @pytest.mark.freeze_time("2019-01-10 18:43:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_not_in_range(