Skip to content

Commit 674c650

Browse files
authored
Merge pull request #40 from graphras-com/feat/timer-enhancements
feat(timer): add on_finished/on_cancelled listeners and time_remaining property
2 parents 2805e1e + 9f30717 commit 674c650

4 files changed

Lines changed: 333 additions & 1 deletion

File tree

src/haclient/client.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ def __init__(
115115
verify_ssl=verify_ssl,
116116
)
117117
self._state_sub_id: int | None = None
118+
self._timer_finished_sub_id: int | None = None
119+
self._timer_cancelled_sub_id: int | None = None
118120
self._connected = False
119121

120122
async def __aenter__(self) -> HAClient:
@@ -157,6 +159,12 @@ async def connect(self) -> None:
157159
self._state_sub_id = await self.ws.subscribe_events(
158160
self._on_state_changed_event, "state_changed"
159161
)
162+
self._timer_finished_sub_id = await self.ws.subscribe_events(
163+
self._on_timer_event, "timer.finished"
164+
)
165+
self._timer_cancelled_sub_id = await self.ws.subscribe_events(
166+
self._on_timer_event, "timer.cancelled"
167+
)
160168
self._connected = True
161169

162170
async def close(self) -> None:
@@ -184,6 +192,26 @@ def _on_state_changed_event(self, event: dict[str, Any]) -> None:
184192
data.get("old_state"), data.get("new_state")
185193
)
186194

195+
def _on_timer_event(self, event: dict[str, Any]) -> None:
196+
"""Dispatch a ``timer.finished`` or ``timer.cancelled`` event.
197+
198+
Parameters
199+
----------
200+
event : dict
201+
The raw event payload from the WebSocket.
202+
"""
203+
from .domains.timer import Timer as _Timer
204+
205+
event_type = event.get("event_type", "")
206+
data = event.get("data") or {}
207+
eid = data.get("entity_id")
208+
if not isinstance(eid, str):
209+
return
210+
entity = self.registry.get(eid)
211+
if entity is None or not isinstance(entity, _Timer):
212+
return
213+
entity._handle_timer_event(event_type, data) # noqa: SLF001
214+
187215
async def _call_service(
188216
self,
189217
domain: str,

src/haclient/domains/timer.py

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22

33
from __future__ import annotations
44

5+
import datetime
6+
import logging
57
from typing import Any
68

7-
from ..entity import Entity
9+
from ..entity import Entity, ValueChangeHandler
10+
11+
_LOGGER = logging.getLogger(__name__)
812

913

1014
class 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

tests/test_domains.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,3 +468,94 @@ def _listener(old: Any, new: Any) -> None:
468468
await asyncio.sleep(0.05)
469469
assert len(fired) == 1
470470
assert fired[0][1] == "2024-06-15T20:30:00+00:00"
471+
472+
473+
async def test_timer_time_remaining_active() -> None:
474+
"""``time_remaining`` computes live seconds from ``finishes_at`` when active."""
475+
import datetime
476+
477+
ha = HAClient("http://x", "t")
478+
try:
479+
t = ha.timer("remaining_timer")
480+
# Set finishes_at to 120 seconds from now
481+
finish = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=120)
482+
t._apply_state(
483+
{
484+
"state": "active",
485+
"attributes": {
486+
"duration": "0:02:00",
487+
"remaining": "0:02:00",
488+
"finishes_at": finish.isoformat(),
489+
},
490+
}
491+
)
492+
rem = t.time_remaining
493+
assert rem is not None
494+
# Should be close to 120s (allow some tolerance for test execution)
495+
assert 118.0 <= rem <= 121.0
496+
finally:
497+
await ha.close()
498+
499+
500+
async def test_timer_time_remaining_paused() -> None:
501+
"""``time_remaining`` parses remaining attribute when paused."""
502+
ha = HAClient("http://x", "t")
503+
try:
504+
t = ha.timer("paused_timer")
505+
t._apply_state(
506+
{
507+
"state": "paused",
508+
"attributes": {"duration": "0:05:00", "remaining": "0:03:30"},
509+
}
510+
)
511+
rem = t.time_remaining
512+
assert rem is not None
513+
assert rem == 210.0 # 3 min 30 sec
514+
finally:
515+
await ha.close()
516+
517+
518+
async def test_timer_time_remaining_idle() -> None:
519+
"""``time_remaining`` returns ``None`` when idle."""
520+
ha = HAClient("http://x", "t")
521+
try:
522+
t = ha.timer("idle_timer")
523+
t._apply_state({"state": "idle", "attributes": {"duration": "0:05:00"}})
524+
assert t.time_remaining is None
525+
finally:
526+
await ha.close()
527+
528+
529+
async def test_timer_time_remaining_missing_attrs() -> None:
530+
"""``time_remaining`` returns ``None`` when attributes are missing."""
531+
ha = HAClient("http://x", "t")
532+
try:
533+
t = ha.timer("no_attrs_timer")
534+
t._apply_state({"state": "active", "attributes": {}})
535+
assert t.time_remaining is None
536+
t._apply_state({"state": "paused", "attributes": {}})
537+
assert t.time_remaining is None
538+
finally:
539+
await ha.close()
540+
541+
542+
async def test_timer_time_remaining_bad_finishes_at() -> None:
543+
"""``time_remaining`` returns ``None`` for unparseable ``finishes_at``."""
544+
ha = HAClient("http://x", "t")
545+
try:
546+
t = ha.timer("bad_timer")
547+
t._apply_state({"state": "active", "attributes": {"finishes_at": "not-a-date"}})
548+
assert t.time_remaining is None
549+
finally:
550+
await ha.close()
551+
552+
553+
async def test_timer_time_remaining_bad_remaining() -> None:
554+
"""``time_remaining`` returns ``None`` for unparseable ``remaining``."""
555+
ha = HAClient("http://x", "t")
556+
try:
557+
t = ha.timer("bad_rem_timer")
558+
t._apply_state({"state": "paused", "attributes": {"remaining": "bad"}})
559+
assert t.time_remaining is None
560+
finally:
561+
await ha.close()

