Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/haclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
142 changes: 141 additions & 1 deletion src/haclient/domains/timer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
91 changes: 91 additions & 0 deletions tests/test_domains.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
73 changes: 73 additions & 0 deletions tests/test_granular_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading