Allow setting to-time in schedule to 24:00 (#77558)

This commit is contained in:
Erik Montnemery 2022-08-30 21:54:31 +02:00 committed by GitHub
parent 4b2e4c8276
commit 7c5a5f86ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 162 additions and 11 deletions

View File

@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime, timedelta from datetime import datetime, time, timedelta
import itertools import itertools
import logging import logging
from typing import Literal from typing import Any, Literal
import voluptuous as vol import voluptuous as vol
@ -81,6 +81,30 @@ def valid_schedule(schedule: list[dict[str, str]]) -> list[dict[str, str]]:
return schedule 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 = { BASE_SCHEMA = {
vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)), vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)),
vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_ICON): cv.icon,
@ -88,12 +112,14 @@ BASE_SCHEMA = {
TIME_RANGE_SCHEMA = { TIME_RANGE_SCHEMA = {
vol.Required(CONF_FROM): cv.time, 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( STORAGE_TIME_RANGE_SCHEMA = vol.Schema(
{ {
vol.Required(CONF_FROM): vol.All(cv.time, vol.Coerce(str)), vol.Required(CONF_FROM): vol.Coerce(str),
vol.Required(CONF_TO): vol.All(cv.time, vol.Coerce(str)), vol.Required(CONF_TO): serialize_to_time,
} }
) )
@ -111,11 +137,17 @@ STORAGE_SCHEDULE_SCHEMA = {
} }
# Validate YAML config
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{DOMAIN: cv.schema_with_slug_keys(vol.All(BASE_SCHEMA | SCHEDULE_SCHEMA))}, {DOMAIN: cv.schema_with_slug_keys(vol.All(BASE_SCHEMA | SCHEDULE_SCHEMA))},
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
# Validate storage config
STORAGE_SCHEMA = vol.Schema( 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 {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: def __init__(self, config: ConfigType, editable: bool = True) -> None:
"""Initialize a schedule.""" """Initialize a schedule."""
self._config = STORAGE_SCHEMA(config) self._config = ENTITY_SCHEMA(config)
self._attr_capability_attributes = {ATTR_EDITABLE: editable} self._attr_capability_attributes = {ATTR_EDITABLE: editable}
self._attr_icon = self._config.get(CONF_ICON) self._attr_icon = self._config.get(CONF_ICON)
self._attr_name = self._config[CONF_NAME] self._attr_name = self._config[CONF_NAME]
@ -234,7 +266,7 @@ class Schedule(Entity):
async def async_update_config(self, config: ConfigType) -> None: async def async_update_config(self, config: ConfigType) -> None:
"""Handle when the config is updated.""" """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_icon = config.get(CONF_ICON)
self._attr_name = config[CONF_NAME] self._attr_name = config[CONF_NAME]
self._clean_up_listener() self._clean_up_listener()

View File

@ -69,7 +69,7 @@ def schedule_setup(
{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}, {CONF_FROM: "00:00:00", CONF_TO: "23:59:59"},
], ],
CONF_SUNDAY: [ 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" 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: async def test_setup_no_config(hass: HomeAssistant, hass_admin_user: MockUser) -> None:
"""Test component setup with no config.""" """Test component setup with no config."""
count_start = len(hass.states.async_entity_ids()) count_start = len(hass.states.async_entity_ids())
@ -310,6 +365,15 @@ async def test_ws_list(
assert len(result) == 1 assert len(result) == 1
assert result["from_storage"][ATTR_NAME] == "from storage" 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 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.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( async def test_update(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]],
schedule_setup: Callable[..., Coroutine[Any, Any, bool]], schedule_setup: Callable[..., Coroutine[Any, Any, bool]],
to: str,
next_event: str,
saved_to: str,
) -> None: ) -> None:
"""Test updating the schedule.""" """Test updating the schedule."""
ent_reg = er.async_get(hass) ent_reg = er.async_get(hass)
@ -369,7 +444,7 @@ async def test_update(
CONF_ICON: "mdi:party-pooper", CONF_ICON: "mdi:party-pooper",
CONF_MONDAY: [], CONF_MONDAY: [],
CONF_TUESDAY: [], 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_THURSDAY: [],
CONF_FRIDAY: [], CONF_FRIDAY: [],
CONF_SATURDAY: [], CONF_SATURDAY: [],
@ -384,16 +459,41 @@ async def test_update(
assert state.state == STATE_ON assert state.state == STATE_ON
assert state.attributes[ATTR_FRIENDLY_NAME] == "Party pooper" assert state.attributes[ATTR_FRIENDLY_NAME] == "Party pooper"
assert state.attributes[ATTR_ICON] == "mdi: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.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( async def test_ws_create(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]],
schedule_setup: Callable[..., Coroutine[Any, Any, bool]], schedule_setup: Callable[..., Coroutine[Any, Any, bool]],
freezer,
to: str,
next_event: str,
saved_to: str,
) -> None: ) -> None:
"""Test create WS.""" """Test create WS."""
freezer.move_to("2022-08-11 8:52:00-07:00")
ent_reg = er.async_get(hass) ent_reg = er.async_get(hass)
assert await schedule_setup(items=[]) assert await schedule_setup(items=[])
@ -409,7 +509,7 @@ async def test_ws_create(
"type": f"{DOMAIN}/create", "type": f"{DOMAIN}/create",
"name": "Party mode", "name": "Party mode",
"icon": "mdi:party-popper", "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() 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_EDITABLE] is True
assert state.attributes[ATTR_ICON] == "mdi:party-popper" assert state.attributes[ATTR_ICON] == "mdi:party-popper"
assert state.attributes[ATTR_NEXT_EVENT].isoformat() == "2022-08-15T12:00:00-07:00" 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}
]