diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 35258555537..63ab766567d 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -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() diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 5c276bc0cfc..8f605d2de9f 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -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