Fix DST handling in TOD (#84931)

Co-authored-by: J. Nick Koston <nick@koston.org>
pull/103754/head
Jean-Marie White 2023-11-11 02:25:25 +13:00 committed by GitHub
parent 3666af0b10
commit 82ce5e56b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 208 additions and 10 deletions

View File

@ -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."""

View File

@ -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(