From 7c5a5f86ee41765d4c04a8da9e4775ab1462bfe9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 30 Aug 2022 21:54:31 +0200 Subject: [PATCH] Allow setting to-time in schedule to 24:00 (#77558) --- homeassistant/components/schedule/__init__.py | 46 ++++++- tests/components/schedule/test_init.py | 127 +++++++++++++++++- 2 files changed, 162 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 96d452469a5..c698993440a 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -2,10 +2,10 @@ from __future__ import annotations from collections.abc import Callable -from datetime import datetime, timedelta +from datetime import datetime, time, timedelta import itertools import logging -from typing import Literal +from typing import Any, Literal import voluptuous as vol @@ -81,6 +81,30 @@ def valid_schedule(schedule: list[dict[str, str]]) -> list[dict[str, str]]: return schedule +def deserialize_to_time(value: Any) -> Any: + """Convert 24:00 and 24:00:00 to time.max.""" + if not isinstance(value, str): + return cv.time(value) + + parts = value.split(":") + if len(parts) < 2: + return cv.time(value) + hour = int(parts[0]) + minute = int(parts[1]) + + if hour == 24 and minute == 0: + return time.max + + return cv.time(value) + + +def serialize_to_time(value: Any) -> Any: + """Convert time.max to 24:00:00.""" + if value == time.max: + return "24:00:00" + return vol.Coerce(str)(value) + + BASE_SCHEMA = { vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)), vol.Optional(CONF_ICON): cv.icon, @@ -88,12 +112,14 @@ BASE_SCHEMA = { TIME_RANGE_SCHEMA = { vol.Required(CONF_FROM): cv.time, - vol.Required(CONF_TO): cv.time, + vol.Required(CONF_TO): deserialize_to_time, } + +# Serialize time in validated config STORAGE_TIME_RANGE_SCHEMA = vol.Schema( { - vol.Required(CONF_FROM): vol.All(cv.time, vol.Coerce(str)), - vol.Required(CONF_TO): vol.All(cv.time, vol.Coerce(str)), + vol.Required(CONF_FROM): vol.Coerce(str), + vol.Required(CONF_TO): serialize_to_time, } ) @@ -111,11 +137,17 @@ STORAGE_SCHEDULE_SCHEMA = { } +# Validate YAML config CONFIG_SCHEMA = vol.Schema( {DOMAIN: cv.schema_with_slug_keys(vol.All(BASE_SCHEMA | SCHEDULE_SCHEMA))}, extra=vol.ALLOW_EXTRA, ) +# Validate storage config STORAGE_SCHEMA = vol.Schema( + {vol.Required(CONF_ID): cv.string} | BASE_SCHEMA | STORAGE_SCHEDULE_SCHEMA +) +# Validate + transform entity config +ENTITY_SCHEMA = vol.Schema( {vol.Required(CONF_ID): cv.string} | BASE_SCHEMA | SCHEDULE_SCHEMA ) @@ -219,7 +251,7 @@ class Schedule(Entity): def __init__(self, config: ConfigType, editable: bool = True) -> None: """Initialize a schedule.""" - self._config = STORAGE_SCHEMA(config) + self._config = ENTITY_SCHEMA(config) self._attr_capability_attributes = {ATTR_EDITABLE: editable} self._attr_icon = self._config.get(CONF_ICON) self._attr_name = self._config[CONF_NAME] @@ -234,7 +266,7 @@ class Schedule(Entity): async def async_update_config(self, config: ConfigType) -> None: """Handle when the config is updated.""" - self._config = STORAGE_SCHEMA(config) + self._config = ENTITY_SCHEMA(config) self._attr_icon = config.get(CONF_ICON) self._attr_name = config[CONF_NAME] self._clean_up_listener() diff --git a/tests/components/schedule/test_init.py b/tests/components/schedule/test_init.py index a1161800e9e..825bac5686c 100644 --- a/tests/components/schedule/test_init.py +++ b/tests/components/schedule/test_init.py @@ -69,7 +69,7 @@ def schedule_setup( {CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}, ], CONF_SUNDAY: [ - {CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}, + {CONF_FROM: "00:00:00", CONF_TO: "24:00:00"}, ], } ] @@ -225,6 +225,61 @@ async def test_events_one_day( assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-09-11T07:00:00-07:00" +@pytest.mark.parametrize( + "schedule", + ( + {CONF_FROM: "00:00:00", CONF_TO: "24:00"}, + {CONF_FROM: "00:00:00", CONF_TO: "24:00:00"}, + ), +) +async def test_to_midnight( + hass: HomeAssistant, + schedule_setup: Callable[..., Coroutine[Any, Any, bool]], + caplog: pytest.LogCaptureFixture, + schedule: list[dict[str, str]], + freezer, +) -> None: + """Test time range allow to 24:00.""" + 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: schedule, + } + } + }, + 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-04T00: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-04T23:59:59.999999-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-11T00:00:00-07:00" + + async def test_setup_no_config(hass: HomeAssistant, hass_admin_user: MockUser) -> None: """Test component setup with no config.""" count_start = len(hass.states.async_entity_ids()) @@ -310,6 +365,15 @@ async def test_ws_list( assert len(result) == 1 assert result["from_storage"][ATTR_NAME] == "from storage" + assert result["from_storage"][CONF_FRIDAY] == [ + {CONF_FROM: "17:00:00", CONF_TO: "23:59:59"} + ] + assert result["from_storage"][CONF_SATURDAY] == [ + {CONF_FROM: "00:00:00", CONF_TO: "23:59:59"} + ] + assert result["from_storage"][CONF_SUNDAY] == [ + {CONF_FROM: "00:00:00", CONF_TO: "24:00:00"} + ] assert "from_yaml" not in result @@ -340,10 +404,21 @@ async def test_ws_delete( @pytest.mark.freeze_time("2022-08-10 20:10:00-07:00") +@pytest.mark.parametrize( + "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"), + ), +) async def test_update( hass: HomeAssistant, hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], schedule_setup: Callable[..., Coroutine[Any, Any, bool]], + to: str, + next_event: str, + saved_to: str, ) -> None: """Test updating the schedule.""" ent_reg = er.async_get(hass) @@ -369,7 +444,7 @@ async def test_update( CONF_ICON: "mdi:party-pooper", CONF_MONDAY: [], CONF_TUESDAY: [], - CONF_WEDNESDAY: [{CONF_FROM: "17:00:00", CONF_TO: "23:59:59"}], + CONF_WEDNESDAY: [{CONF_FROM: "17:00:00", CONF_TO: to}], CONF_THURSDAY: [], CONF_FRIDAY: [], CONF_SATURDAY: [], @@ -384,16 +459,41 @@ async def test_update( assert state.state == STATE_ON assert state.attributes[ATTR_FRIENDLY_NAME] == "Party pooper" assert state.attributes[ATTR_ICON] == "mdi:party-pooper" - assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-08-10T23:59:59-07:00" + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == next_event + + await client.send_json({"id": 2, "type": f"{DOMAIN}/list"}) + resp = await client.receive_json() + assert resp["success"] + + result = {item["id"]: item for item in resp["result"]} + + assert len(result) == 1 + assert result["from_storage"][CONF_WEDNESDAY] == [ + {CONF_FROM: "17:00:00", CONF_TO: saved_to} + ] @pytest.mark.freeze_time("2022-08-11 8:52:00-07:00") +@pytest.mark.parametrize( + "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"), + ), +) async def test_ws_create( hass: HomeAssistant, hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], schedule_setup: Callable[..., Coroutine[Any, Any, bool]], + freezer, + to: str, + next_event: str, + saved_to: str, ) -> None: """Test create WS.""" + freezer.move_to("2022-08-11 8:52:00-07:00") + ent_reg = er.async_get(hass) assert await schedule_setup(items=[]) @@ -409,7 +509,7 @@ async def test_ws_create( "type": f"{DOMAIN}/create", "name": "Party mode", "icon": "mdi:party-popper", - "monday": [{"from": "12:00:00", "to": "14:00:00"}], + "monday": [{"from": "12:00:00", "to": to}], } ) resp = await client.receive_json() @@ -422,3 +522,22 @@ async def test_ws_create( assert state.attributes[ATTR_EDITABLE] is True assert state.attributes[ATTR_ICON] == "mdi:party-popper" assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-08-15T12:00:00-07:00" + + freezer.move_to(state.attributes[ATTR_NEXT_EVENT]) + async_fire_time_changed(hass) + + state = hass.states.get("schedule.party_mode") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_NEXT_EVENT].isoformat() == next_event + + await client.send_json({"id": 2, "type": f"{DOMAIN}/list"}) + resp = await client.receive_json() + assert resp["success"] + + result = {item["id"]: item for item in resp["result"]} + + assert len(result) == 1 + assert result["party_mode"][CONF_MONDAY] == [ + {CONF_FROM: "12:00:00", CONF_TO: saved_to} + ]