Compare commits

..

2 Commits

Author SHA1 Message Date
Erik
faf663cef1 Add last_action state attribute to timers 2026-04-15 11:24:21 +02:00
Erik Montnemery
d6be6e8810 Improve timer tests (#168277) 2026-04-15 11:21:59 +02:00
3 changed files with 551 additions and 208 deletions

View File

@@ -24,9 +24,7 @@ from homeassistant.components.recorder.statistics import (
statistics_during_period,
)
from homeassistant.const import UnitOfEnergy
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
@@ -294,41 +292,7 @@ class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]):
name=f"{DOMAIN} price",
update_interval=timedelta(minutes=1),
)
self._tomorrow_price_poll_threshold_seconds = random.uniform(
3600 * 14, 3600 * 23
)
self._unsub_tomorrow_price_poll: CALLBACK_TYPE | None = None
initial_tomorrow_price_poll = dt_util.start_of_local_day() + timedelta(
seconds=self._tomorrow_price_poll_threshold_seconds
)
if initial_tomorrow_price_poll <= dt_util.utcnow():
initial_tomorrow_price_poll += timedelta(days=1)
self._schedule_tomorrow_price_poll(initial_tomorrow_price_poll)
self._tibber_homes: list[tibber.TibberHome] | None = None
async def async_shutdown(self) -> None:
"""Cancel any scheduled call, and ignore new runs."""
await super().async_shutdown()
if self._unsub_tomorrow_price_poll:
self._unsub_tomorrow_price_poll()
self._unsub_tomorrow_price_poll = None
def _schedule_tomorrow_price_poll(self, point_in_time: datetime) -> None:
"""Schedule the next one-shot tomorrow price poll."""
if point_in_time <= (now := dt_util.utcnow()):
point_in_time = now + timedelta(seconds=1)
if self._unsub_tomorrow_price_poll:
self._unsub_tomorrow_price_poll()
self._unsub_tomorrow_price_poll = async_track_point_in_utc_time(
self.hass,
self._async_handle_tomorrow_price_poll,
point_in_time,
)
async def _async_handle_tomorrow_price_poll(self, _: datetime) -> None:
"""Handle the scheduled tomorrow price poll."""
self._unsub_tomorrow_price_poll = None
await self._fetch_data()
self._tomorrow_price_poll_threshold_seconds = random.uniform(0, 3600 * 10)
def _time_until_next_15_minute(self) -> timedelta:
"""Return time until the next 15-minute boundary (0, 15, 30, 45) in UTC."""
@@ -345,23 +309,7 @@ class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]):
return next_run - now
async def _async_update_data(self) -> dict[str, TibberHomeData]:
if self._tibber_homes is None:
await self._fetch_data()
homes = self._tibber_homes
if homes is None:
raise UpdateFailed("No Tibber homes available")
result = {home.home_id: _build_home_data(home) for home in homes}
self.update_interval = self._time_until_next_15_minute()
return result
async def _fetch_data(self) -> None:
"""Fetch latest price data via API and update cached home data."""
self._schedule_tomorrow_price_poll(
dt_util.utcnow() + timedelta(seconds=random.uniform(60, 60 * 10))
)
"""Update data via API and return per-home data for sensors."""
tibber_connection = await self._async_get_client()
active_homes = tibber_connection.get_homes(only_active=True)
@@ -400,31 +348,21 @@ class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]):
return False
homes_to_update = [home for home in active_homes if _needs_update(home)]
try:
if homes_to_update:
await asyncio.gather(
*(home.update_info_and_price_info() for home in homes_to_update)
)
except tibber.exceptions.RateLimitExceededError as err:
self._schedule_tomorrow_price_poll(
dt_util.utcnow() + timedelta(seconds=err.retry_after)
)
raise UpdateFailed(
f"Rate limit exceeded, retry after {err.retry_after} seconds",
retry_after=err.retry_after,
) from err
except tibber.exceptions.HttpExceptionError as err:
raise UpdateFailed(f"Error communicating with API ({err})") from err
except tibber.RetryableHttpExceptionError as err:
raise UpdateFailed(f"Error communicating with API ({err.status})") from err
except tibber.FatalHttpExceptionError as err:
raise UpdateFailed(f"Error communicating with API ({err.status})") from err
self._schedule_tomorrow_price_poll(
dt_util.start_of_local_day(now)
+ timedelta(days=1, seconds=self._tomorrow_price_poll_threshold_seconds)
)
self._tibber_homes = active_homes
result = {home.home_id: _build_home_data(home) for home in active_homes}
class TibberFetchPriceCoordinator(TibberPriceCoordinator):
"""Backward-compatible alias for the merged price fetch coordinator."""
self.update_interval = self._time_until_next_15_minute()
return result
class TibberDataAPICoordinator(TibberCoordinator[dict[str, TibberDevice]]):

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

