Home Assistant Unofficial Reference 2024.12.1
timers.py
Go to the documentation of this file.
1 """Timer implementation for intents."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Callable
7 from dataclasses import dataclass
8 from enum import StrEnum
9 import logging
10 import time
11 from typing import Any
12 
13 from propcache import cached_property
14 import voluptuous as vol
15 
16 from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME
17 from homeassistant.core import Context, HomeAssistant, callback
18 from homeassistant.helpers import (
19  area_registry as ar,
20  config_validation as cv,
21  device_registry as dr,
22  intent,
23 )
24 from homeassistant.util import ulid
25 
26 from .const import TIMER_DATA
27 
28 _LOGGER = logging.getLogger(__name__)
29 
30 TIMER_NOT_FOUND_RESPONSE = "timer_not_found"
31 MULTIPLE_TIMERS_MATCHED_RESPONSE = "multiple_timers_matched"
32 NO_TIMER_SUPPORT_RESPONSE = "no_timer_support"
33 
34 
35 @dataclass
36 class TimerInfo:
37  """Information for a single timer."""
38 
39  id: str
40  """Unique id of the timer."""
41 
42  name: str | None
43  """User-provided name for timer."""
44 
45  seconds: int
46  """Total number of seconds the timer should run for."""
47 
48  device_id: str | None
49  """Id of the device where the timer was set.
50 
51  May be None only if conversation_command is set.
52  """
53 
54  start_hours: int | None
55  """Number of hours the timer should run as given by the user."""
56 
57  start_minutes: int | None
58  """Number of minutes the timer should run as given by the user."""
59 
60  start_seconds: int | None
61  """Number of seconds the timer should run as given by the user."""
62 
63  created_at: int
64  """Timestamp when timer was created (time.monotonic_ns)"""
65 
66  updated_at: int
67  """Timestamp when timer was last updated (time.monotonic_ns)"""
68 
69  language: str
70  """Language of command used to set the timer."""
71 
72  is_active: bool = True
73  """True if timer is ticking down."""
74 
75  area_id: str | None = None
76  """Id of area that the device belongs to."""
77 
78  area_name: str | None = None
79  """Normalized name of the area that the device belongs to."""
80 
81  floor_id: str | None = None
82  """Id of floor that the device's area belongs to."""
83 
84  conversation_command: str | None = None
85  """Text of conversation command to execute when timer is finished.
86 
87  This command must be in the language used to set the timer.
88  """
89 
90  conversation_agent_id: str | None = None
91  """Id of the conversation agent used to set the timer.
92 
93  This agent will be used to execute the conversation command.
94  """
95 
96  _created_seconds: int = 0
97  """Number of seconds on the timer when it was created."""
98 
99  def __post_init__(self) -> None:
100  """Post initialization."""
101  self._created_seconds_created_seconds = self.secondsseconds
102 
103  @property
104  def seconds_left(self) -> int:
105  """Return number of seconds left on the timer."""
106  if not self.is_activeis_active:
107  return self.secondsseconds
108 
109  now = time.monotonic_ns()
110  seconds_running = int((now - self.updated_atupdated_at) / 1e9)
111  return max(0, self.secondsseconds - seconds_running)
112 
113  @property
114  def created_seconds(self) -> int:
115  """Return number of seconds on the timer when it was created.
116 
117  This value is increased if time is added to the timer, exceeding its
118  original created_seconds.
119  """
120  return self._created_seconds_created_seconds
121 
122  @cached_property
123  def name_normalized(self) -> str:
124  """Return normalized timer name."""
125  return _normalize_name(self.name or "")
126 
127  def cancel(self) -> None:
128  """Cancel the timer."""
129  self.secondsseconds = 0
130  self.updated_atupdated_at = time.monotonic_ns()
131  self.is_activeis_active = False
132 
133  def pause(self) -> None:
134  """Pause the timer."""
135  self.secondsseconds = self.seconds_leftseconds_left
136  self.updated_atupdated_at = time.monotonic_ns()
137  self.is_activeis_active = False
138 
139  def unpause(self) -> None:
140  """Unpause the timer."""
141  self.updated_atupdated_at = time.monotonic_ns()
142  self.is_activeis_active = True
143 
144  def add_time(self, seconds: int) -> None:
145  """Add time to the timer.
146 
147  Seconds may be negative to remove time instead.
148  """
149  self.secondsseconds = max(0, self.seconds_leftseconds_left + seconds)
150  self._created_seconds_created_seconds = max(self._created_seconds_created_seconds, self.secondsseconds)
151  self.updated_atupdated_at = time.monotonic_ns()
152 
153  def finish(self) -> None:
154  """Finish the timer."""
155  self.secondsseconds = 0
156  self.updated_atupdated_at = time.monotonic_ns()
157  self.is_activeis_active = False
158 
159 
160 class TimerEventType(StrEnum):
161  """Event type in timer handler."""
162 
163  STARTED = "started"
164  """Timer has started."""
165 
166  UPDATED = "updated"
167  """Timer has been increased, decreased, paused, or unpaused."""
168 
169  CANCELLED = "cancelled"
170  """Timer has been cancelled."""
171 
172  FINISHED = "finished"
173  """Timer finished without being cancelled."""
174 
175 
176 type TimerHandler = Callable[[TimerEventType, TimerInfo], None]
177 
178 
179 class TimerNotFoundError(intent.IntentHandleError):
180  """Error when a timer could not be found by name or start time."""
181 
182  def __init__(self) -> None:
183  """Initialize error."""
184  super().__init__("Timer not found", TIMER_NOT_FOUND_RESPONSE)
185 
186 
187 class MultipleTimersMatchedError(intent.IntentHandleError):
188  """Error when multiple timers matched name or start time."""
189 
190  def __init__(self) -> None:
191  """Initialize error."""
192  super().__init__("Multiple timers matched", MULTIPLE_TIMERS_MATCHED_RESPONSE)
193 
194 
195 class TimersNotSupportedError(intent.IntentHandleError):
196  """Error when a timer intent is used from a device that isn't registered to handle timer events."""
197 
198  def __init__(self, device_id: str | None = None) -> None:
199  """Initialize error."""
200  super().__init__(
201  f"Device does not support timers: device_id={device_id}",
202  NO_TIMER_SUPPORT_RESPONSE,
203  )
204 
205 
207  """Manager for intent timers."""
208 
209  def __init__(self, hass: HomeAssistant) -> None:
210  """Initialize timer manager."""
211  self.hasshass = hass
212 
213  # timer id -> timer
214  self.timers: dict[str, TimerInfo] = {}
215  self.timer_tasks: dict[str, asyncio.Task] = {}
216 
217  # device_id -> handler
218  self.handlers: dict[str, TimerHandler] = {}
219 
221  self, device_id: str, handler: TimerHandler
222  ) -> Callable[[], None]:
223  """Register a timer handler.
224 
225  Returns a callable to unregister.
226  """
227  self.handlers[device_id] = handler
228 
229  def unregister() -> None:
230  self.handlers.pop(device_id)
231 
232  return unregister
233 
235  self,
236  device_id: str | None,
237  hours: int | None,
238  minutes: int | None,
239  seconds: int | None,
240  language: str,
241  name: str | None = None,
242  conversation_command: str | None = None,
243  conversation_agent_id: str | None = None,
244  ) -> str:
245  """Start a timer."""
246  if (not conversation_command) and (device_id is None):
247  raise ValueError("Conversation command must be set if no device id")
248 
249  if (not conversation_command) and (
250  (device_id is None) or (not self.is_timer_deviceis_timer_device(device_id))
251  ):
252  raise TimersNotSupportedError(device_id)
253 
254  total_seconds = 0
255  if hours is not None:
256  total_seconds += 60 * 60 * hours
257 
258  if minutes is not None:
259  total_seconds += 60 * minutes
260 
261  if seconds is not None:
262  total_seconds += seconds
263 
264  timer_id = ulid.ulid_now()
265  created_at = time.monotonic_ns()
266  timer = TimerInfo(
267  id=timer_id,
268  name=name,
269  start_hours=hours,
270  start_minutes=minutes,
271  start_seconds=seconds,
272  seconds=total_seconds,
273  language=language,
274  device_id=device_id,
275  created_at=created_at,
276  updated_at=created_at,
277  conversation_command=conversation_command,
278  conversation_agent_id=conversation_agent_id,
279  )
280 
281  # Fill in area/floor info
282  device_registry = dr.async_get(self.hasshass)
283  if device_id and (device := device_registry.async_get(device_id)):
284  timer.area_id = device.area_id
285  area_registry = ar.async_get(self.hasshass)
286  if device.area_id and (
287  area := area_registry.async_get_area(device.area_id)
288  ):
289  timer.area_name = _normalize_name(area.name)
290  timer.floor_id = area.floor_id
291 
292  self.timers[timer_id] = timer
293  self.timer_tasks[timer_id] = self.hasshass.async_create_background_task(
294  self._wait_for_timer_wait_for_timer(timer_id, total_seconds, created_at),
295  name=f"Timer {timer_id}",
296  )
297 
298  if (not timer.conversation_command) and (timer.device_id in self.handlers):
299  self.handlers[timer.device_id](TimerEventType.STARTED, timer)
300  _LOGGER.debug(
301  "Timer started: id=%s, name=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s",
302  timer_id,
303  name,
304  hours,
305  minutes,
306  seconds,
307  device_id,
308  )
309 
310  return timer_id
311 
312  async def _wait_for_timer(
313  self, timer_id: str, seconds: int, updated_at: int
314  ) -> None:
315  """Sleep until timer is up. Timer is only finished if it hasn't been updated."""
316  try:
317  await asyncio.sleep(seconds)
318  if (timer := self.timers.get(timer_id)) and (
319  timer.updated_at == updated_at
320  ):
321  self._timer_finished_timer_finished(timer_id)
322  except asyncio.CancelledError:
323  pass # expected when timer is updated
324 
325  def cancel_timer(self, timer_id: str) -> None:
326  """Cancel a timer."""
327  timer = self.timers.pop(timer_id, None)
328  if timer is None:
329  raise TimerNotFoundError
330 
331  if timer.is_active:
332  task = self.timer_tasks.pop(timer_id)
333  task.cancel()
334 
335  timer.cancel()
336 
337  if (not timer.conversation_command) and (timer.device_id in self.handlers):
338  self.handlers[timer.device_id](TimerEventType.CANCELLED, timer)
339  _LOGGER.debug(
340  "Timer cancelled: id=%s, name=%s, seconds_left=%s, device_id=%s",
341  timer_id,
342  timer.name,
343  timer.seconds_left,
344  timer.device_id,
345  )
346 
347  def add_time(self, timer_id: str, seconds: int) -> None:
348  """Add time to a timer."""
349  timer = self.timers.get(timer_id)
350  if timer is None:
351  raise TimerNotFoundError
352 
353  if seconds == 0:
354  # Don't bother cancelling and recreating the timer task
355  return
356 
357  timer.add_time(seconds)
358  if timer.is_active:
359  task = self.timer_tasks.pop(timer_id)
360  task.cancel()
361  self.timer_tasks[timer_id] = self.hasshass.async_create_background_task(
362  self._wait_for_timer_wait_for_timer(timer_id, timer.seconds, timer.updated_at),
363  name=f"Timer {timer_id}",
364  )
365 
366  if (not timer.conversation_command) and (timer.device_id in self.handlers):
367  self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
368 
369  if seconds > 0:
370  log_verb = "increased"
371  log_seconds = seconds
372  else:
373  log_verb = "decreased"
374  log_seconds = -seconds
375 
376  _LOGGER.debug(
377  "Timer %s by %s second(s): id=%s, name=%s, seconds_left=%s, device_id=%s",
378  log_verb,
379  log_seconds,
380  timer_id,
381  timer.name,
382  timer.seconds_left,
383  timer.device_id,
384  )
385 
386  def remove_time(self, timer_id: str, seconds: int) -> None:
387  """Remove time from a timer."""
388  self.add_timeadd_time(timer_id, -seconds)
389 
390  def pause_timer(self, timer_id: str) -> None:
391  """Pauses a timer."""
392  timer = self.timers.get(timer_id)
393  if timer is None:
394  raise TimerNotFoundError
395 
396  if not timer.is_active:
397  # Already paused
398  return
399 
400  timer.pause()
401  task = self.timer_tasks.pop(timer_id)
402  task.cancel()
403 
404  if (not timer.conversation_command) and (timer.device_id in self.handlers):
405  self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
406  _LOGGER.debug(
407  "Timer paused: id=%s, name=%s, seconds_left=%s, device_id=%s",
408  timer_id,
409  timer.name,
410  timer.seconds_left,
411  timer.device_id,
412  )
413 
414  def unpause_timer(self, timer_id: str) -> None:
415  """Unpause a timer."""
416  timer = self.timers.get(timer_id)
417  if timer is None:
418  raise TimerNotFoundError
419 
420  if timer.is_active:
421  # Already unpaused
422  return
423 
424  timer.unpause()
425  self.timer_tasks[timer_id] = self.hasshass.async_create_background_task(
426  self._wait_for_timer_wait_for_timer(timer_id, timer.seconds_left, timer.updated_at),
427  name=f"Timer {timer.id}",
428  )
429 
430  if (not timer.conversation_command) and (timer.device_id in self.handlers):
431  self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
432  _LOGGER.debug(
433  "Timer unpaused: id=%s, name=%s, seconds_left=%s, device_id=%s",
434  timer_id,
435  timer.name,
436  timer.seconds_left,
437  timer.device_id,
438  )
439 
440  def _timer_finished(self, timer_id: str) -> None:
441  """Call event handlers when a timer finishes."""
442  timer = self.timers.pop(timer_id)
443 
444  timer.finish()
445 
446  if timer.conversation_command:
447  # pylint: disable-next=import-outside-toplevel
448  from homeassistant.components.conversation import async_converse
449 
450  self.hasshass.async_create_background_task(
452  self.hasshass,
453  timer.conversation_command,
454  conversation_id=None,
455  context=Context(),
456  language=timer.language,
457  agent_id=timer.conversation_agent_id,
458  device_id=timer.device_id,
459  ),
460  "timer assist command",
461  )
462  elif timer.device_id in self.handlers:
463  self.handlers[timer.device_id](TimerEventType.FINISHED, timer)
464 
465  _LOGGER.debug(
466  "Timer finished: id=%s, name=%s, device_id=%s",
467  timer_id,
468  timer.name,
469  timer.device_id,
470  )
471 
472  def is_timer_device(self, device_id: str) -> bool:
473  """Return True if device has been registered to handle timer events."""
474  return device_id in self.handlers
475 
476 
477 @callback
478 def async_device_supports_timers(hass: HomeAssistant, device_id: str) -> bool:
479  """Return True if device has been registered to handle timer events."""
480  timer_manager: TimerManager | None = hass.data.get(TIMER_DATA)
481  if timer_manager is None:
482  return False
483  return timer_manager.is_timer_device(device_id)
484 
485 
486 @callback
488  hass: HomeAssistant, device_id: str, handler: TimerHandler
489 ) -> Callable[[], None]:
490  """Register a handler for timer events.
491 
492  Returns a callable to unregister.
493  """
494  timer_manager: TimerManager = hass.data[TIMER_DATA]
495  return timer_manager.register_handler(device_id, handler)
496 
497 
498 # -----------------------------------------------------------------------------
499 
500 
501 class FindTimerFilter(StrEnum):
502  """Type of filter to apply when finding a timer."""
503 
504  ONLY_ACTIVE = "only_active"
505  ONLY_INACTIVE = "only_inactive"
506 
507 
509  hass: HomeAssistant,
510  device_id: str | None,
511  slots: dict[str, Any],
512  find_filter: FindTimerFilter | None = None,
513 ) -> TimerInfo:
514  """Match a single timer with constraints or raise an error."""
515  timer_manager: TimerManager = hass.data[TIMER_DATA]
516 
517  # Ignore delayed command timers
518  matching_timers: list[TimerInfo] = [
519  t for t in timer_manager.timers.values() if not t.conversation_command
520  ]
521  has_filter = False
522 
523  if find_filter:
524  # Filter by active state
525  has_filter = True
526  if find_filter == FindTimerFilter.ONLY_ACTIVE:
527  matching_timers = [t for t in matching_timers if t.is_active]
528  elif find_filter == FindTimerFilter.ONLY_INACTIVE:
529  matching_timers = [t for t in matching_timers if not t.is_active]
530 
531  if len(matching_timers) == 1:
532  # Only 1 match
533  return matching_timers[0]
534 
535  # Search by name first
536  name: str | None = None
537  if "name" in slots:
538  has_filter = True
539  name = slots["name"]["value"]
540  assert name is not None
541  name_norm = _normalize_name(name)
542 
543  matching_timers = [t for t in matching_timers if t.name_normalized == name_norm]
544  if len(matching_timers) == 1:
545  # Only 1 match
546  return matching_timers[0]
547 
548  # Search by area name
549  area_name: str | None = None
550  if "area" in slots:
551  has_filter = True
552  area_name = slots["area"]["value"]
553  assert area_name is not None
554  area_name_norm = _normalize_name(area_name)
555 
556  matching_timers = [t for t in matching_timers if t.area_name == area_name_norm]
557  if len(matching_timers) == 1:
558  # Only 1 match
559  return matching_timers[0]
560 
561  # Use starting time to disambiguate
562  start_hours: int | None = None
563  if "start_hours" in slots:
564  start_hours = int(slots["start_hours"]["value"])
565 
566  start_minutes: int | None = None
567  if "start_minutes" in slots:
568  start_minutes = int(slots["start_minutes"]["value"])
569 
570  start_seconds: int | None = None
571  if "start_seconds" in slots:
572  start_seconds = int(slots["start_seconds"]["value"])
573 
574  if (
575  (start_hours is not None)
576  or (start_minutes is not None)
577  or (start_seconds is not None)
578  ):
579  has_filter = True
580  matching_timers = [
581  t
582  for t in matching_timers
583  if (t.start_hours == start_hours)
584  and (t.start_minutes == start_minutes)
585  and (t.start_seconds == start_seconds)
586  ]
587 
588  if len(matching_timers) == 1:
589  # Only 1 match remaining
590  return matching_timers[0]
591 
592  if (not has_filter) and (len(matching_timers) == 1):
593  # Only 1 match remaining with no filter
594  return matching_timers[0]
595 
596  # Use device id
597  if matching_timers and device_id:
598  matching_device_timers = [
599  t for t in matching_timers if (t.device_id == device_id)
600  ]
601  if len(matching_device_timers) == 1:
602  # Only 1 match remaining
603  return matching_device_timers[0]
604 
605  # Try area/floor
606  device_registry = dr.async_get(hass)
607  area_registry = ar.async_get(hass)
608  if (
609  (device := device_registry.async_get(device_id))
610  and device.area_id
611  and (area := area_registry.async_get_area(device.area_id))
612  ):
613  # Try area
614  matching_area_timers = [
615  t for t in matching_timers if (t.area_id == area.id)
616  ]
617  if len(matching_area_timers) == 1:
618  # Only 1 match remaining
619  return matching_area_timers[0]
620 
621  # Try floor
622  matching_floor_timers = [
623  t for t in matching_timers if (t.floor_id == area.floor_id)
624  ]
625  if len(matching_floor_timers) == 1:
626  # Only 1 match remaining
627  return matching_floor_timers[0]
628 
629  if matching_timers:
630  raise MultipleTimersMatchedError
631 
632  _LOGGER.warning(
633  "Timer not found: name=%s, area=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s",
634  name,
635  area_name,
636  start_hours,
637  start_minutes,
638  start_seconds,
639  device_id,
640  )
641 
642  raise TimerNotFoundError
643 
644 
646  hass: HomeAssistant, device_id: str | None, slots: dict[str, Any]
647 ) -> list[TimerInfo]:
648  """Match multiple timers with constraints or raise an error."""
649  timer_manager: TimerManager = hass.data[TIMER_DATA]
650 
651  # Ignore delayed command timers
652  matching_timers: list[TimerInfo] = [
653  t for t in timer_manager.timers.values() if not t.conversation_command
654  ]
655 
656  # Filter by name first
657  name: str | None = None
658  if "name" in slots:
659  name = slots["name"]["value"]
660  assert name is not None
661  name_norm = _normalize_name(name)
662 
663  matching_timers = [t for t in matching_timers if t.name_normalized == name_norm]
664  if not matching_timers:
665  # No matches
666  return matching_timers
667 
668  # Filter by area name
669  area_name: str | None = None
670  if "area" in slots:
671  area_name = slots["area"]["value"]
672  assert area_name is not None
673  area_name_norm = _normalize_name(area_name)
674 
675  matching_timers = [t for t in matching_timers if t.area_name == area_name_norm]
676  if not matching_timers:
677  # No matches
678  return matching_timers
679 
680  # Use starting time to filter, if present
681  start_hours: int | None = None
682  if "start_hours" in slots:
683  start_hours = int(slots["start_hours"]["value"])
684 
685  start_minutes: int | None = None
686  if "start_minutes" in slots:
687  start_minutes = int(slots["start_minutes"]["value"])
688 
689  start_seconds: int | None = None
690  if "start_seconds" in slots:
691  start_seconds = int(slots["start_seconds"]["value"])
692 
693  if (
694  (start_hours is not None)
695  or (start_minutes is not None)
696  or (start_seconds is not None)
697  ):
698  matching_timers = [
699  t
700  for t in matching_timers
701  if (t.start_hours == start_hours)
702  and (t.start_minutes == start_minutes)
703  and (t.start_seconds == start_seconds)
704  ]
705  if not matching_timers:
706  # No matches
707  return matching_timers
708 
709  if not device_id:
710  # Can't order using area/floor
711  return matching_timers
712 
713  # Use device id to order remaining timers
714  device_registry = dr.async_get(hass)
715  device = device_registry.async_get(device_id)
716  if (device is None) or (device.area_id is None):
717  return matching_timers
718 
719  area_registry = ar.async_get(hass)
720  area = area_registry.async_get_area(device.area_id)
721  if area is None:
722  return matching_timers
723 
724  def area_floor_sort(timer: TimerInfo) -> int:
725  """Sort by area, then floor."""
726  if timer.area_id == area.id:
727  return -2
728 
729  if timer.floor_id == area.floor_id:
730  return -1
731 
732  return 0
733 
734  matching_timers.sort(key=area_floor_sort)
735 
736  return matching_timers
737 
738 
739 def _normalize_name(name: str) -> str:
740  """Normalize name for comparison."""
741  return name.strip().casefold()
742 
743 
744 def _get_total_seconds(slots: dict[str, Any]) -> int:
745  """Return the total number of seconds from hours/minutes/seconds slots."""
746  total_seconds = 0
747  if "hours" in slots:
748  total_seconds += 60 * 60 * int(slots["hours"]["value"])
749 
750  if "minutes" in slots:
751  total_seconds += 60 * int(slots["minutes"]["value"])
752 
753  if "seconds" in slots:
754  total_seconds += int(slots["seconds"]["value"])
755 
756  return total_seconds
757 
758 
759 def _round_time(hours: int, minutes: int, seconds: int) -> tuple[int, int, int]:
760  """Round time to a lower precision for feedback."""
761  if hours > 0:
762  # No seconds, round up above 45 minutes and down below 15
763  rounded_hours = hours
764  rounded_seconds = 0
765  if minutes > 45:
766  # 01:50:30 -> 02:00:00
767  rounded_hours += 1
768  rounded_minutes = 0
769  elif minutes < 15:
770  # 01:10:30 -> 01:00:00
771  rounded_minutes = 0
772  else:
773  # 01:25:30 -> 01:30:00
774  rounded_minutes = 30
775  elif minutes > 0:
776  # Round up above 45 seconds, down below 15
777  rounded_hours = 0
778  rounded_minutes = minutes
779  if seconds > 45:
780  # 00:01:50 -> 00:02:00
781  rounded_minutes += 1
782  rounded_seconds = 0
783  elif seconds < 15:
784  # 00:01:10 -> 00:01:00
785  rounded_seconds = 0
786  else:
787  # 00:01:25 -> 00:01:30
788  rounded_seconds = 30
789  else:
790  # Round up above 50 seconds, exact below 10, and down to nearest 10
791  # otherwise.
792  rounded_hours = 0
793  rounded_minutes = 0
794  if seconds > 50:
795  # 00:00:55 -> 00:01:00
796  rounded_minutes = 1
797  rounded_seconds = 0
798  elif seconds < 10:
799  # 00:00:09 -> 00:00:09
800  rounded_seconds = seconds
801  else:
802  # 00:01:25 -> 00:01:20
803  rounded_seconds = seconds - (seconds % 10)
804 
805  return rounded_hours, rounded_minutes, rounded_seconds
806 
807 
808 class StartTimerIntentHandler(intent.IntentHandler):
809  """Intent handler for starting a new timer."""
810 
811  intent_type = intent.INTENT_START_TIMER
812  description = "Starts a new timer"
813  slot_schema = {
814  vol.Required(vol.Any("hours", "minutes", "seconds")): cv.positive_int,
815  vol.Optional("name"): cv.string,
816  vol.Optional("conversation_command"): cv.string,
817  }
818 
819  async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
820  """Handle the intent."""
821  hass = intent_obj.hass
822  timer_manager: TimerManager = hass.data[TIMER_DATA]
823  slots = self.async_validate_slots(intent_obj.slots)
824 
825  conversation_command: str | None = None
826  if "conversation_command" in slots:
827  conversation_command = slots["conversation_command"]["value"].strip()
828 
829  if (not conversation_command) and (
830  not (
831  intent_obj.device_id
832  and timer_manager.is_timer_device(intent_obj.device_id)
833  )
834  ):
835  # Fail early if this is not a delayed command
836  raise TimersNotSupportedError(intent_obj.device_id)
837 
838  name: str | None = None
839  if "name" in slots:
840  name = slots["name"]["value"]
841 
842  hours: int | None = None
843  if "hours" in slots:
844  hours = int(slots["hours"]["value"])
845 
846  minutes: int | None = None
847  if "minutes" in slots:
848  minutes = int(slots["minutes"]["value"])
849 
850  seconds: int | None = None
851  if "seconds" in slots:
852  seconds = int(slots["seconds"]["value"])
853 
854  timer_manager.start_timer(
855  intent_obj.device_id,
856  hours,
857  minutes,
858  seconds,
859  language=intent_obj.language,
860  name=name,
861  conversation_command=conversation_command,
862  conversation_agent_id=intent_obj.conversation_agent_id,
863  )
864 
865  return intent_obj.create_response()
866 
867 
868 class CancelTimerIntentHandler(intent.IntentHandler):
869  """Intent handler for cancelling a timer."""
870 
871  intent_type = intent.INTENT_CANCEL_TIMER
872  description = "Cancels a timer"
873  slot_schema = {
874  vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int,
875  vol.Optional("name"): cv.string,
876  vol.Optional("area"): cv.string,
877  }
878 
879  async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
880  """Handle the intent."""
881  hass = intent_obj.hass
882  timer_manager: TimerManager = hass.data[TIMER_DATA]
883  slots = self.async_validate_slots(intent_obj.slots)
884 
885  timer = _find_timer(hass, intent_obj.device_id, slots)
886  timer_manager.cancel_timer(timer.id)
887  return intent_obj.create_response()
888 
889 
890 class CancelAllTimersIntentHandler(intent.IntentHandler):
891  """Intent handler for cancelling all timers."""
892 
893  intent_type = intent.INTENT_CANCEL_ALL_TIMERS
894  description = "Cancels all timers"
895  slot_schema = {
896  vol.Optional("area"): cv.string,
897  }
898 
899  async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
900  """Handle the intent."""
901  hass = intent_obj.hass
902  timer_manager: TimerManager = hass.data[TIMER_DATA]
903  slots = self.async_validate_slots(intent_obj.slots)
904  canceled = 0
905 
906  for timer in _find_timers(hass, intent_obj.device_id, slots):
907  timer_manager.cancel_timer(timer.id)
908  canceled += 1
909 
910  response = intent_obj.create_response()
911  speech_slots = {"canceled": canceled}
912  if "area" in slots:
913  speech_slots["area"] = slots["area"]["value"]
914 
915  response.async_set_speech_slots(speech_slots)
916 
917  return response
918 
919 
920 class IncreaseTimerIntentHandler(intent.IntentHandler):
921  """Intent handler for increasing the time of a timer."""
922 
923  intent_type = intent.INTENT_INCREASE_TIMER
924  description = "Adds more time to a timer"
925  slot_schema = {
926  vol.Any("hours", "minutes", "seconds"): cv.positive_int,
927  vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int,
928  vol.Optional("name"): cv.string,
929  vol.Optional("area"): cv.string,
930  }
931 
932  async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
933  """Handle the intent."""
934  hass = intent_obj.hass
935  timer_manager: TimerManager = hass.data[TIMER_DATA]
936  slots = self.async_validate_slots(intent_obj.slots)
937 
938  total_seconds = _get_total_seconds(slots)
939  timer = _find_timer(hass, intent_obj.device_id, slots)
940  timer_manager.add_time(timer.id, total_seconds)
941  return intent_obj.create_response()
942 
943 
944 class DecreaseTimerIntentHandler(intent.IntentHandler):
945  """Intent handler for decreasing the time of a timer."""
946 
947  intent_type = intent.INTENT_DECREASE_TIMER
948  description = "Removes time from a timer"
949  slot_schema = {
950  vol.Required(vol.Any("hours", "minutes", "seconds")): cv.positive_int,
951  vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int,
952  vol.Optional("name"): cv.string,
953  vol.Optional("area"): cv.string,
954  }
955 
956  async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
957  """Handle the intent."""
958  hass = intent_obj.hass
959  timer_manager: TimerManager = hass.data[TIMER_DATA]
960  slots = self.async_validate_slots(intent_obj.slots)
961 
962  total_seconds = _get_total_seconds(slots)
963  timer = _find_timer(hass, intent_obj.device_id, slots)
964  timer_manager.remove_time(timer.id, total_seconds)
965  return intent_obj.create_response()
966 
967 
968 class PauseTimerIntentHandler(intent.IntentHandler):
969  """Intent handler for pausing a running timer."""
970 
971  intent_type = intent.INTENT_PAUSE_TIMER
972  description = "Pauses a running timer"
973  slot_schema = {
974  vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int,
975  vol.Optional("name"): cv.string,
976  vol.Optional("area"): cv.string,
977  }
978 
979  async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
980  """Handle the intent."""
981  hass = intent_obj.hass
982  timer_manager: TimerManager = hass.data[TIMER_DATA]
983  slots = self.async_validate_slots(intent_obj.slots)
984 
985  timer = _find_timer(
986  hass, intent_obj.device_id, slots, find_filter=FindTimerFilter.ONLY_ACTIVE
987  )
988  timer_manager.pause_timer(timer.id)
989  return intent_obj.create_response()
990 
991 
992 class UnpauseTimerIntentHandler(intent.IntentHandler):
993  """Intent handler for unpausing a paused timer."""
994 
995  intent_type = intent.INTENT_UNPAUSE_TIMER
996  description = "Resumes a paused timer"
997  slot_schema = {
998  vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int,
999  vol.Optional("name"): cv.string,
1000  vol.Optional("area"): cv.string,
1001  }
1002 
1003  async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
1004  """Handle the intent."""
1005  hass = intent_obj.hass
1006  timer_manager: TimerManager = hass.data[TIMER_DATA]
1007  slots = self.async_validate_slots(intent_obj.slots)
1008 
1009  timer = _find_timer(
1010  hass, intent_obj.device_id, slots, find_filter=FindTimerFilter.ONLY_INACTIVE
1011  )
1012  timer_manager.unpause_timer(timer.id)
1013  return intent_obj.create_response()
1014 
1015 
1016 class TimerStatusIntentHandler(intent.IntentHandler):
1017  """Intent handler for reporting the status of a timer."""
1018 
1019  intent_type = intent.INTENT_TIMER_STATUS
1020  description = "Reports the current status of timers"
1021  slot_schema = {
1022  vol.Any("start_hours", "start_minutes", "start_seconds"): cv.positive_int,
1023  vol.Optional("name"): cv.string,
1024  vol.Optional("area"): cv.string,
1025  }
1026 
1027  async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
1028  """Handle the intent."""
1029  hass = intent_obj.hass
1030  slots = self.async_validate_slots(intent_obj.slots)
1031 
1032  statuses: list[dict[str, Any]] = []
1033  for timer in _find_timers(hass, intent_obj.device_id, slots):
1034  total_seconds = timer.seconds_left
1035 
1036  minutes, seconds = divmod(total_seconds, 60)
1037  hours, minutes = divmod(minutes, 60)
1038 
1039  # Get lower-precision time for feedback
1040  rounded_hours, rounded_minutes, rounded_seconds = _round_time(
1041  hours, minutes, seconds
1042  )
1043 
1044  statuses.append(
1045  {
1046  ATTR_ID: timer.id,
1047  ATTR_NAME: timer.name or "",
1048  ATTR_DEVICE_ID: timer.device_id or "",
1049  "language": timer.language,
1050  "start_hours": timer.start_hours or 0,
1051  "start_minutes": timer.start_minutes or 0,
1052  "start_seconds": timer.start_seconds or 0,
1053  "is_active": timer.is_active,
1054  "hours_left": hours,
1055  "minutes_left": minutes,
1056  "seconds_left": seconds,
1057  "rounded_hours_left": rounded_hours,
1058  "rounded_minutes_left": rounded_minutes,
1059  "rounded_seconds_left": rounded_seconds,
1060  "total_seconds_left": total_seconds,
1061  }
1062  )
1063 
1064  response = intent_obj.create_response()
1065  response.async_set_speech_slots({"timers": statuses})
1066 
1067  return response
intent.IntentResponse async_handle(self, intent.Intent intent_obj)
Definition: timers.py:899
intent.IntentResponse async_handle(self, intent.Intent intent_obj)
Definition: timers.py:879
intent.IntentResponse async_handle(self, intent.Intent intent_obj)
Definition: timers.py:956
intent.IntentResponse async_handle(self, intent.Intent intent_obj)
Definition: timers.py:932
intent.IntentResponse async_handle(self, intent.Intent intent_obj)
Definition: timers.py:979
intent.IntentResponse async_handle(self, intent.Intent intent_obj)
Definition: timers.py:819
str start_timer(self, str|None device_id, int|None hours, int|None minutes, int|None seconds, str language, str|None name=None, str|None conversation_command=None, str|None conversation_agent_id=None)
Definition: timers.py:244
None remove_time(self, str timer_id, int seconds)
Definition: timers.py:386
Callable[[], None] register_handler(self, str device_id, TimerHandler handler)
Definition: timers.py:222
None add_time(self, str timer_id, int seconds)
Definition: timers.py:347
None _wait_for_timer(self, str timer_id, int seconds, int updated_at)
Definition: timers.py:314
None __init__(self, HomeAssistant hass)
Definition: timers.py:209
intent.IntentResponse async_handle(self, intent.Intent intent_obj)
Definition: timers.py:1027
None __init__(self, str|None device_id=None)
Definition: timers.py:198
intent.IntentResponse async_handle(self, intent.Intent intent_obj)
Definition: timers.py:1003
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
ConversationResult async_converse(HomeAssistant hass, str text, str|None conversation_id, Context context, str|None language=None, str|None agent_id=None, str|None device_id=None)
TimerInfo _find_timer(HomeAssistant hass, str|None device_id, dict[str, Any] slots, FindTimerFilter|None find_filter=None)
Definition: timers.py:513
int _get_total_seconds(dict[str, Any] slots)
Definition: timers.py:744
tuple[int, int, int] _round_time(int hours, int minutes, int seconds)
Definition: timers.py:759
list[TimerInfo] _find_timers(HomeAssistant hass, str|None device_id, dict[str, Any] slots)
Definition: timers.py:647
bool async_device_supports_timers(HomeAssistant hass, str device_id)
Definition: timers.py:478
Callable[[], None] async_register_timer_handler(HomeAssistant hass, str device_id, TimerHandler handler)
Definition: timers.py:489