mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 03:07:37 +00:00
Allow setting to-time in schedule to 24:00 (#77558)
This commit is contained in:
parent
4b2e4c8276
commit
7c5a5f86ee
@ -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()
|
||||||
|
@ -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}
|
||||||
|
]
|
||||||
|
Loading…
x
Reference in New Issue
Block a user