From 9f3071780609801b0921e60a1d7f58f08df185cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A6rkeren?= <164513459+Faerkeren@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:16:29 -0100 Subject: [PATCH] feat(timer): add on_finished/on_cancelled listeners and time_remaining property Distinguish natural timer expiry from cancellation using HA's dedicated timer.finished and timer.cancelled event types. Add computed time_remaining property that calculates live seconds from finishes_at (active) or parses the remaining attribute (paused). --- src/haclient/client.py | 28 +++++++ src/haclient/domains/timer.py | 142 +++++++++++++++++++++++++++++++++- tests/test_domains.py | 91 ++++++++++++++++++++++ tests/test_granular_events.py | 73 +++++++++++++++++ 4 files changed, 333 insertions(+), 1 deletion(-) diff --git a/src/haclient/client.py b/src/haclient/client.py index 6adbe65..3ef7f51 100644 --- a/src/haclient/client.py +++ b/src/haclient/client.py @@ -115,6 +115,8 @@ def __init__( verify_ssl=verify_ssl, ) self._state_sub_id: int | None = None + self._timer_finished_sub_id: int | None = None + self._timer_cancelled_sub_id: int | None = None self._connected = False async def __aenter__(self) -> HAClient: @@ -157,6 +159,12 @@ async def connect(self) -> None: self._state_sub_id = await self.ws.subscribe_events( self._on_state_changed_event, "state_changed" ) + self._timer_finished_sub_id = await self.ws.subscribe_events( + self._on_timer_event, "timer.finished" + ) + self._timer_cancelled_sub_id = await self.ws.subscribe_events( + self._on_timer_event, "timer.cancelled" + ) self._connected = True async def close(self) -> None: @@ -184,6 +192,26 @@ def _on_state_changed_event(self, event: dict[str, Any]) -> None: data.get("old_state"), data.get("new_state") ) + def _on_timer_event(self, event: dict[str, Any]) -> None: + """Dispatch a ``timer.finished`` or ``timer.cancelled`` event. + + Parameters + ---------- + event : dict + The raw event payload from the WebSocket. + """ + from .domains.timer import Timer as _Timer + + event_type = event.get("event_type", "") + data = event.get("data") or {} + eid = data.get("entity_id") + if not isinstance(eid, str): + return + entity = self.registry.get(eid) + if entity is None or not isinstance(entity, _Timer): + return + entity._handle_timer_event(event_type, data) # noqa: SLF001 + async def _call_service( self, domain: str, diff --git a/src/haclient/domains/timer.py b/src/haclient/domains/timer.py index 1e8b8ba..5a2b211 100644 --- a/src/haclient/domains/timer.py +++ b/src/haclient/domains/timer.py @@ -2,9 +2,13 @@ from __future__ import annotations +import datetime +import logging from typing import Any -from ..entity import Entity +from ..entity import Entity, ValueChangeHandler + +_LOGGER = logging.getLogger(__name__) class Timer(Entity): @@ -13,10 +17,25 @@ class Timer(Entity): Timer states: ``idle``, ``active``, ``paused``. Actions use intent-specific names: ``start``, ``pause``, ``cancel``, ``finish``, ``change``. + + In addition to the generic ``on_idle`` listener (which fires for both + natural expiry and explicit cancellation), the timer provides + ``on_finished`` and ``on_cancelled`` listeners that fire only for the + corresponding reason. These are driven by Home Assistant's dedicated + ``timer.finished`` and ``timer.cancelled`` event types. + + The ``time_remaining`` property computes the live seconds remaining + from ``finishes_at`` when the timer is active, or parses the + ``remaining`` attribute when paused. """ domain = "timer" + def __init__(self, entity_id: str, client: Any) -> None: + super().__init__(entity_id, client) + self._finished_listeners: list[ValueChangeHandler] = [] + self._cancelled_listeners: list[ValueChangeHandler] = [] + # -- State properties -- @property @@ -88,6 +107,40 @@ def finishes_at(self) -> str | None: val = self.attributes.get("finishes_at") return str(val) if val is not None else None + @property + def time_remaining(self) -> float | None: + """Compute live seconds remaining on the timer. + + When the timer is **active**, this calculates the difference between + ``finishes_at`` and the current UTC time. When **paused**, it parses + the ``remaining`` attribute. Returns ``None`` when idle or when the + required attributes are missing. + + Returns + ------- + float or None + Seconds remaining (clamped to ``>= 0``), or ``None`` if not + applicable. + """ + if self.state == "active": + raw = self.attributes.get("finishes_at") + if raw is None: + return None + try: + finish_dt = datetime.datetime.fromisoformat(str(raw)) + now = datetime.datetime.now(datetime.UTC) + delta = (finish_dt - now).total_seconds() + return max(delta, 0.0) + except (ValueError, TypeError): + _LOGGER.debug("Could not parse finishes_at: %r", raw) + return None + if self.state == "paused": + raw = self.attributes.get("remaining") + if raw is None: + return None + return _parse_duration_to_seconds(str(raw)) + return None + # -- Actions -- async def start(self, *, duration: str | None = None) -> None: @@ -169,3 +222,90 @@ def on_idle(self, func: Any) -> Any: The same *func*, for use as a decorator. """ return self._register_state_transition_listener("idle", func) + + def on_finished(self, func: Any) -> Any: + """Register a listener for when the timer finishes naturally. + + Unlike ``on_idle``, this fires **only** when the timer expires or + is finished explicitly -- not when it is cancelled. Driven by the + Home Assistant ``timer.finished`` event. + + Parameters + ---------- + func : callable + Callback with signature ``(entity_id: str, event_data: dict)``. + + Returns + ------- + callable + The same *func*, for use as a decorator. + """ + self._finished_listeners.append(func) + return func + + def on_cancelled(self, func: Any) -> Any: + """Register a listener for when the timer is cancelled. + + Unlike ``on_idle``, this fires **only** on cancellation -- not on + natural expiry. Driven by the Home Assistant ``timer.cancelled`` + event. + + Parameters + ---------- + func : callable + Callback with signature ``(entity_id: str, event_data: dict)``. + + Returns + ------- + callable + The same *func*, for use as a decorator. + """ + self._cancelled_listeners.append(func) + return func + + def _handle_timer_event(self, event_type: str, data: dict[str, Any]) -> None: + """Dispatch a ``timer.finished`` or ``timer.cancelled`` event. + + Called by `HAClient` when a matching timer event arrives for this + entity. + + Parameters + ---------- + event_type : str + Either ``"timer.finished"`` or ``"timer.cancelled"``. + data : dict + The event data payload from Home Assistant. + """ + if event_type == "timer.finished": + listeners = self._finished_listeners + elif event_type == "timer.cancelled": + listeners = self._cancelled_listeners + else: + return + for listener in list(listeners): + self._schedule_value(listener, self.entity_id, data) + + +def _parse_duration_to_seconds(value: str) -> float | None: + """Parse a Home Assistant duration string to total seconds. + + Supports formats like ``"0:05:00"`` and ``"00:05:00"``. + + Parameters + ---------- + value : str + Duration string in ``H:MM:SS`` or ``HH:MM:SS`` format. + + Returns + ------- + float or None + Total seconds, or ``None`` if parsing fails. + """ + parts = value.split(":") + if len(parts) != 3: # noqa: PLR2004 + return None + try: + hours, minutes, seconds = int(parts[0]), int(parts[1]), float(parts[2]) + except (ValueError, TypeError): + return None + return hours * 3600.0 + minutes * 60.0 + seconds diff --git a/tests/test_domains.py b/tests/test_domains.py index 245f909..ac40b1f 100644 --- a/tests/test_domains.py +++ b/tests/test_domains.py @@ -468,3 +468,94 @@ def _listener(old: Any, new: Any) -> None: await asyncio.sleep(0.05) assert len(fired) == 1 assert fired[0][1] == "2024-06-15T20:30:00+00:00" + + +async def test_timer_time_remaining_active() -> None: + """``time_remaining`` computes live seconds from ``finishes_at`` when active.""" + import datetime + + ha = HAClient("http://x", "t") + try: + t = ha.timer("remaining_timer") + # Set finishes_at to 120 seconds from now + finish = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=120) + t._apply_state( + { + "state": "active", + "attributes": { + "duration": "0:02:00", + "remaining": "0:02:00", + "finishes_at": finish.isoformat(), + }, + } + ) + rem = t.time_remaining + assert rem is not None + # Should be close to 120s (allow some tolerance for test execution) + assert 118.0 <= rem <= 121.0 + finally: + await ha.close() + + +async def test_timer_time_remaining_paused() -> None: + """``time_remaining`` parses remaining attribute when paused.""" + ha = HAClient("http://x", "t") + try: + t = ha.timer("paused_timer") + t._apply_state( + { + "state": "paused", + "attributes": {"duration": "0:05:00", "remaining": "0:03:30"}, + } + ) + rem = t.time_remaining + assert rem is not None + assert rem == 210.0 # 3 min 30 sec + finally: + await ha.close() + + +async def test_timer_time_remaining_idle() -> None: + """``time_remaining`` returns ``None`` when idle.""" + ha = HAClient("http://x", "t") + try: + t = ha.timer("idle_timer") + t._apply_state({"state": "idle", "attributes": {"duration": "0:05:00"}}) + assert t.time_remaining is None + finally: + await ha.close() + + +async def test_timer_time_remaining_missing_attrs() -> None: + """``time_remaining`` returns ``None`` when attributes are missing.""" + ha = HAClient("http://x", "t") + try: + t = ha.timer("no_attrs_timer") + t._apply_state({"state": "active", "attributes": {}}) + assert t.time_remaining is None + t._apply_state({"state": "paused", "attributes": {}}) + assert t.time_remaining is None + finally: + await ha.close() + + +async def test_timer_time_remaining_bad_finishes_at() -> None: + """``time_remaining`` returns ``None`` for unparseable ``finishes_at``.""" + ha = HAClient("http://x", "t") + try: + t = ha.timer("bad_timer") + t._apply_state({"state": "active", "attributes": {"finishes_at": "not-a-date"}}) + assert t.time_remaining is None + finally: + await ha.close() + + +async def test_timer_time_remaining_bad_remaining() -> None: + """``time_remaining`` returns ``None`` for unparseable ``remaining``.""" + ha = HAClient("http://x", "t") + try: + t = ha.timer("bad_rem_timer") + t._apply_state({"state": "paused", "attributes": {"remaining": "bad"}}) + assert t.time_remaining is None + finally: + await ha.close() diff --git a/tests/test_granular_events.py b/tests/test_granular_events.py index 1cbceeb..8f86153 100644 --- a/tests/test_granular_events.py +++ b/tests/test_granular_events.py @@ -766,3 +766,76 @@ def handler(old: Any, new: Any) -> None: ) await asyncio.sleep(0.05) assert captured == [("active", "idle")] + + +async def test_timer_on_finished(client: HAClient, fake_ha: FakeHA) -> None: + t = client.timer("my_timer") + captured: list[tuple[Any, Any]] = [] + + @t.on_finished + def handler(entity_id: Any, data: Any) -> None: + captured.append((entity_id, data)) + + await fake_ha.push_event( + "timer.finished", + {"data": {"entity_id": "timer.my_timer"}}, + ) + await asyncio.sleep(0.05) + assert len(captured) == 1 + assert captured[0][0] == "timer.my_timer" + + +async def test_timer_on_cancelled(client: HAClient, fake_ha: FakeHA) -> None: + t = client.timer("my_timer") + captured: list[tuple[Any, Any]] = [] + + @t.on_cancelled + def handler(entity_id: Any, data: Any) -> None: + captured.append((entity_id, data)) + + await fake_ha.push_event( + "timer.cancelled", + {"data": {"entity_id": "timer.my_timer"}}, + ) + await asyncio.sleep(0.05) + assert len(captured) == 1 + assert captured[0][0] == "timer.my_timer" + + +async def test_timer_on_finished_ignores_other_entities(client: HAClient, fake_ha: FakeHA) -> None: + t = client.timer("my_timer") + captured: list[tuple[Any, Any]] = [] + + @t.on_finished + def handler(entity_id: Any, data: Any) -> None: + captured.append((entity_id, data)) + + # Fire event for a different timer entity + await fake_ha.push_event( + "timer.finished", + {"data": {"entity_id": "timer.other_timer"}}, + ) + await asyncio.sleep(0.05) + assert captured == [] + + +async def test_timer_on_finished_does_not_fire_on_cancel(client: HAClient, fake_ha: FakeHA) -> None: + t = client.timer("my_timer") + finished: list[tuple[Any, Any]] = [] + cancelled: list[tuple[Any, Any]] = [] + + @t.on_finished + def on_fin(entity_id: Any, data: Any) -> None: + finished.append((entity_id, data)) + + @t.on_cancelled + def on_can(entity_id: Any, data: Any) -> None: + cancelled.append((entity_id, data)) + + await fake_ha.push_event( + "timer.cancelled", + {"data": {"entity_id": "timer.my_timer"}}, + ) + await asyncio.sleep(0.05) + assert finished == [] + assert len(cancelled) == 1