Allow timer state to be restored on restart (#67658)

This commit is contained in:
Raman Gupta 2022-03-30 02:04:46 -04:00 committed by GitHub
parent 4d59cb290c
commit df4ddc6491
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 255 additions and 12 deletions

View File

@ -9,6 +9,7 @@ import voluptuous as vol
from homeassistant.const import ( from homeassistant.const import (
ATTR_EDITABLE, ATTR_EDITABLE,
ATTR_ENTITY_ID,
CONF_ICON, CONF_ICON,
CONF_ID, CONF_ID,
CONF_NAME, CONF_NAME,
@ -31,10 +32,16 @@ DOMAIN = "timer"
ENTITY_ID_FORMAT = DOMAIN + ".{}" ENTITY_ID_FORMAT = DOMAIN + ".{}"
DEFAULT_DURATION = 0 DEFAULT_DURATION = 0
DEFAULT_RESTORE = False
ATTR_DURATION = "duration" ATTR_DURATION = "duration"
ATTR_REMAINING = "remaining" ATTR_REMAINING = "remaining"
ATTR_FINISHES_AT = "finishes_at" ATTR_FINISHES_AT = "finishes_at"
ATTR_RESTORE = "restore"
ATTR_FINISHED_AT = "finished_at"
CONF_DURATION = "duration" CONF_DURATION = "duration"
CONF_RESTORE = "restore"
STATUS_IDLE = "idle" STATUS_IDLE = "idle"
STATUS_ACTIVE = "active" STATUS_ACTIVE = "active"
@ -58,11 +65,13 @@ CREATE_FIELDS = {
vol.Required(CONF_NAME): cv.string, vol.Required(CONF_NAME): cv.string,
vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_ICON): cv.icon,
vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): cv.time_period, vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): cv.time_period,
vol.Optional(CONF_RESTORE, default=DEFAULT_RESTORE): cv.boolean,
} }
UPDATE_FIELDS = { UPDATE_FIELDS = {
vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_ICON): cv.icon,
vol.Optional(CONF_DURATION): cv.time_period, 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( vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): vol.All(
cv.time_period, _format_timedelta 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._remaining: timedelta | None = None
self._end: datetime | None = None self._end: datetime | None = None
self._listener: Callable[[], None] | None = None self._listener: Callable[[], None] | None = None
self._restore: bool = self._config[CONF_RESTORE]
self._attr_should_poll = False self._attr_should_poll = False
self._attr_force_update = True self._attr_force_update = True
@ -235,6 +246,8 @@ class Timer(RestoreEntity):
attrs[ATTR_FINISHES_AT] = self._end.isoformat() attrs[ATTR_FINISHES_AT] = self._end.isoformat()
if self._remaining is not None: if self._remaining is not None:
attrs[ATTR_REMAINING] = _format_timedelta(self._remaining) attrs[ATTR_REMAINING] = _format_timedelta(self._remaining)
if self._restore:
attrs[ATTR_RESTORE] = self._restore
return attrs return attrs
@ -245,15 +258,41 @@ class Timer(RestoreEntity):
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Call when entity is about to be added to Home Assistant.""" """Call when entity is about to be added to Home Assistant."""
# If not None, we got an initial value. # If we don't need to restore a previous state or no previous state exists,
if self._state is not None: # start at idle
if not self._restore or (state := await self.async_get_last_state()) is None:
self._state = STATUS_IDLE
return return
state = await self.async_get_last_state() # Begin restoring state
self._state = state and state.state == 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 @callback
def async_start(self, duration: timedelta): def async_start(self, duration: timedelta | None = None):
"""Start a timer.""" """Start a timer."""
if self._listener: if self._listener:
self._listener() self._listener()
@ -274,7 +313,7 @@ class Timer(RestoreEntity):
self._end = start + self._remaining 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._listener = async_track_point_in_utc_time(
self.hass, self._async_finished, self._end 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._remaining = self._end - dt_util.utcnow().replace(microsecond=0)
self._state = STATUS_PAUSED self._state = STATUS_PAUSED
self._end = None 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() self.async_write_ha_state()
@callback @callback
@ -304,7 +343,9 @@ class Timer(RestoreEntity):
self._state = STATUS_IDLE self._state = STATUS_IDLE
self._end = None self._end = None
self._remaining = 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() self.async_write_ha_state()
@callback @callback
@ -316,10 +357,14 @@ class Timer(RestoreEntity):
if self._listener: if self._listener:
self._listener() self._listener()
self._listener = None self._listener = None
end = self._end
self._state = STATUS_IDLE self._state = STATUS_IDLE
self._end = None self._end = None
self._remaining = 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() self.async_write_ha_state()
@callback @callback
@ -330,13 +375,18 @@ class Timer(RestoreEntity):
self._listener = None self._listener = None
self._state = STATUS_IDLE self._state = STATUS_IDLE
end = self._end
self._end = None self._end = None
self._remaining = 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() self.async_write_ha_state()
async def async_update_config(self, config: dict) -> None: async def async_update_config(self, config: dict) -> None:
"""Handle when the config is updated.""" """Handle when the config is updated."""
self._config = config self._config = config
self._duration = cv.time_period_str(config[CONF_DURATION]) self._duration = cv.time_period_str(config[CONF_DURATION])
self._restore = config[CONF_RESTORE]
self.async_write_ha_state() self.async_write_ha_state()

View File

@ -8,10 +8,13 @@ import pytest
from homeassistant.components.timer import ( from homeassistant.components.timer import (
ATTR_DURATION, ATTR_DURATION,
ATTR_FINISHES_AT,
ATTR_REMAINING, ATTR_REMAINING,
ATTR_RESTORE,
CONF_DURATION, CONF_DURATION,
CONF_ICON, CONF_ICON,
CONF_NAME, CONF_NAME,
CONF_RESTORE,
DEFAULT_DURATION, DEFAULT_DURATION,
DOMAIN, DOMAIN,
EVENT_TIMER_CANCELLED, EVENT_TIMER_CANCELLED,
@ -26,6 +29,7 @@ from homeassistant.components.timer import (
STATUS_ACTIVE, STATUS_ACTIVE,
STATUS_IDLE, STATUS_IDLE,
STATUS_PAUSED, STATUS_PAUSED,
Timer,
_format_timedelta, _format_timedelta,
) )
from homeassistant.const import ( from homeassistant.const import (
@ -35,16 +39,22 @@ from homeassistant.const import (
ATTR_ID, ATTR_ID,
ATTR_NAME, ATTR_NAME,
CONF_ENTITY_ID, CONF_ENTITY_ID,
CONF_ID,
EVENT_STATE_CHANGED, EVENT_STATE_CHANGED,
SERVICE_RELOAD, SERVICE_RELOAD,
) )
from homeassistant.core import Context, CoreState from homeassistant.core import Context, CoreState, State
from homeassistant.exceptions import Unauthorized from homeassistant.exceptions import Unauthorized
from homeassistant.helpers import config_validation as cv, entity_registry as er 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.setup import async_setup_component
from homeassistant.util.dt import utcnow 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__) _LOGGER = logging.getLogger(__name__)
@ -64,6 +74,7 @@ def storage_setup(hass, hass_storage):
ATTR_ID: "from_storage", ATTR_ID: "from_storage",
ATTR_NAME: "timer from storage", ATTR_NAME: "timer from storage",
ATTR_DURATION: "0:00:00", 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", "type": f"{DOMAIN}/update",
f"{DOMAIN}_id": f"{timer_id}", f"{DOMAIN}_id": f"{timer_id}",
CONF_DURATION: 33, CONF_DURATION: 33,
CONF_RESTORE: True,
} }
) )
resp = await client.receive_json() 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) state = hass.states.get(timer_entity_id)
assert state.attributes[ATTR_DURATION] == _format_timedelta(cv.time_period(33)) 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): 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() await hass.async_block_till_done()
assert count_start == len(hass.states.async_entity_ids()) 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