22
33from __future__ import annotations
44
5+ import datetime
6+ import logging
57from typing import Any
68
7- from ..entity import Entity
9+ from ..entity import Entity , ValueChangeHandler
10+
11+ _LOGGER = logging .getLogger (__name__ )
812
913
1014class Timer (Entity ):
@@ -13,10 +17,25 @@ class Timer(Entity):
1317 Timer states: ``idle``, ``active``, ``paused``.
1418 Actions use intent-specific names: ``start``, ``pause``, ``cancel``,
1519 ``finish``, ``change``.
20+
21+ In addition to the generic ``on_idle`` listener (which fires for both
22+ natural expiry and explicit cancellation), the timer provides
23+ ``on_finished`` and ``on_cancelled`` listeners that fire only for the
24+ corresponding reason. These are driven by Home Assistant's dedicated
25+ ``timer.finished`` and ``timer.cancelled`` event types.
26+
27+ The ``time_remaining`` property computes the live seconds remaining
28+ from ``finishes_at`` when the timer is active, or parses the
29+ ``remaining`` attribute when paused.
1630 """
1731
1832 domain = "timer"
1933
34+ def __init__ (self , entity_id : str , client : Any ) -> None :
35+ super ().__init__ (entity_id , client )
36+ self ._finished_listeners : list [ValueChangeHandler ] = []
37+ self ._cancelled_listeners : list [ValueChangeHandler ] = []
38+
2039 # -- State properties --
2140
2241 @property
@@ -88,6 +107,40 @@ def finishes_at(self) -> str | None:
88107 val = self .attributes .get ("finishes_at" )
89108 return str (val ) if val is not None else None
90109
110+ @property
111+ def time_remaining (self ) -> float | None :
112+ """Compute live seconds remaining on the timer.
113+
114+ When the timer is **active**, this calculates the difference between
115+ ``finishes_at`` and the current UTC time. When **paused**, it parses
116+ the ``remaining`` attribute. Returns ``None`` when idle or when the
117+ required attributes are missing.
118+
119+ Returns
120+ -------
121+ float or None
122+ Seconds remaining (clamped to ``>= 0``), or ``None`` if not
123+ applicable.
124+ """
125+ if self .state == "active" :
126+ raw = self .attributes .get ("finishes_at" )
127+ if raw is None :
128+ return None
129+ try :
130+ finish_dt = datetime .datetime .fromisoformat (str (raw ))
131+ now = datetime .datetime .now (datetime .UTC )
132+ delta = (finish_dt - now ).total_seconds ()
133+ return max (delta , 0.0 )
134+ except (ValueError , TypeError ):
135+ _LOGGER .debug ("Could not parse finishes_at: %r" , raw )
136+ return None
137+ if self .state == "paused" :
138+ raw = self .attributes .get ("remaining" )
139+ if raw is None :
140+ return None
141+ return _parse_duration_to_seconds (str (raw ))
142+ return None
143+
91144 # -- Actions --
92145
93146 async def start (self , * , duration : str | None = None ) -> None :
@@ -169,3 +222,90 @@ def on_idle(self, func: Any) -> Any:
169222 The same *func*, for use as a decorator.
170223 """
171224 return self ._register_state_transition_listener ("idle" , func )
225+
226+ def on_finished (self , func : Any ) -> Any :
227+ """Register a listener for when the timer finishes naturally.
228+
229+ Unlike ``on_idle``, this fires **only** when the timer expires or
230+ is finished explicitly -- not when it is cancelled. Driven by the
231+ Home Assistant ``timer.finished`` event.
232+
233+ Parameters
234+ ----------
235+ func : callable
236+ Callback with signature ``(entity_id: str, event_data: dict)``.
237+
238+ Returns
239+ -------
240+ callable
241+ The same *func*, for use as a decorator.
242+ """
243+ self ._finished_listeners .append (func )
244+ return func
245+
246+ def on_cancelled (self , func : Any ) -> Any :
247+ """Register a listener for when the timer is cancelled.
248+
249+ Unlike ``on_idle``, this fires **only** on cancellation -- not on
250+ natural expiry. Driven by the Home Assistant ``timer.cancelled``
251+ event.
252+
253+ Parameters
254+ ----------
255+ func : callable
256+ Callback with signature ``(entity_id: str, event_data: dict)``.
257+
258+ Returns
259+ -------
260+ callable
261+ The same *func*, for use as a decorator.
262+ """
263+ self ._cancelled_listeners .append (func )
264+ return func
265+
266+ def _handle_timer_event (self , event_type : str , data : dict [str , Any ]) -> None :
267+ """Dispatch a ``timer.finished`` or ``timer.cancelled`` event.
268+
269+ Called by `HAClient` when a matching timer event arrives for this
270+ entity.
271+
272+ Parameters
273+ ----------
274+ event_type : str
275+ Either ``"timer.finished"`` or ``"timer.cancelled"``.
276+ data : dict
277+ The event data payload from Home Assistant.
278+ """
279+ if event_type == "timer.finished" :
280+ listeners = self ._finished_listeners
281+ elif event_type == "timer.cancelled" :
282+ listeners = self ._cancelled_listeners
283+ else :
284+ return
285+ for listener in list (listeners ):
286+ self ._schedule_value (listener , self .entity_id , data )
287+
288+
289+ def _parse_duration_to_seconds (value : str ) -> float | None :
290+ """Parse a Home Assistant duration string to total seconds.
291+
292+ Supports formats like ``"0:05:00"`` and ``"00:05:00"``.
293+
294+ Parameters
295+ ----------
296+ value : str
297+ Duration string in ``H:MM:SS`` or ``HH:MM:SS`` format.
298+
299+ Returns
300+ -------
301+ float or None
302+ Total seconds, or ``None`` if parsing fails.
303+ """
304+ parts = value .split (":" )
305+ if len (parts ) != 3 : # noqa: PLR2004
306+ return None
307+ try :
308+ hours , minutes , seconds = int (parts [0 ]), int (parts [1 ]), float (parts [2 ])
309+ except (ValueError , TypeError ):
310+ return None
311+ return hours * 3600.0 + minutes * 60.0 + seconds
0 commit comments