Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix DST handling in TOD #84931

Merged
merged 8 commits into from Nov 10, 2023
23 changes: 21 additions & 2 deletions homeassistant/components/tod/binary_sensor.py
Expand Up @@ -226,6 +226,21 @@ def _calculate_boundary_time(self) -> None:
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:
Expand All @@ -238,7 +253,9 @@ def _turn_to_next_day(self) -> None:
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(
Expand All @@ -247,7 +264,9 @@ def _turn_to_next_day(self) -> None:
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."""
Expand Down
195 changes: 187 additions & 8 deletions tests/components/tod/test_binary_sensor.py
Expand Up @@ -614,34 +614,213 @@ 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)
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: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(
Expand Down