mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
Allow timer state to be restored on restart (#67658)
This commit is contained in:
parent
4d59cb290c
commit
df4ddc6491
@ -9,6 +9,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_EDITABLE,
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
@ -31,10 +32,16 @@ DOMAIN = "timer"
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
|
||||
DEFAULT_DURATION = 0
|
||||
DEFAULT_RESTORE = False
|
||||
|
||||
ATTR_DURATION = "duration"
|
||||
ATTR_REMAINING = "remaining"
|
||||
ATTR_FINISHES_AT = "finishes_at"
|
||||
ATTR_RESTORE = "restore"
|
||||
ATTR_FINISHED_AT = "finished_at"
|
||||
|
||||
CONF_DURATION = "duration"
|
||||
CONF_RESTORE = "restore"
|
||||
|
||||
STATUS_IDLE = "idle"
|
||||
STATUS_ACTIVE = "active"
|
||||
@ -58,11 +65,13 @@ CREATE_FIELDS = {
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_ICON): cv.icon,
|
||||
vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): cv.time_period,
|
||||
vol.Optional(CONF_RESTORE, default=DEFAULT_RESTORE): cv.boolean,
|
||||
}
|
||||
UPDATE_FIELDS = {
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_ICON): cv.icon,
|
||||
vol.Optional(CONF_DURATION): cv.time_period,
|
||||
vol.Optional(CONF_RESTORE): cv.boolean,
|
||||
}
|
||||
|
||||
|
||||
@ -90,6 +99,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): vol.All(
|
||||
cv.time_period, _format_timedelta
|
||||
),
|
||||
vol.Optional(CONF_RESTORE, default=DEFAULT_RESTORE): cv.boolean,
|
||||
},
|
||||
)
|
||||
)
|
||||
@ -197,6 +207,7 @@ class Timer(RestoreEntity):
|
||||
self._remaining: timedelta | None = None
|
||||
self._end: datetime | None = None
|
||||
self._listener: Callable[[], None] | None = None
|
||||
self._restore: bool = self._config[CONF_RESTORE]
|
||||
|
||||
self._attr_should_poll = False
|
||||
self._attr_force_update = True
|
||||
@ -235,6 +246,8 @@ class Timer(RestoreEntity):
|
||||
attrs[ATTR_FINISHES_AT] = self._end.isoformat()
|
||||
if self._remaining is not None:
|
||||
attrs[ATTR_REMAINING] = _format_timedelta(self._remaining)
|
||||
if self._restore:
|
||||
attrs[ATTR_RESTORE] = self._restore
|
||||
|
||||
return attrs
|
||||
|
||||
@ -245,15 +258,41 @@ class Timer(RestoreEntity):
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Call when entity is about to be added to Home Assistant."""
|
||||
# If not None, we got an initial value.
|
||||
if self._state is not None:
|
||||
# If we don't need to restore a previous state or no previous state exists,
|
||||
# start at idle
|
||||
if not self._restore or (state := await self.async_get_last_state()) is None:
|
||||
self._state = STATUS_IDLE
|
||||
return
|
||||
|
||||
state = await self.async_get_last_state()
|
||||
self._state = state and state.state == state
|
||||
# Begin restoring state
|
||||
self._state = state.state
|
||||
self._duration = cv.time_period(state.attributes[ATTR_DURATION])
|
||||
|
||||
# Nothing more to do if the timer is idle
|
||||
if self._state == STATUS_IDLE:
|
||||
return
|
||||
|
||||
# If the timer was paused, we restore the remaining time
|
||||
if self._state == STATUS_PAUSED:
|
||||
self._remaining = cv.time_period(state.attributes[ATTR_REMAINING])
|
||||
return
|
||||
# If we get here, the timer must have been active so we need to decide what
|
||||
# to do based on end time and the current time
|
||||
end = cv.datetime(state.attributes[ATTR_FINISHES_AT])
|
||||
# If there is time remaining in the timer, restore the remaining time then
|
||||
# start the timer
|
||||
if (remaining := end - dt_util.utcnow().replace(microsecond=0)) > timedelta(0):
|
||||
self._remaining = remaining
|
||||
self._state = STATUS_PAUSED
|
||||
self.async_start()
|
||||
# If the timer ended before now, finish the timer. The event will indicate
|
||||
# when the timer was expected to fire.
|
||||
else:
|
||||
self._end = end
|
||||
self.async_finish()
|
||||
|
||||
@callback
|
||||
def async_start(self, duration: timedelta):
|
||||
def async_start(self, duration: timedelta | None = None):
|
||||
"""Start a timer."""
|
||||
if self._listener:
|
||||
self._listener()
|
||||
@ -274,7 +313,7 @@ class Timer(RestoreEntity):
|
||||
|
||||
self._end = start + self._remaining
|
||||
|
||||
self.hass.bus.async_fire(event, {"entity_id": self.entity_id})
|
||||
self.hass.bus.async_fire(event, {ATTR_ENTITY_ID: self.entity_id})
|
||||
|
||||
self._listener = async_track_point_in_utc_time(
|
||||
self.hass, self._async_finished, self._end
|
||||
@ -292,7 +331,7 @@ class Timer(RestoreEntity):
|
||||
self._remaining = self._end - dt_util.utcnow().replace(microsecond=0)
|
||||
self._state = STATUS_PAUSED
|
||||
self._end = None
|
||||
self.hass.bus.async_fire(EVENT_TIMER_PAUSED, {"entity_id": self.entity_id})
|
||||
self.hass.bus.async_fire(EVENT_TIMER_PAUSED, {ATTR_ENTITY_ID: self.entity_id})
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
@ -304,7 +343,9 @@ class Timer(RestoreEntity):
|
||||
self._state = STATUS_IDLE
|
||||
self._end = None
|
||||
self._remaining = None
|
||||
self.hass.bus.async_fire(EVENT_TIMER_CANCELLED, {"entity_id": self.entity_id})
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_TIMER_CANCELLED, {ATTR_ENTITY_ID: self.entity_id}
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
@ -316,10 +357,14 @@ class Timer(RestoreEntity):
|
||||
if self._listener:
|
||||
self._listener()
|
||||
self._listener = None
|
||||
end = self._end
|
||||
self._state = STATUS_IDLE
|
||||
self._end = None
|
||||
self._remaining = None
|
||||
self.hass.bus.async_fire(EVENT_TIMER_FINISHED, {"entity_id": self.entity_id})
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_TIMER_FINISHED,
|
||||
{ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()},
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
@ -330,13 +375,18 @@ class Timer(RestoreEntity):
|
||||
|
||||
self._listener = None
|
||||
self._state = STATUS_IDLE
|
||||
end = self._end
|
||||
self._end = None
|
||||
self._remaining = None
|
||||
self.hass.bus.async_fire(EVENT_TIMER_FINISHED, {"entity_id": self.entity_id})
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_TIMER_FINISHED,
|
||||
{ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()},
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update_config(self, config: dict) -> None:
|
||||
"""Handle when the config is updated."""
|
||||
self._config = config
|
||||
self._duration = cv.time_period_str(config[CONF_DURATION])
|
||||
self._restore = config[CONF_RESTORE]
|
||||
self.async_write_ha_state()
|
||||
|
@ -8,10 +8,13 @@ import pytest
|
||||
|
||||
from homeassistant.components.timer import (
|
||||
ATTR_DURATION,
|
||||
ATTR_FINISHES_AT,
|
||||
ATTR_REMAINING,
|
||||
ATTR_RESTORE,
|
||||
CONF_DURATION,
|
||||
CONF_ICON,
|
||||
CONF_NAME,
|
||||
CONF_RESTORE,
|
||||
DEFAULT_DURATION,
|
||||
DOMAIN,
|
||||
EVENT_TIMER_CANCELLED,
|
||||
@ -26,6 +29,7 @@ from homeassistant.components.timer import (
|
||||
STATUS_ACTIVE,
|
||||
STATUS_IDLE,
|
||||
STATUS_PAUSED,
|
||||
Timer,
|
||||
_format_timedelta,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
@ -35,16 +39,22 @@ from homeassistant.const import (
|
||||
ATTR_ID,
|
||||
ATTR_NAME,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_ID,
|
||||
EVENT_STATE_CHANGED,
|
||||
SERVICE_RELOAD,
|
||||
)
|
||||
from homeassistant.core import Context, CoreState
|
||||
from homeassistant.core import Context, CoreState, State
|
||||
from homeassistant.exceptions import Unauthorized
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.restore_state import (
|
||||
DATA_RESTORE_STATE_TASK,
|
||||
RestoreStateData,
|
||||
StoredState,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
from tests.common import async_capture_events, async_fire_time_changed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -64,6 +74,7 @@ def storage_setup(hass, hass_storage):
|
||||
ATTR_ID: "from_storage",
|
||||
ATTR_NAME: "timer from storage",
|
||||
ATTR_DURATION: "0:00:00",
|
||||
ATTR_RESTORE: False,
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -580,6 +591,7 @@ async def test_update(hass, hass_ws_client, storage_setup):
|
||||
"type": f"{DOMAIN}/update",
|
||||
f"{DOMAIN}_id": f"{timer_id}",
|
||||
CONF_DURATION: 33,
|
||||
CONF_RESTORE: True,
|
||||
}
|
||||
)
|
||||
resp = await client.receive_json()
|
||||
@ -587,6 +599,7 @@ async def test_update(hass, hass_ws_client, storage_setup):
|
||||
|
||||
state = hass.states.get(timer_entity_id)
|
||||
assert state.attributes[ATTR_DURATION] == _format_timedelta(cv.time_period(33))
|
||||
assert state.attributes[ATTR_RESTORE]
|
||||
|
||||
|
||||
async def test_ws_create(hass, hass_ws_client, storage_setup):
|
||||
@ -637,3 +650,183 @@ async def test_setup_no_config(hass, hass_admin_user):
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert count_start == len(hass.states.async_entity_ids())
|
||||
|
||||
|
||||
async def test_restore_idle(hass):
|
||||
"""Test entity restore logic when timer is idle."""
|
||||
utc_now = utcnow()
|
||||
stored_state = StoredState(
|
||||
State(
|
||||
"timer.test",
|
||||
STATUS_IDLE,
|
||||
{ATTR_DURATION: "0:00:30"},
|
||||
),
|
||||
None,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
data = await RestoreStateData.async_get_instance(hass)
|
||||
await hass.async_block_till_done()
|
||||
await data.store.async_save([stored_state.as_dict()])
|
||||
|
||||
# Emulate a fresh load
|
||||
hass.data.pop(DATA_RESTORE_STATE_TASK)
|
||||
|
||||
entity = Timer(
|
||||
{
|
||||
CONF_ID: "test",
|
||||
CONF_NAME: "test",
|
||||
CONF_DURATION: "0:01:00",
|
||||
CONF_RESTORE: True,
|
||||
}
|
||||
)
|
||||
entity.hass = hass
|
||||
entity.entity_id = "timer.test"
|
||||
|
||||
await entity.async_added_to_hass()
|
||||
await hass.async_block_till_done()
|
||||
assert entity.state == STATUS_IDLE
|
||||
assert entity.extra_state_attributes[ATTR_DURATION] == "0:00:30"
|
||||
assert ATTR_REMAINING not in entity.extra_state_attributes
|
||||
assert ATTR_FINISHES_AT not in entity.extra_state_attributes
|
||||
assert entity.extra_state_attributes[ATTR_RESTORE]
|
||||
|
||||
|
||||
async def test_restore_paused(hass):
|
||||
"""Test entity restore logic when timer is paused."""
|
||||
utc_now = utcnow()
|
||||
stored_state = StoredState(
|
||||
State(
|
||||
"timer.test",
|
||||
STATUS_PAUSED,
|
||||
{ATTR_DURATION: "0:00:30", ATTR_REMAINING: "0:00:15"},
|
||||
),
|
||||
None,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
data = await RestoreStateData.async_get_instance(hass)
|
||||
await hass.async_block_till_done()
|
||||
await data.store.async_save([stored_state.as_dict()])
|
||||
|
||||
# Emulate a fresh load
|
||||
hass.data.pop(DATA_RESTORE_STATE_TASK)
|
||||
|
||||
entity = Timer(
|
||||
{
|
||||
CONF_ID: "test",
|
||||
CONF_NAME: "test",
|
||||
CONF_DURATION: "0:01:00",
|
||||
CONF_RESTORE: True,
|
||||
}
|
||||
)
|
||||
entity.hass = hass
|
||||
entity.entity_id = "timer.test"
|
||||
|
||||
await entity.async_added_to_hass()
|
||||
await hass.async_block_till_done()
|
||||
assert entity.state == STATUS_PAUSED
|
||||
assert entity.extra_state_attributes[ATTR_DURATION] == "0:00:30"
|
||||
assert entity.extra_state_attributes[ATTR_REMAINING] == "0:00:15"
|
||||
assert ATTR_FINISHES_AT not in entity.extra_state_attributes
|
||||
assert entity.extra_state_attributes[ATTR_RESTORE]
|
||||
|
||||
|
||||
async def test_restore_active_resume(hass):
|
||||
"""Test entity restore logic when timer is active and end time is after startup."""
|
||||
events = async_capture_events(hass, EVENT_TIMER_RESTARTED)
|
||||
assert not events
|
||||
utc_now = utcnow()
|
||||
finish = utc_now + timedelta(seconds=30)
|
||||
simulated_utc_now = utc_now + timedelta(seconds=15)
|
||||
stored_state = StoredState(
|
||||
State(
|
||||
"timer.test",
|
||||
STATUS_ACTIVE,
|
||||
{ATTR_DURATION: "0:00:30", ATTR_FINISHES_AT: finish.isoformat()},
|
||||
),
|
||||
None,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
data = await RestoreStateData.async_get_instance(hass)
|
||||
await hass.async_block_till_done()
|
||||
await data.store.async_save([stored_state.as_dict()])
|
||||
|
||||
# Emulate a fresh load
|
||||
hass.data.pop(DATA_RESTORE_STATE_TASK)
|
||||
|
||||
entity = Timer(
|
||||
{
|
||||
CONF_ID: "test",
|
||||
CONF_NAME: "test",
|
||||
CONF_DURATION: "0:01:00",
|
||||
CONF_RESTORE: True,
|
||||
}
|
||||
)
|
||||
entity.hass = hass
|
||||
entity.entity_id = "timer.test"
|
||||
|
||||
# In patch make sure we ignore microseconds
|
||||
with patch(
|
||||
"homeassistant.components.timer.dt_util.utcnow",
|
||||
return_value=simulated_utc_now.replace(microsecond=999),
|
||||
):
|
||||
await entity.async_added_to_hass()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entity.state == STATUS_ACTIVE
|
||||
assert entity.extra_state_attributes[ATTR_DURATION] == "0:00:30"
|
||||
assert entity.extra_state_attributes[ATTR_REMAINING] == "0:00:15"
|
||||
assert entity.extra_state_attributes[ATTR_FINISHES_AT] == finish.isoformat()
|
||||
assert entity.extra_state_attributes[ATTR_RESTORE]
|
||||
assert len(events) == 1
|
||||
|
||||
|
||||
async def test_restore_active_finished_outside_grace(hass):
|
||||
"""Test entity restore logic: timer is active, ended while Home Assistant was stopped."""
|
||||
events = async_capture_events(hass, EVENT_TIMER_FINISHED)
|
||||
assert not events
|
||||
utc_now = utcnow()
|
||||
finish = utc_now + timedelta(seconds=30)
|
||||
simulated_utc_now = utc_now + timedelta(seconds=46)
|
||||
stored_state = StoredState(
|
||||
State(
|
||||
"timer.test",
|
||||
STATUS_ACTIVE,
|
||||
{ATTR_DURATION: "0:00:30", ATTR_FINISHES_AT: finish.isoformat()},
|
||||
),
|
||||
None,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
data = await RestoreStateData.async_get_instance(hass)
|
||||
await hass.async_block_till_done()
|
||||
await data.store.async_save([stored_state.as_dict()])
|
||||
|
||||
# Emulate a fresh load
|
||||
hass.data.pop(DATA_RESTORE_STATE_TASK)
|
||||
|
||||
entity = Timer(
|
||||
{
|
||||
CONF_ID: "test",
|
||||
CONF_NAME: "test",
|
||||
CONF_DURATION: "0:01:00",
|
||||
CONF_RESTORE: True,
|
||||
}
|
||||
)
|
||||
entity.hass = hass
|
||||
entity.entity_id = "timer.test"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.timer.dt_util.utcnow", return_value=simulated_utc_now
|
||||
):
|
||||
await entity.async_added_to_hass()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entity.state == STATUS_IDLE
|
||||
assert entity.extra_state_attributes[ATTR_DURATION] == "0:00:30"
|
||||
assert ATTR_REMAINING not in entity.extra_state_attributes
|
||||
assert ATTR_FINISHES_AT not in entity.extra_state_attributes
|
||||
assert entity.extra_state_attributes[ATTR_RESTORE]
|
||||
assert len(events) == 1
|
||||
|
Loading…
x
Reference in New Issue
Block a user