@@ -5,11 +5,13 @@ import logging
from typing import Any
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.timer import (
ATTR_DURATION,
ATTR_FINISHES_AT,
ATTR_LAST_ACTION,
ATTR_REMAINING,
ATTR_RESTORE,
CONF_DURATION,
@@ -33,7 +35,6 @@ from homeassistant.components.timer import (
STATUS_IDLE,
STATUS_PAUSED,
Timer,
_format_timedelta,
)
from homeassistant.const import (
ATTR_EDITABLE,
@@ -132,20 +133,30 @@ async def test_config_options(hass: HomeAssistant) -> None:
assert state_3 is not None
assert state_1.state == STATUS_IDLE
assert ATTR_ICON not in state_1.attributes
assert ATTR_FRIENDLY_NAME not in state_1.attributes
assert state_1.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
assert state_2.state == STATUS_IDLE
assert state_2.attributes.get(ATTR_FRIENDLY_NAME) == "Hello World"
assert state_2.attributes.get(ATTR_ICON) == "mdi:work"
assert state_2.attributes.get(ATTR_DURATION) == "0:00:10"
assert state_2.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "Hello World",
ATTR_ICON: "mdi:work",
ATTR_LAST_ACTION: None,
}
assert state_3.state == STATUS_IDLE
assert str(cv.time_period(DEFAULT_DURATION)) == state_3.attributes.get(
CONF_DURATION
)
assert state_3.attributes == {
ATTR_DURATION: str(cv.time_period(DEFAULT_DURATION)),
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
@pytest.mark.freeze_time("2023-06-05 17:47:50")
async def test_methods_and_events(hass: HomeAssistant) -> None:
"""Test methods and events."""
hass.set_state(CoreState.starting)
@@ -155,13 +166,18 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
results: list[tuple[Event, str]] = []
results: list[tuple[Event, State | None]] = []
@callback
def fake_event_listener(event: Event):
"""Fake event listener for trigger."""
results.append((event, hass.states.get("timer.test1").state))
results.append((event, hass.states.get("timer.test1")))
hass.bus.async_listen(EVENT_TIMER_STARTED, fake_event_listener)
hass.bus.async_listen(EVENT_TIMER_RESTARTED, fake_event_listener)
@@ -170,102 +186,158 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
hass.bus.async_listen(EVENT_TIMER_CANCELLED, fake_event_listener)
hass.bus.async_listen(EVENT_TIMER_CHANGED, fake_event_listener)
finish_10 = (utcnow() + timedelta(seconds=10)).isoformat()
finish_5 = (utcnow() + timedelta(seconds=5)).isoformat()
steps = [
{
"call": SERVICE_START,
"state": STATUS_ACTIVE,
"event": EVENT_TIMER_STARTED,
"data": {},
"call_data": {},
"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,
},
{
"call": SERVICE_PAUSE,
"state": STATUS_PAUSED,
"event": EVENT_TIMER_PAUSED,
"data": {},
"call_data": {},
"expected_state": STATUS_PAUSED,
"expected_extra_attributes": {
ATTR_LAST_ACTION: "paused",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_PAUSED,
},
{
"call": SERVICE_START,
"state": STATUS_ACTIVE,
"event": EVENT_TIMER_RESTARTED,
"data": {},
"call_data": {},
"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,
},
{
"call": SERVICE_CANCEL,
"state": STATUS_IDLE,
"event": EVENT_TIMER_CANCELLED,
"data": {},
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {ATTR_LAST_ACTION: "cancelled"},
"expected_event": EVENT_TIMER_CANCELLED,
},
{
"call": SERVICE_CANCEL,
"state": STATUS_IDLE,
"event": None,
"data": {},
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {ATTR_LAST_ACTION: "cancelled"},
"expected_event": None,
},
{
"call": SERVICE_START,
"state": STATUS_ACTIVE,
"event": EVENT_TIMER_STARTED,
"data": {},
"call_data": {},
"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,
},
{
"call": SERVICE_FINISH,
"state": STATUS_IDLE,
"event": EVENT_TIMER_FINISHED,
"data": {},
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {ATTR_LAST_ACTION: "finished"},
"expected_event": EVENT_TIMER_FINISHED,
},
{
"call": SERVICE_FINISH,
"state": STATUS_IDLE,
"event": None,
"data": {},
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {ATTR_LAST_ACTION: "finished"},
"expected_event": None,
},
{
"call": SERVICE_START,
"state": STATUS_ACTIVE,
"event": EVENT_TIMER_STARTED,
"data": {},
"call_data": {},
"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,
},
{
"call": SERVICE_PAUSE,
"state": STATUS_PAUSED,
"event": EVENT_TIMER_PAUSED,
"data": {},
"call_data": {},
"expected_state": STATUS_PAUSED,
"expected_extra_attributes": {
ATTR_LAST_ACTION: "paused",
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_PAUSED,
},
{
"call": SERVICE_CANCEL,
"state": STATUS_IDLE,
"event": EVENT_TIMER_CANCELLED,
"data": {},
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {ATTR_LAST_ACTION: "cancelled"},
"expected_event": EVENT_TIMER_CANCELLED,
},
{
"call": SERVICE_START,
"state": STATUS_ACTIVE,
"event": EVENT_TIMER_STARTED,
"data": {},
"call_data": {},
"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,
},
{
"call": SERVICE_CHANGE,
"state": STATUS_ACTIVE,
"event": EVENT_TIMER_CHANGED,
"data": {CONF_DURATION: -5},
"call_data": {CONF_DURATION: -5},
"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,
},
{
"call": SERVICE_START,
"state": STATUS_ACTIVE,
"event": EVENT_TIMER_RESTARTED,
"data": {},
"call_data": {},
"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,
},
{
"call": SERVICE_PAUSE,
"state": STATUS_PAUSED,
"event": EVENT_TIMER_PAUSED,
"data": {},
"call_data": {},
"expected_state": STATUS_PAUSED,
"expected_extra_attributes": {
ATTR_LAST_ACTION: "paused",
ATTR_REMAINING: "0:00:05",
},
"expected_event": EVENT_TIMER_PAUSED,
},
{
"call": SERVICE_FINISH,
"state": STATUS_IDLE,
"event": EVENT_TIMER_FINISHED,
"data": {},
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {ATTR_LAST_ACTION: "finished"},
"expected_event": EVENT_TIMER_FINISHED,
},
]
@@ -275,22 +347,38 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
await hass.services.async_call(
DOMAIN,
step["call"],
{CONF_ENTITY_ID: "timer.test1", **step["data"]},
{CONF_ENTITY_ID: "timer.test1", **step["call_data"]},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get("timer.test1")
assert state
if step["state"] is not None:
assert state.state == step["state"]
if step["expected_state"] is not None:
assert state.state == step["expected_state"]
assert (
state.attributes
== {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
}
| step["expected_extra_attributes"]
)
if step["event"] is not None:
if step["expected_event"] is not None:
expected_events += 1
last_result = results[-1]
event, state = last_result
assert event.event_type == step["event"]
assert state == step["state"]
assert event.event_type == step["expected_event"]
assert state.state == step["expected_state"]
assert (
state.attributes
== {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
}
| step["expected_extra_attributes"]
)
assert len(results) == expected_events
@@ -302,7 +390,11 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_DURATION] == "0:00:10"
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_LAST_ACTION: None,
}
await hass.services.async_call(
DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
@@ -311,8 +403,13 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes[ATTR_DURATION] == "0:00:10"
assert state.attributes[ATTR_REMAINING] == "0:00:10"
assert state.attributes == {
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",
}
await hass.services.async_call(
DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
@@ -321,8 +418,11 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_DURATION] == "0:00:10"
assert ATTR_REMAINING not in state.attributes
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_LAST_ACTION: "cancelled",
}
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
@@ -342,8 +442,13 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes[ATTR_DURATION] == "0:00:15"
assert state.attributes[ATTR_REMAINING] == "0:00:15"
assert state.attributes == {
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",
}
with pytest.raises(
HomeAssistantError,
@@ -376,8 +481,13 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes[ATTR_DURATION] == "0:00:15"
assert state.attributes[ATTR_REMAINING] == "0:00:12"
assert state.attributes == {
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",
}
await hass.services.async_call(
DOMAIN,
@@ -388,8 +498,13 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes[ATTR_DURATION] == "0:00:15"
assert state.attributes[ATTR_REMAINING] == "0:00:14"
assert state.attributes == {
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",
}
await hass.services.async_call(
DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
@@ -398,8 +513,11 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_DURATION] == "0:00:10"
assert ATTR_REMAINING not in state.attributes
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_LAST_ACTION: "cancelled",
}
with pytest.raises(
HomeAssistantError,
@@ -415,11 +533,17 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_DURATION] == "0:00:10"
assert ATTR_REMAINING not in state.attributes
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_LAST_ACTION: "cancelled", # Change does not set last_action
}
async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
@pytest.mark.freeze_time("2023-06-05 17:47:50")
async def test_wait_till_timer_expires(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test for a timer to end."""
hass.set_state(CoreState.starting)
@@ -428,6 +552,11 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
results = []
@@ -450,6 +579,13 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
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",
}
assert results[-1].event_type == EVENT_TIMER_STARTED
assert len(results) == 1
@@ -465,23 +601,44 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
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",
}
assert results[-1].event_type == EVENT_TIMER_CHANGED
assert len(results) == 2
async_fire_time_changed(hass, utcnow() + timedelta(seconds=10))
freezer.tick(10)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
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",
}
async_fire_time_changed(hass, utcnow() + timedelta(seconds=20))
freezer.tick(20)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: "finished",
}
assert results[-1].event_type == EVENT_TIMER_FINISHED
assert len(results) == 3
@@ -496,6 +653,11 @@ async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> Non
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
async def test_config_reload(
@@ -538,13 +700,20 @@ async def test_config_reload(
assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None
assert state_1.state == STATUS_IDLE
assert ATTR_ICON not in state_1.attributes
assert ATTR_FRIENDLY_NAME not in state_1.attributes
assert state_1.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
assert state_2.state == STATUS_IDLE
assert state_2.attributes.get(ATTR_FRIENDLY_NAME) == "Hello World"
assert state_2.attributes.get(ATTR_ICON) == "mdi:work"
assert state_2.attributes.get(ATTR_DURATION) == "0:00:10"
assert state_2.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "Hello World",
ATTR_ICON: "mdi:work",
ATTR_LAST_ACTION: None,
}
with patch(
"homeassistant.config.load_yaml_config_file",
@@ -589,15 +758,23 @@ async def test_config_reload(
assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None
assert state_2.state == STATUS_IDLE
assert state_2.attributes.get(ATTR_FRIENDLY_NAME) == "Hello World reloaded"
assert state_2.attributes.get(ATTR_ICON) == "mdi:work-reloaded"
assert state_2.attributes.get(ATTR_DURATION) == "0:00:20"
assert state_2.attributes == {
ATTR_DURATION: "0:00:20",
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 ATTR_ICON not in state_3.attributes
assert ATTR_FRIENDLY_NAME not in state_3.attributes
assert state_3.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
@pytest.mark.freeze_time("2023-06-05 17:47:50")
async def test_timer_restarted_event(hass: HomeAssistant) -> None:
"""Ensure restarted event is called after starting a paused or running timer."""
hass.set_state(CoreState.starting)
@@ -607,6 +784,11 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
results = []
@@ -628,6 +810,13 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
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",
}
assert results[-1].event_type == EVENT_TIMER_STARTED
assert len(results) == 1
@@ -639,6 +828,13 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
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",
}
assert results[-1].event_type == EVENT_TIMER_RESTARTED
assert len(results) == 2
@@ -650,6 +846,12 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_PAUSED
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: "paused",
ATTR_REMAINING: "0:00:10",
}
assert results[-1].event_type == EVENT_TIMER_PAUSED
assert len(results) == 3
@@ -661,11 +863,19 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
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",
}
assert results[-1].event_type == EVENT_TIMER_RESTARTED
assert len(results) == 4
@pytest.mark.freeze_time("2023-06-05 17:47:50")
async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
"""Ensure timer's state changes when it restarted."""
hass.set_state(CoreState.starting)
@@ -675,6 +885,11 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
results = []
@@ -692,6 +907,13 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
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",
}
assert results[-1].event_type == EVENT_STATE_CHANGED
assert len(results) == 1
@@ -703,18 +925,101 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
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",
}
assert results[-1].event_type == EVENT_STATE_CHANGED
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()
state = hass.states.get(f"{DOMAIN}.timer_from_storage")
assert state.state == STATUS_IDLE
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "timer from storage"
assert state.attributes.get(ATTR_EDITABLE)
assert state.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "timer from storage",
ATTR_LAST_ACTION: None,
}
async def test_editable_state_attribute(hass: HomeAssistant, storage_setup) -> None:
@@ -723,12 +1028,20 @@ async def test_editable_state_attribute(hass: HomeAssistant, storage_setup) -> N
state = hass.states.get(f"{DOMAIN}.{DOMAIN}_from_storage")
assert state.state == STATUS_IDLE
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "timer from storage"
assert state.attributes.get(ATTR_EDITABLE)
assert state.attributes == {
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")
assert not state.attributes.get(ATTR_EDITABLE)
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
ATTR_LAST_ACTION: None,
}
async def test_ws_list(
@@ -797,7 +1110,13 @@ async def test_update(
timer_entity_id = f"{DOMAIN}.{DOMAIN}_{timer_id}"
state = hass.states.get(timer_entity_id)
assert state.attributes[ATTR_FRIENDLY_NAME] == "timer from storage"
assert state.state == STATUS_IDLE
assert state.attributes == {
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
)
@@ -827,8 +1146,14 @@ async def test_update(
}
state = hass.states.get(timer_entity_id)
assert state.attributes[ATTR_DURATION] == _format_timedelta(cv.time_period(33))
assert state.attributes[ATTR_RESTORE]
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:33",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "timer from storage",
ATTR_LAST_ACTION: None,
ATTR_RESTORE: True,
}
async def test_ws_create(
@@ -862,7 +1187,12 @@ async def test_ws_create(
state = hass.states.get(timer_entity_id)
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_DURATION] == _format_timedelta(cv.time_period(42))
assert state.attributes == {
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
)
@@ -887,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."""
@@ -895,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,
@@ -919,14 +1293,20 @@ async def test_restore_paused(hass: HomeAssistant) -> None:
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]
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
@@ -937,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,
@@ -967,14 +1351,21 @@ async def test_restore_active_resume(hass: HomeAssistant) -> None:
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 entity.extra_state_attributes == {
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
@@ -985,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,
@@ -1013,8 +1408,10 @@ async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> Non
await hass.async_block_till_done()
assert entity.state == STATUS_IDLE
assert entity.extra_state_attributes[ATTR_DURATION] == "0:01:00"
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 entity.extra_state_attributes == {
ATTR_DURATION: "0:01:00",
ATTR_EDITABLE: True,
ATTR_LAST_ACTION: "finished",
ATTR_RESTORE: True,
}
assert len(events) == 1