diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 6ad3bcff58e47ee707f1fd2b5428d33118f0501d..f5519e93c3fad83fa2afb57c77524ac76fd9750c 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -291,14 +291,17 @@ class Schedule(Entity): todays_schedule = self._config.get(WEEKDAY_TO_CONF[now.weekday()], []) # Determine current schedule state - self._attr_state = next( - ( - STATE_ON - for time_range in todays_schedule - if time_range[CONF_FROM] <= now.time() <= time_range[CONF_TO] - ), - STATE_OFF, - ) + for time_range in todays_schedule: + # The current time should be greater or equal to CONF_FROM. + if now.time() < time_range[CONF_FROM]: + continue + # The current time should be smaller (and not equal) to CONF_TO. + # Note that any time in the day is treated as smaller than time.max. + if now.time() < time_range[CONF_TO] or time_range[CONF_TO] == time.max: + self._attr_state = STATE_ON + break + else: + self._attr_state = STATE_OFF # Find next event in the schedule, loop over each day (starting with # the current day) until the next event has been found. @@ -319,11 +322,15 @@ class Schedule(Entity): if next_event := next( ( possible_next_event - for time in times + for timestamp in times if ( possible_next_event := ( - datetime.combine(now.date(), time, tzinfo=now.tzinfo) + datetime.combine(now.date(), timestamp, tzinfo=now.tzinfo) + timedelta(days=day) + if not timestamp == time.max + # Special case for midnight of the following day. + else datetime.combine(now.date(), time(), tzinfo=now.tzinfo) + + timedelta(days=day + 1) ) ) > now diff --git a/tests/components/schedule/test_init.py b/tests/components/schedule/test_init.py index 5d5de58134935722dbf689ca81021aefa35587bd..0eda21f5ba0bf8ae6de5b826ff4bf014cdd7ebcb 100644 --- a/tests/components/schedule/test_init.py +++ b/tests/components/schedule/test_init.py @@ -221,25 +221,14 @@ async def test_events_one_day( state = hass.states.get(f"{DOMAIN}.from_yaml") assert state - assert state.state == STATE_ON + assert state.state == STATE_OFF assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-11T07:00:00-07:00" -@pytest.mark.parametrize( - "sun_schedule, mon_schedule", - ( - ( - {CONF_FROM: "23:00:00", CONF_TO: "24:00:00"}, - {CONF_FROM: "00:00:00", CONF_TO: "01:00:00"}, - ), - ), -) -async def test_adjacent( +async def test_adjacent_cross_midnight( hass: HomeAssistant, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], caplog: pytest.LogCaptureFixture, - sun_schedule: dict[str, str], - mon_schedule: dict[str, str], freezer, ) -> None: """Test adjacent events don't toggle on->off->on.""" @@ -251,8 +240,8 @@ async def test_adjacent( "from_yaml": { CONF_NAME: "from yaml", CONF_ICON: "mdi:party-popper", - CONF_SUNDAY: sun_schedule, - CONF_MONDAY: mon_schedule, + CONF_SUNDAY: {CONF_FROM: "23:00:00", CONF_TO: "24:00:00"}, + CONF_MONDAY: {CONF_FROM: "00:00:00", CONF_TO: "01:00:00"}, } } }, @@ -272,18 +261,70 @@ async def test_adjacent( state = hass.states.get(f"{DOMAIN}.from_yaml") assert state assert state.state == STATE_ON - assert ( - state.attributes[ATTR_NEXT_EVENT].isoformat() - == "2022-09-04T23:59:59.999999-07:00" + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-05T00:00:00-07:00" + + freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) + async_fire_time_changed(hass) + + state = hass.states.get(f"{DOMAIN}.from_yaml") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-05T01:00:00-07:00" + + freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) + async_fire_time_changed(hass) + + state = hass.states.get(f"{DOMAIN}.from_yaml") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-11T23:00:00-07:00" + + await hass.async_block_till_done() + assert len(state_changes) == 3 + for event in state_changes[:-1]: + assert event.data["new_state"].state == STATE_ON + assert state_changes[2].data["new_state"].state == STATE_OFF + + +async def test_adjacent_within_day( + hass: HomeAssistant, + schedule_setup: Callable[..., Coroutine[Any, Any, bool]], + caplog: pytest.LogCaptureFixture, + freezer, +) -> None: + """Test adjacent events don't toggle on->off->on.""" + freezer.move_to("2022-08-30 13:20:00-07:00") + + assert await schedule_setup( + config={ + DOMAIN: { + "from_yaml": { + CONF_NAME: "from yaml", + CONF_ICON: "mdi:party-popper", + CONF_SUNDAY: [ + {CONF_FROM: "22:00:00", CONF_TO: "22:30:00"}, + {CONF_FROM: "22:30:00", CONF_TO: "23:00:00"}, + ], + } + } + }, + items=[], ) + state = hass.states.get(f"{DOMAIN}.from_yaml") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-04T22:00:00-07:00" + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) async_fire_time_changed(hass) state = hass.states.get(f"{DOMAIN}.from_yaml") assert state assert state.state == STATE_ON - assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-05T00:00:00-07:00" + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-04T22:30:00-07:00" freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) async_fire_time_changed(hass) @@ -291,7 +332,54 @@ async def test_adjacent( state = hass.states.get(f"{DOMAIN}.from_yaml") assert state assert state.state == STATE_ON - assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-05T01:00:00-07:00" + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-04T23:00:00-07:00" + + freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) + async_fire_time_changed(hass) + + state = hass.states.get(f"{DOMAIN}.from_yaml") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-11T22:00:00-07:00" + + await hass.async_block_till_done() + assert len(state_changes) == 3 + for event in state_changes[:-1]: + assert event.data["new_state"].state == STATE_ON + assert state_changes[2].data["new_state"].state == STATE_OFF + + +async def test_non_adjacent_within_day( + hass: HomeAssistant, + schedule_setup: Callable[..., Coroutine[Any, Any, bool]], + caplog: pytest.LogCaptureFixture, + freezer, +) -> None: + """Test adjacent events don't toggle on->off->on.""" + freezer.move_to("2022-08-30 13:20:00-07:00") + + assert await schedule_setup( + config={ + DOMAIN: { + "from_yaml": { + CONF_NAME: "from yaml", + CONF_ICON: "mdi:party-popper", + CONF_SUNDAY: [ + {CONF_FROM: "22:00:00", CONF_TO: "22:15:00"}, + {CONF_FROM: "22:30:00", CONF_TO: "23:00:00"}, + ], + } + } + }, + items=[], + ) + + state = hass.states.get(f"{DOMAIN}.from_yaml") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-04T22:00:00-07:00" + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) async_fire_time_changed(hass) @@ -299,12 +387,38 @@ async def test_adjacent( state = hass.states.get(f"{DOMAIN}.from_yaml") assert state assert state.state == STATE_ON - assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-11T23:00:00-07:00" + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-04T22:15:00-07:00" + + freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) + async_fire_time_changed(hass) + + state = hass.states.get(f"{DOMAIN}.from_yaml") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-04T22:30:00-07:00" + + freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) + async_fire_time_changed(hass) + + state = hass.states.get(f"{DOMAIN}.from_yaml") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-04T23:00:00-07:00" + + freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) + async_fire_time_changed(hass) + + state = hass.states.get(f"{DOMAIN}.from_yaml") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-11T22:00:00-07:00" await hass.async_block_till_done() assert len(state_changes) == 4 - for event in state_changes: - assert event.data["new_state"].state == STATE_ON + assert state_changes[0].data["new_state"].state == STATE_ON + assert state_changes[1].data["new_state"].state == STATE_OFF + assert state_changes[2].data["new_state"].state == STATE_ON + assert state_changes[3].data["new_state"].state == STATE_OFF @pytest.mark.parametrize( @@ -348,17 +462,14 @@ async def test_to_midnight( state = hass.states.get(f"{DOMAIN}.from_yaml") assert state assert state.state == STATE_ON - assert ( - state.attributes[ATTR_NEXT_EVENT].isoformat() - == "2022-09-04T23:59:59.999999-07:00" - ) + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-05T00:00:00-07:00" freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) async_fire_time_changed(hass) state = hass.states.get(f"{DOMAIN}.from_yaml") assert state - assert state.state == STATE_ON + assert state.state == STATE_OFF assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-11T00:00:00-07:00" @@ -490,8 +601,8 @@ async def test_ws_delete( "to, next_event, saved_to", ( ("23:59:59", "2022-08-10T23:59:59-07:00", "23:59:59"), - ("24:00", "2022-08-10T23:59:59.999999-07:00", "24:00:00"), - ("24:00:00", "2022-08-10T23:59:59.999999-07:00", "24:00:00"), + ("24:00", "2022-08-11T00:00:00-07:00", "24:00:00"), + ("24:00:00", "2022-08-11T00:00:00-07:00", "24:00:00"), ), ) async def test_update( @@ -560,8 +671,8 @@ async def test_update( "to, next_event, saved_to", ( ("14:00:00", "2022-08-15T14:00:00-07:00", "14:00:00"), - ("24:00", "2022-08-15T23:59:59.999999-07:00", "24:00:00"), - ("24:00:00", "2022-08-15T23:59:59.999999-07:00", "24:00:00"), + ("24:00", "2022-08-16T00:00:00-07:00", "24:00:00"), + ("24:00:00", "2022-08-16T00:00:00-07:00", "24:00:00"), ), ) async def test_ws_create(