Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for Timers."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 from datetime import datetime, timedelta
7 import logging
8 from typing import Any, Self
9 
10 import voluptuous as vol
11 
12 from homeassistant.const import (
13  ATTR_EDITABLE,
14  ATTR_ENTITY_ID,
15  CONF_ICON,
16  CONF_ID,
17  CONF_NAME,
18  SERVICE_RELOAD,
19 )
20 from homeassistant.core import HomeAssistant, ServiceCall, callback
21 from homeassistant.exceptions import HomeAssistantError
22 from homeassistant.helpers import collection
24 from homeassistant.helpers.entity_component import EntityComponent
25 from homeassistant.helpers.event import async_track_point_in_utc_time
26 from homeassistant.helpers.restore_state import RestoreEntity
28 from homeassistant.helpers.storage import Store
29 from homeassistant.helpers.typing import ConfigType, VolDictType
30 import homeassistant.util.dt as dt_util
31 
32 _LOGGER = logging.getLogger(__name__)
33 
34 DOMAIN = "timer"
35 ENTITY_ID_FORMAT = DOMAIN + ".{}"
36 
37 DEFAULT_DURATION = 0
38 DEFAULT_RESTORE = False
39 
40 ATTR_DURATION = "duration"
41 ATTR_REMAINING = "remaining"
42 ATTR_FINISHES_AT = "finishes_at"
43 ATTR_RESTORE = "restore"
44 ATTR_FINISHED_AT = "finished_at"
45 
46 CONF_DURATION = "duration"
47 CONF_RESTORE = "restore"
48 
49 STATUS_IDLE = "idle"
50 STATUS_ACTIVE = "active"
51 STATUS_PAUSED = "paused"
52 
53 EVENT_TIMER_FINISHED = "timer.finished"
54 EVENT_TIMER_CANCELLED = "timer.cancelled"
55 EVENT_TIMER_CHANGED = "timer.changed"
56 EVENT_TIMER_STARTED = "timer.started"
57 EVENT_TIMER_RESTARTED = "timer.restarted"
58 EVENT_TIMER_PAUSED = "timer.paused"
59 
60 SERVICE_START = "start"
61 SERVICE_PAUSE = "pause"
62 SERVICE_CANCEL = "cancel"
63 SERVICE_CHANGE = "change"
64 SERVICE_FINISH = "finish"
65 
66 STORAGE_KEY = DOMAIN
67 STORAGE_VERSION = 1
68 
69 STORAGE_FIELDS: VolDictType = {
70  vol.Required(CONF_NAME): cv.string,
71  vol.Optional(CONF_ICON): cv.icon,
72  vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): cv.time_period,
73  vol.Optional(CONF_RESTORE, default=DEFAULT_RESTORE): cv.boolean,
74 }
75 
76 
77 def _format_timedelta(delta: timedelta) -> str:
78  total_seconds = delta.total_seconds()
79  hours, remainder = divmod(total_seconds, 3600)
80  minutes, seconds = divmod(remainder, 60)
81  return f"{int(hours)}:{int(minutes):02}:{int(seconds):02}"
82 
83 
84 def _none_to_empty_dict[_T](value: _T | None) -> _T | dict[Any, Any]:
85  if value is None:
86  return {}
87  return value
88 
89 
90 CONFIG_SCHEMA = vol.Schema(
91  {
92  DOMAIN: cv.schema_with_slug_keys(
93  vol.All(
94  _none_to_empty_dict,
95  {
96  vol.Optional(CONF_NAME): cv.string,
97  vol.Optional(CONF_ICON): cv.icon,
98  vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): vol.All(
99  cv.time_period, _format_timedelta
100  ),
101  vol.Optional(CONF_RESTORE, default=DEFAULT_RESTORE): cv.boolean,
102  },
103  )
104  )
105  },
106  extra=vol.ALLOW_EXTRA,
107 )
108 
109 RELOAD_SERVICE_SCHEMA = vol.Schema({})
110 
111 
112 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
113  """Set up an input select."""
114  component = EntityComponent[Timer](_LOGGER, DOMAIN, hass)
115  id_manager = collection.IDManager()
116 
117  yaml_collection = collection.YamlCollection(
118  logging.getLogger(f"{__name__}.yaml_collection"), id_manager
119  )
120  collection.sync_entity_lifecycle(
121  hass, DOMAIN, DOMAIN, component, yaml_collection, Timer
122  )
123 
124  storage_collection = TimerStorageCollection(
125  Store(hass, STORAGE_VERSION, STORAGE_KEY),
126  id_manager,
127  )
128  collection.sync_entity_lifecycle(
129  hass, DOMAIN, DOMAIN, component, storage_collection, Timer
130  )
131 
132  await yaml_collection.async_load(
133  [{CONF_ID: id_, **cfg} for id_, cfg in config.get(DOMAIN, {}).items()]
134  )
135  await storage_collection.async_load()
136 
137  collection.DictStorageCollectionWebsocket(
138  storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS
139  ).async_setup(hass)
140 
141  async def reload_service_handler(service_call: ServiceCall) -> None:
142  """Reload yaml entities."""
143  conf = await component.async_prepare_reload(skip_reset=True)
144  if conf is None:
145  conf = {DOMAIN: {}}
146  await yaml_collection.async_load(
147  [{CONF_ID: id_, **cfg} for id_, cfg in conf.get(DOMAIN, {}).items()]
148  )
149 
151  hass,
152  DOMAIN,
153  SERVICE_RELOAD,
154  reload_service_handler,
155  schema=RELOAD_SERVICE_SCHEMA,
156  )
157  component.async_register_entity_service(
158  SERVICE_START,
159  {vol.Optional(ATTR_DURATION, default=DEFAULT_DURATION): cv.time_period},
160  "async_start",
161  )
162  component.async_register_entity_service(SERVICE_PAUSE, None, "async_pause")
163  component.async_register_entity_service(SERVICE_CANCEL, None, "async_cancel")
164  component.async_register_entity_service(SERVICE_FINISH, None, "async_finish")
165  component.async_register_entity_service(
166  SERVICE_CHANGE,
167  {vol.Optional(ATTR_DURATION, default=DEFAULT_DURATION): cv.time_period},
168  "async_change",
169  )
170 
171  return True
172 
173 
174 class TimerStorageCollection(collection.DictStorageCollection):
175  """Timer storage based collection."""
176 
177  CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS)
178 
179  async def _process_create_data(self, data: dict) -> dict:
180  """Validate the config is valid."""
181  data = self.CREATE_UPDATE_SCHEMACREATE_UPDATE_SCHEMA(data)
182  # make duration JSON serializeable
183  data[CONF_DURATION] = _format_timedelta(data[CONF_DURATION])
184  return data
185 
186  @callback
187  def _get_suggested_id(self, info: dict) -> str:
188  """Suggest an ID based on the config."""
189  return info[CONF_NAME] # type: ignore[no-any-return]
190 
191  async def _update_data(self, item: dict, update_data: dict) -> dict:
192  """Return a new updated data object."""
193  data = {CONF_ID: item[CONF_ID]} | self.CREATE_UPDATE_SCHEMACREATE_UPDATE_SCHEMA(update_data)
194  # make duration JSON serializeable
195  if CONF_DURATION in update_data:
196  data[CONF_DURATION] = _format_timedelta(data[CONF_DURATION])
197  return data # type: ignore[no-any-return]
198 
199 
200 class Timer(collection.CollectionEntity, RestoreEntity):
201  """Representation of a timer."""
202 
203  editable: bool
204 
205  def __init__(self, config: ConfigType) -> None:
206  """Initialize a timer."""
207  self._config_config: dict = config
208  self._state_state: str = STATUS_IDLE
209  self._configured_duration_configured_duration = cv.time_period_str(config[CONF_DURATION])
210  self._running_duration_running_duration: timedelta = self._configured_duration_configured_duration
211  self._remaining_remaining: timedelta | None = None
212  self._end_end: datetime | None = None
213  self._listener_listener: Callable[[], None] | None = None
214  self._restore_restore: bool = self._config_config.get(CONF_RESTORE, DEFAULT_RESTORE)
215 
216  self._attr_should_poll_attr_should_poll = False
217  self._attr_force_update_attr_force_update = True
218 
219  @classmethod
220  def from_storage(cls, config: ConfigType) -> Self:
221  """Return entity instance initialized from storage."""
222  timer = cls(config)
223  timer.editable = True
224  return timer
225 
226  @classmethod
227  def from_yaml(cls, config: ConfigType) -> Self:
228  """Return entity instance initialized from yaml."""
229  timer = cls(config)
230  timer.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID])
231  timer.editable = False
232  return timer
233 
234  @property
235  def name(self) -> str | None:
236  """Return name of the timer."""
237  return self._config_config.get(CONF_NAME)
238 
239  @property
240  def icon(self) -> str | None:
241  """Return the icon to be used for this entity."""
242  return self._config_config.get(CONF_ICON)
243 
244  @property
245  def state(self) -> str:
246  """Return the current value of the timer."""
247  return self._state_state
248 
249  @property
250  def extra_state_attributes(self) -> dict[str, Any]:
251  """Return the state attributes."""
252  attrs: dict[str, Any] = {
253  ATTR_DURATION: _format_timedelta(self._running_duration_running_duration),
254  ATTR_EDITABLE: self.editable,
255  }
256  if self._end_end is not None:
257  attrs[ATTR_FINISHES_AT] = self._end_end.isoformat()
258  if self._remaining_remaining is not None:
259  attrs[ATTR_REMAINING] = _format_timedelta(self._remaining_remaining)
260  if self._restore_restore:
261  attrs[ATTR_RESTORE] = self._restore_restore
262 
263  return attrs
264 
265  @property
266  def unique_id(self) -> str | None:
267  """Return unique id for the entity."""
268  return self._config_config[CONF_ID] # type: ignore[no-any-return]
269 
270  async def async_added_to_hass(self) -> None:
271  """Call when entity is about to be added to Home Assistant."""
272  # If we don't need to restore a previous state or no previous state exists,
273  # start at idle
274  if not self._restore_restore or (state := await self.async_get_last_stateasync_get_last_state()) is None:
275  self._state_state = STATUS_IDLE
276  return
277 
278  # Begin restoring state
279  self._state_state = state.state
280 
281  # Nothing more to do if the timer is idle
282  if self._state_state == STATUS_IDLE:
283  return
284 
285  self._running_duration_running_duration = cv.time_period(state.attributes[ATTR_DURATION])
286  # If the timer was paused, we restore the remaining time
287  if self._state_state == STATUS_PAUSED:
288  self._remaining_remaining = cv.time_period(state.attributes[ATTR_REMAINING])
289  return
290  # If we get here, the timer must have been active so we need to decide what
291  # to do based on end time and the current time
292  end = cv.datetime(state.attributes[ATTR_FINISHES_AT])
293  # If there is time remaining in the timer, restore the remaining time then
294  # start the timer
295  if (remaining := end - dt_util.utcnow().replace(microsecond=0)) > timedelta(0):
296  self._remaining_remaining = remaining
297  self._state_state = STATUS_PAUSED
298  self.async_startasync_start()
299  # If the timer ended before now, finish the timer. The event will indicate
300  # when the timer was expected to fire.
301  else:
302  self._end_end = end
303  self.async_finishasync_finish()
304 
305  @callback
306  def async_start(self, duration: timedelta | None = None) -> None:
307  """Start a timer."""
308  if self._listener_listener:
309  self._listener_listener()
310  self._listener_listener = None
311 
312  event = EVENT_TIMER_STARTED
313  if self._state_state in (STATUS_ACTIVE, STATUS_PAUSED):
314  event = EVENT_TIMER_RESTARTED
315 
316  self._state_state = STATUS_ACTIVE
317  start = dt_util.utcnow().replace(microsecond=0)
318 
319  # Set remaining and running duration unless resuming or restarting
320  if duration:
321  self._remaining_remaining = self._running_duration_running_duration = duration
322  elif not self._remaining_remaining:
323  self._remaining_remaining = self._running_duration_running_duration
324 
325  self._end_end = start + self._remaining_remaining
326 
327  self.async_write_ha_stateasync_write_ha_state()
328  self.hasshass.bus.async_fire(event, {ATTR_ENTITY_ID: self.entity_identity_id})
329 
331  self.hasshass, self._async_finished_async_finished, self._end_end
332  )
333 
334  @callback
335  def async_change(self, duration: timedelta) -> None:
336  """Change duration of a running timer."""
337  if self._listener_listener is None or self._end_end is None:
338  raise HomeAssistantError(
339  f"Timer {self.entity_id} is not running, only active timers can be changed"
340  )
341  # Check against new remaining time before checking boundaries
342  new_remaining = (self._end_end + duration) - dt_util.utcnow().replace(microsecond=0)
343  if self._remaining_remaining and new_remaining > self._running_duration_running_duration:
344  raise HomeAssistantError(
345  f"Not possible to change timer {self.entity_id} beyond duration"
346  )
347  if self._remaining_remaining and (self._remaining_remaining + duration) < timedelta():
348  raise HomeAssistantError(
349  f"Not possible to change timer {self.entity_id} to negative time remaining"
350  )
351 
352  self._listener_listener()
353  self._end_end += duration
354  self._remaining_remaining = new_remaining
355  self.async_write_ha_stateasync_write_ha_state()
356  self.hasshass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_identity_id})
358  self.hasshass, self._async_finished_async_finished, self._end_end
359  )
360 
361  @callback
362  def async_pause(self) -> None:
363  """Pause a timer."""
364  if self._listener_listener is None or self._end_end is None:
365  return
366 
367  self._listener_listener()
368  self._listener_listener = None
369  self._remaining_remaining = self._end_end - dt_util.utcnow().replace(microsecond=0)
370  self._state_state = STATUS_PAUSED
371  self._end_end = None
372  self.async_write_ha_stateasync_write_ha_state()
373  self.hasshass.bus.async_fire(EVENT_TIMER_PAUSED, {ATTR_ENTITY_ID: self.entity_identity_id})
374 
375  @callback
376  def async_cancel(self) -> None:
377  """Cancel a timer."""
378  if self._listener_listener:
379  self._listener_listener()
380  self._listener_listener = None
381  self._state_state = STATUS_IDLE
382  self._end_end = None
383  self._remaining_remaining = None
384  self._running_duration_running_duration = self._configured_duration_configured_duration
385  self.async_write_ha_stateasync_write_ha_state()
386  self.hasshass.bus.async_fire(
387  EVENT_TIMER_CANCELLED, {ATTR_ENTITY_ID: self.entity_identity_id}
388  )
389 
390  @callback
391  def async_finish(self) -> None:
392  """Reset and updates the states, fire finished event."""
393  if self._state_state != STATUS_ACTIVE or self._end_end is None:
394  return
395 
396  if self._listener_listener:
397  self._listener_listener()
398  self._listener_listener = None
399  end = self._end_end
400  self._state_state = STATUS_IDLE
401  self._end_end = None
402  self._remaining_remaining = None
403  self._running_duration_running_duration = self._configured_duration_configured_duration
404  self.async_write_ha_stateasync_write_ha_state()
405  self.hasshass.bus.async_fire(
406  EVENT_TIMER_FINISHED,
407  {ATTR_ENTITY_ID: self.entity_identity_id, ATTR_FINISHED_AT: end.isoformat()},
408  )
409 
410  @callback
411  def _async_finished(self, time: datetime) -> None:
412  """Reset and updates the states, fire finished event."""
413  if self._state_state != STATUS_ACTIVE or self._end_end is None:
414  return
415 
416  self._listener_listener = None
417  self._state_state = STATUS_IDLE
418  end = self._end_end
419  self._end_end = None
420  self._remaining_remaining = None
421  self._running_duration_running_duration = self._configured_duration_configured_duration
422  self.async_write_ha_stateasync_write_ha_state()
423  self.hasshass.bus.async_fire(
424  EVENT_TIMER_FINISHED,
425  {ATTR_ENTITY_ID: self.entity_identity_id, ATTR_FINISHED_AT: end.isoformat()},
426  )
427 
428  async def async_update_config(self, config: ConfigType) -> None:
429  """Handle when the config is updated."""
430  self._config_config = config
431  self._configured_duration_configured_duration = cv.time_period_str(config[CONF_DURATION])
432  if self._state_state == STATUS_IDLE:
433  self._running_duration_running_duration = self._configured_duration_configured_duration
434  self._restore_restore = config.get(CONF_RESTORE, DEFAULT_RESTORE)
435  self.async_write_ha_stateasync_write_ha_state()
dict _update_data(self, dict item, dict update_data)
Definition: __init__.py:191
None _async_finished(self, datetime time)
Definition: __init__.py:411
Self from_storage(cls, ConfigType config)
Definition: __init__.py:220
None async_update_config(self, ConfigType config)
Definition: __init__.py:428
dict[str, Any] extra_state_attributes(self)
Definition: __init__.py:250
None async_start(self, timedelta|None duration=None)
Definition: __init__.py:306
None async_change(self, timedelta duration)
Definition: __init__.py:335
Self from_yaml(cls, ConfigType config)
Definition: __init__.py:227
None __init__(self, ConfigType config)
Definition: __init__.py:205
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
str _format_timedelta(timedelta delta)
Definition: __init__.py:77
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:112
CALLBACK_TYPE async_track_point_in_utc_time(HomeAssistant hass, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action, datetime point_in_time)
Definition: event.py:1542
None async_register_admin_service(HomeAssistant hass, str domain, str service, Callable[[ServiceCall], Awaitable[None]|None] service_func, VolSchemaType schema=vol.Schema({}, extra=vol.PREVENT_EXTRA))
Definition: service.py:1121