Compare commits

...

1 Commits

Author SHA1 Message Date
Erik
faf663cef1 Add last_action state attribute to timers 2026-04-15 11:24:21 +02:00
2 changed files with 225 additions and 31 deletions

View File

@@ -41,6 +41,7 @@ ATTR_REMAINING = "remaining"
ATTR_FINISHES_AT = "finishes_at"
ATTR_RESTORE = "restore"
ATTR_FINISHED_AT = "finished_at"
ATTR_LAST_ACTION = "last_action"
CONF_DURATION = "duration"
CONF_RESTORE = "restore"
@@ -202,6 +203,7 @@ class Timer(collection.CollectionEntity, RestoreEntity):
def __init__(self, config: ConfigType) -> None:
"""Initialize a timer."""
self._config: dict = config
self._last_action: str | None = None
self._state: str = STATUS_IDLE
self._configured_duration = cv.time_period_str(config[CONF_DURATION])
self._running_duration: timedelta = self._configured_duration
@@ -249,6 +251,7 @@ class Timer(collection.CollectionEntity, RestoreEntity):
attrs: dict[str, Any] = {
ATTR_DURATION: _format_timedelta(self._running_duration),
ATTR_EDITABLE: self.editable,
ATTR_LAST_ACTION: self._last_action,
}
if self._end is not None:
attrs[ATTR_FINISHES_AT] = self._end.isoformat()
@@ -274,6 +277,7 @@ class Timer(collection.CollectionEntity, RestoreEntity):
# Begin restoring state
self._state = state.state
self._last_action = state.attributes.get(ATTR_LAST_ACTION)
# Nothing more to do if the timer is idle
if self._state == STATUS_IDLE:
@@ -321,8 +325,7 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self._end = start + self._remaining
self.async_write_ha_state()
self.hass.bus.async_fire(event, {ATTR_ENTITY_ID: self.entity_id})
self._fire_event_and_write_state(event)
self._listener = async_track_point_in_utc_time(
self.hass, self._async_finished, self._end
@@ -349,6 +352,8 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self._listener()
self._end += duration
self._remaining = new_remaining
# We don't use _fire_event_and_write_state here because we don't want to
# update last_action
self.async_write_ha_state()
self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id})
self._listener = async_track_point_in_utc_time(
@@ -366,8 +371,7 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self._remaining = self._end - dt_util.utcnow().replace(microsecond=0)
self._state = STATUS_PAUSED
self._end = None
self.async_write_ha_state()
self.hass.bus.async_fire(EVENT_TIMER_PAUSED, {ATTR_ENTITY_ID: self.entity_id})
self._fire_event_and_write_state(EVENT_TIMER_PAUSED)
@callback
def async_cancel(self) -> None:
@@ -382,10 +386,7 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self._end = None
self._remaining = None
self._running_duration = self._configured_duration
self.async_write_ha_state()
self.hass.bus.async_fire(
EVENT_TIMER_CANCELLED, {ATTR_ENTITY_ID: self.entity_id}
)
self._fire_event_and_write_state(EVENT_TIMER_CANCELLED)
@callback
def async_finish(self) -> None:
@@ -403,10 +404,8 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self._end = None
self._remaining = None
self._running_duration = self._configured_duration
self.async_write_ha_state()
self.hass.bus.async_fire(
EVENT_TIMER_FINISHED,
{ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()},
self._fire_event_and_write_state(
EVENT_TIMER_FINISHED, extra_attrs={ATTR_FINISHED_AT: end.isoformat()}
)
@callback
@@ -421,10 +420,8 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self._end = None
self._remaining = None
self._running_duration = self._configured_duration
self.async_write_ha_state()
self.hass.bus.async_fire(
EVENT_TIMER_FINISHED,
{ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()},
self._fire_event_and_write_state(
EVENT_TIMER_FINISHED, extra_attrs={ATTR_FINISHED_AT: end.isoformat()}
)
async def async_update_config(self, config: ConfigType) -> None:
@@ -435,3 +432,14 @@ class Timer(collection.CollectionEntity, RestoreEntity):
self._running_duration = self._configured_duration
self._restore = config.get(CONF_RESTORE, DEFAULT_RESTORE)
self.async_write_ha_state()
def _fire_event_and_write_state(
self, event: str, *, extra_attrs: dict[str, Any] | None = None
) -> None:
"""Fire the event and write state."""
self._last_action = event.partition(".")[2]
self.async_write_ha_state()
event_data = {ATTR_ENTITY_ID: self.entity_id}
if extra_attrs:
event_data.update(extra_attrs)
self.hass.bus.async_fire(event, event_data)

View File

@@ -11,6 +11,7 @@ import pytest
from homeassistant.components.timer import (
ATTR_DURATION,
ATTR_FINISHES_AT,
ATTR_LAST_ACTION,
ATTR_REMAINING,
ATTR_RESTORE,
CONF_DURATION,
@@ -133,8 +134,9 @@ async def test_config_options(hass: HomeAssistant) -> None:
assert state_1.state == STATUS_IDLE
assert state_1.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
assert state_2.state == STATUS_IDLE
@@ -143,12 +145,14 @@ async def test_config_options(hass: HomeAssistant) -> None:
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "Hello World",
ATTR_ICON: "mdi:work",
ATTR_LAST_ACTION: None,
}
assert state_3.state == STATUS_IDLE
assert state_3.attributes == {
ATTR_DURATION: str(cv.time_period(DEFAULT_DURATION)),
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
@@ -165,6 +169,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
results: list[tuple[Event, State | None]] = []
@@ -191,6 +196,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_LAST_ACTION: "started",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_STARTED,
@@ -199,7 +205,10 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"call": SERVICE_PAUSE,
"call_data": {},
"expected_state": STATUS_PAUSED,
"expected_extra_attributes": {ATTR_REMAINING: "0:00:10"},
"expected_extra_attributes": {
ATTR_LAST_ACTION: "paused",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_PAUSED,
},
{
@@ -208,6 +217,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_LAST_ACTION: "restarted",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_RESTARTED,
@@ -216,14 +226,14 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"call": SERVICE_CANCEL,
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {},
"expected_extra_attributes": {ATTR_LAST_ACTION: "cancelled"},
"expected_event": EVENT_TIMER_CANCELLED,
},
{
"call": SERVICE_CANCEL,
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {},
"expected_extra_attributes": {ATTR_LAST_ACTION: "cancelled"},
"expected_event": None,
},
{
@@ -232,6 +242,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_LAST_ACTION: "started",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_STARTED,
@@ -240,14 +251,14 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"call": SERVICE_FINISH,
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {},
"expected_extra_attributes": {ATTR_LAST_ACTION: "finished"},
"expected_event": EVENT_TIMER_FINISHED,
},
{
"call": SERVICE_FINISH,
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {},
"expected_extra_attributes": {ATTR_LAST_ACTION: "finished"},
"expected_event": None,
},
{
@@ -256,6 +267,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_LAST_ACTION: "started",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_STARTED,
@@ -264,14 +276,17 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"call": SERVICE_PAUSE,
"call_data": {},
"expected_state": STATUS_PAUSED,
"expected_extra_attributes": {ATTR_REMAINING: "0:00:10"},
"expected_extra_attributes": {
ATTR_LAST_ACTION: "paused",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_PAUSED,
},
{
"call": SERVICE_CANCEL,
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {},
"expected_extra_attributes": {ATTR_LAST_ACTION: "cancelled"},
"expected_event": EVENT_TIMER_CANCELLED,
},
{
@@ -280,6 +295,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_LAST_ACTION: "started",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_STARTED,
@@ -290,6 +306,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_5,
ATTR_LAST_ACTION: "started", # Change does not set last_action
ATTR_REMAINING: "0:00:05",
},
"expected_event": EVENT_TIMER_CHANGED,
@@ -300,6 +317,7 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_5,
ATTR_LAST_ACTION: "restarted",
ATTR_REMAINING: "0:00:05",
},
"expected_event": EVENT_TIMER_RESTARTED,
@@ -308,14 +326,17 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
"call": SERVICE_PAUSE,
"call_data": {},
"expected_state": STATUS_PAUSED,
"expected_extra_attributes": {ATTR_REMAINING: "0:00:05"},
"expected_extra_attributes": {
ATTR_LAST_ACTION: "paused",
ATTR_REMAINING: "0:00:05",
},
"expected_event": EVENT_TIMER_PAUSED,
},
{
"call": SERVICE_FINISH,
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {},
"expected_extra_attributes": {ATTR_LAST_ACTION: "finished"},
"expected_event": EVENT_TIMER_FINISHED,
},
]
@@ -372,6 +393,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_LAST_ACTION: None,
}
await hass.services.async_call(
@@ -385,6 +407,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_LAST_ACTION: "started",
ATTR_REMAINING: "0:00:10",
}
@@ -398,6 +421,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_LAST_ACTION: "cancelled",
}
with pytest.raises(HomeAssistantError):
@@ -422,6 +446,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:15",
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=15)).isoformat(),
ATTR_LAST_ACTION: "started",
ATTR_REMAINING: "0:00:15",
}
@@ -460,6 +485,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:15",
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=12)).isoformat(),
ATTR_LAST_ACTION: "started", # Change does not set last_action
ATTR_REMAINING: "0:00:12",
}
@@ -476,6 +502,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:15",
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=14)).isoformat(),
ATTR_LAST_ACTION: "started", # Change does not set last_action
ATTR_REMAINING: "0:00:14",
}
@@ -489,6 +516,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_LAST_ACTION: "cancelled",
}
with pytest.raises(
@@ -508,6 +536,7 @@ async def test_start_service(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_LAST_ACTION: "cancelled", # Change does not set last_action
}
@@ -526,6 +555,7 @@ async def test_wait_till_timer_expires(
assert state.attributes == {
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
results = []
@@ -553,6 +583,7 @@ async def test_wait_till_timer_expires(
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=20)).isoformat(),
ATTR_LAST_ACTION: "started",
ATTR_REMAINING: "0:00:20",
}
@@ -574,6 +605,7 @@ async def test_wait_till_timer_expires(
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=15)).isoformat(),
ATTR_LAST_ACTION: "started",
ATTR_REMAINING: "0:00:15",
}
@@ -591,6 +623,7 @@ async def test_wait_till_timer_expires(
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=5)).isoformat(),
ATTR_LAST_ACTION: "started",
ATTR_REMAINING: "0:00:15",
}
@@ -604,6 +637,7 @@ async def test_wait_till_timer_expires(
assert state.attributes == {
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: "finished",
}
assert results[-1].event_type == EVENT_TIMER_FINISHED
@@ -622,6 +656,7 @@ async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> Non
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
@@ -668,6 +703,7 @@ async def test_config_reload(
assert state_1.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
assert state_2.state == STATUS_IDLE
@@ -676,6 +712,7 @@ async def test_config_reload(
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "Hello World",
ATTR_ICON: "mdi:work",
ATTR_LAST_ACTION: None,
}
with patch(
@@ -726,12 +763,14 @@ async def test_config_reload(
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "Hello World reloaded",
ATTR_ICON: "mdi:work-reloaded",
ATTR_LAST_ACTION: None,
}
assert state_3.state == STATUS_IDLE
assert state_3.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
@@ -748,6 +787,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
results = []
@@ -774,6 +814,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_LAST_ACTION: "started",
ATTR_REMAINING: "0:00:10",
}
@@ -791,6 +832,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_LAST_ACTION: "restarted",
ATTR_REMAINING: "0:00:10",
}
@@ -807,6 +849,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: "paused",
ATTR_REMAINING: "0:00:10",
}
@@ -824,6 +867,7 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_LAST_ACTION: "restarted",
ATTR_REMAINING: "0:00:10",
}
@@ -844,6 +888,7 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
results = []
@@ -866,6 +911,7 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_LAST_ACTION: "started",
ATTR_REMAINING: "0:00:10",
}
@@ -883,6 +929,7 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_LAST_ACTION: "restarted",
ATTR_REMAINING: "0:00:10",
}
@@ -890,6 +937,78 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
assert len(results) == 2
@pytest.mark.freeze_time("2023-06-05 17:47:50")
async def test_last_action_after_restarted_timer_expires(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test that last_action changes from restarted to finished when timer expires."""
hass.set_state(CoreState.starting)
await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}})
# Start the timer
await hass.services.async_call(
DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
)
await hass.async_block_till_done()
# Restart the timer
await hass.services.async_call(
DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
)
await hass.async_block_till_done()
state = hass.states.get("timer.test1")
assert state.state == STATUS_ACTIVE
assert state.attributes[ATTR_LAST_ACTION] == "restarted"
# Let the timer expire
freezer.tick(15)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("timer.test1")
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_LAST_ACTION] == "finished"
@pytest.mark.freeze_time("2023-06-05 17:47:50")
async def test_last_action_persists_across_config_update(
hass: HomeAssistant,
) -> None:
"""Test that last_action is preserved when the timer config is updated."""
hass.set_state(CoreState.starting)
await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}})
# Start and cancel to set last_action to "cancelled"
await hass.services.async_call(
DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
)
await hass.services.async_call(
DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
)
await hass.async_block_till_done()
state = hass.states.get("timer.test1")
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_LAST_ACTION] == "cancelled"
# Reload with a new duration — last_action should persist
with patch(
"homeassistant.config.load_yaml_config_file",
autospec=True,
return_value={DOMAIN: {"test1": {CONF_DURATION: 20}}},
):
await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True)
await hass.async_block_till_done()
state = hass.states.get("timer.test1")
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_DURATION] == "0:00:20"
assert state.attributes[ATTR_LAST_ACTION] == "cancelled"
async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None:
"""Test set up from storage."""
assert await storage_setup()
@@ -899,6 +1018,7 @@ async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None:
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "timer from storage",
ATTR_LAST_ACTION: None,
}
@@ -912,6 +1032,7 @@ async def test_editable_state_attribute(hass: HomeAssistant, storage_setup) -> N
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "timer from storage",
ATTR_LAST_ACTION: None,
}
state = hass.states.get(f"{DOMAIN}.from_yaml")
@@ -919,6 +1040,7 @@ async def test_editable_state_attribute(hass: HomeAssistant, storage_setup) -> N
assert state.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
@@ -993,6 +1115,7 @@ async def test_update(
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "timer from storage",
ATTR_LAST_ACTION: None,
}
assert (
entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id
@@ -1028,6 +1151,7 @@ async def test_update(
ATTR_DURATION: "0:00:33",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "timer from storage",
ATTR_LAST_ACTION: None,
ATTR_RESTORE: True,
}
@@ -1067,6 +1191,7 @@ async def test_ws_create(
ATTR_DURATION: "0:00:42",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "New Timer",
ATTR_LAST_ACTION: None,
}
assert (
entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id
@@ -1092,6 +1217,46 @@ async def test_setup_no_config(hass: HomeAssistant, hass_admin_user: MockUser) -
assert count_start == len(hass.states.async_entity_ids())
@pytest.mark.parametrize("last_action", [None, "cancelled", "finished"])
async def test_restore_idle(hass: HomeAssistant, last_action: str | None) -> None:
"""Test entity restore logic when timer is idle."""
utc_now = utcnow()
attrs: dict[str, Any] = {ATTR_DURATION: "0:00:30"}
if last_action is not None:
attrs[ATTR_LAST_ACTION] = last_action
stored_state = StoredState(
State("timer.test", STATUS_IDLE, attrs),
None,
utc_now,
)
data = async_get(hass)
await data.store.async_save([stored_state.as_dict()])
await data.async_load()
entity = Timer.from_storage(
{
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 == {
# Idle timers reset to the configured duration, not the stored one
ATTR_DURATION: "0:01:00",
ATTR_EDITABLE: True,
ATTR_LAST_ACTION: last_action,
ATTR_RESTORE: True,
}
@pytest.mark.freeze_time("2023-06-05 17:47:50")
async def test_restore_paused(hass: HomeAssistant) -> None:
"""Test entity restore logic when timer is paused."""
@@ -1100,7 +1265,11 @@ async def test_restore_paused(hass: HomeAssistant) -> None:
State(
"timer.test",
STATUS_PAUSED,
{ATTR_DURATION: "0:00:30", ATTR_REMAINING: "0:00:15"},
{
ATTR_DURATION: "0:00:30",
ATTR_LAST_ACTION: "paused",
ATTR_REMAINING: "0:00:15",
},
),
None,
utc_now,
@@ -1127,13 +1296,17 @@ async def test_restore_paused(hass: HomeAssistant) -> None:
assert entity.extra_state_attributes == {
ATTR_DURATION: "0:00:30",
ATTR_EDITABLE: True,
ATTR_LAST_ACTION: "paused",
ATTR_REMAINING: "0:00:15",
ATTR_RESTORE: True,
}
@pytest.mark.freeze_time("2023-06-05 17:47:50")
async def test_restore_active_resume(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("last_action", [None, "started", "restarted"])
async def test_restore_active_resume(
hass: HomeAssistant, last_action: str | None
) -> None:
"""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
@@ -1144,7 +1317,11 @@ async def test_restore_active_resume(hass: HomeAssistant) -> None:
State(
"timer.test",
STATUS_ACTIVE,
{ATTR_DURATION: "0:00:30", ATTR_FINISHES_AT: finish.isoformat()},
{
ATTR_DURATION: "0:00:30",
ATTR_FINISHES_AT: finish.isoformat(),
ATTR_LAST_ACTION: last_action,
},
),
None,
utc_now,
@@ -1178,13 +1355,17 @@ async def test_restore_active_resume(hass: HomeAssistant) -> None:
ATTR_DURATION: "0:00:30",
ATTR_EDITABLE: True,
ATTR_FINISHES_AT: finish.isoformat(),
ATTR_LAST_ACTION: "restarted",
ATTR_REMAINING: "0:00:15",
ATTR_RESTORE: True,
}
assert len(events) == 1
async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("last_action", [None, "started", "restarted"])
async def test_restore_active_finished_outside_grace(
hass: HomeAssistant, last_action: str | None
) -> None:
"""Test entity restore logic: timer is active, ended while Home Assistant was stopped."""
events = async_capture_events(hass, EVENT_TIMER_FINISHED)
assert not events
@@ -1195,7 +1376,11 @@ async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> Non
State(
"timer.test",
STATUS_ACTIVE,
{ATTR_DURATION: "0:00:30", ATTR_FINISHES_AT: finish.isoformat()},
{
ATTR_DURATION: "0:00:30",
ATTR_FINISHES_AT: finish.isoformat(),
ATTR_LAST_ACTION: last_action,
},
),
None,
utc_now,
@@ -1226,6 +1411,7 @@ async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> Non
assert entity.extra_state_attributes == {
ATTR_DURATION: "0:01:00",
ATTR_EDITABLE: True,
ATTR_LAST_ACTION: "finished",
ATTR_RESTORE: True,
}
assert len(events) == 1