tests/test_granular_events.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,3 +766,76 @@ def handler(old: Any, new: Any) -> None:
766766
)
767767
await asyncio.sleep(0.05)
768768
assert captured == [("active", "idle")]
769+
770+
771+
async def test_timer_on_finished(client: HAClient, fake_ha: FakeHA) -> None:
772+
t = client.timer("my_timer")
773+
captured: list[tuple[Any, Any]] = []
774+
775+
@t.on_finished
776+
def handler(entity_id: Any, data: Any) -> None:
777+
captured.append((entity_id, data))
778+
779+
await fake_ha.push_event(
780+
"timer.finished",
781+
{"data": {"entity_id": "timer.my_timer"}},
782+
)
783+
await asyncio.sleep(0.05)
784+
assert len(captured) == 1
785+
assert captured[0][0] == "timer.my_timer"
786+
787+
788+
async def test_timer_on_cancelled(client: HAClient, fake_ha: FakeHA) -> None:
789+
t = client.timer("my_timer")
790+
captured: list[tuple[Any, Any]] = []
791+
792+
@t.on_cancelled
793+
def handler(entity_id: Any, data: Any) -> None:
794+
captured.append((entity_id, data))
795+
796+
await fake_ha.push_event(
797+
"timer.cancelled",
798+
{"data": {"entity_id": "timer.my_timer"}},
799+
)
800+
await asyncio.sleep(0.05)
801+
assert len(captured) == 1
802+
assert captured[0][0] == "timer.my_timer"
803+
804+
805+
async def test_timer_on_finished_ignores_other_entities(client: HAClient, fake_ha: FakeHA) -> None:
806+
t = client.timer("my_timer")
807+
captured: list[tuple[Any, Any]] = []
808+
809+
@t.on_finished
810+
def handler(entity_id: Any, data: Any) -> None:
811+
captured.append((entity_id, data))
812+
813+
# Fire event for a different timer entity
814+
await fake_ha.push_event(
815+
"timer.finished",
816+
{"data": {"entity_id": "timer.other_timer"}},
817+
)
818+
await asyncio.sleep(0.05)
819+
assert captured == []
820+
821+
822+
async def test_timer_on_finished_does_not_fire_on_cancel(client: HAClient, fake_ha: FakeHA) -> None:
823+
t = client.timer("my_timer")
824+
finished: list[tuple[Any, Any]] = []
825+
cancelled: list[tuple[Any, Any]] = []
826+
827+
@t.on_finished
828+
def on_fin(entity_id: Any, data: Any) -> None:
829+
finished.append((entity_id, data))
830+
831+
@t.on_cancelled
832+
def on_can(entity_id: Any, data: Any) -> None:
833+
cancelled.append((entity_id, data))
834+
835+
await fake_ha.push_event(
836+
"timer.cancelled",
837+
{"data": {"entity_id": "timer.my_timer"}},
838+
)
839+
await asyncio.sleep(0.05)
840+
assert finished == []
841+
assert len(cancelled) == 1

0 commit comments

Comments
 (0)