Skip to content

Commit 3f9084b

Browse files
authored
Merge pull request #27 from graphras-com/feat/timer-domain
feat: add Timer domain for Home Assistant timer entities
2 parents 6f110e2 + f2b0618 commit 3f9084b

4 files changed

Lines changed: 196 additions & 0 deletions

File tree

src/haclient/client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from .domains.media_player import MediaPlayer
4141
from .domains.sensor import Sensor
4242
from .domains.switch import Switch
43+
from .domains.timer import Timer
4344

4445
_E = TypeVar("_E", bound=Entity)
4546

@@ -381,3 +382,20 @@ def binary_sensor(self, name: str) -> BinarySensor:
381382
from .domains.binary_sensor import BinarySensor as _BinarySensor
382383

383384
return self._get_or_create("binary_sensor", name, _BinarySensor)
385+
386+
def timer(self, name: str) -> Timer:
387+
"""Return the `Timer` for *name*, creating it if needed.
388+
389+
Parameters
390+
----------
391+
name : str
392+
Short object-id or fully-qualified entity id.
393+
394+
Returns
395+
-------
396+
Timer
397+
The timer entity.
398+
"""
399+
from .domains.timer import Timer as _Timer
400+
401+
return self._get_or_create("timer", name, _Timer)

src/haclient/domains/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .media_player import FavoriteItem, MediaPlayer, NowPlaying
88
from .sensor import Sensor
99
from .switch import Switch
10+
from .timer import Timer
1011

1112
__all__ = [
1213
"BinarySensor",
@@ -18,4 +19,5 @@
1819
"NowPlaying",
1920
"Sensor",
2021
"Switch",
22+
"Timer",
2123
]

src/haclient/domains/timer.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""``timer`` domain implementation."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from ..entity import Entity
8+
9+
10+
class Timer(Entity):
11+
"""A Home Assistant timer entity.
12+
13+
Timer states: ``idle``, ``active``, ``paused``.
14+
"""
15+
16+
domain = "timer"
17+
18+
# -- State properties --
19+
20+
@property
21+
def is_active(self) -> bool:
22+
"""``True`` if the timer is currently running."""
23+
return self.state == "active"
24+
25+
@property
26+
def is_paused(self) -> bool:
27+
"""``True`` if the timer is paused."""
28+
return self.state == "paused"
29+
30+
@property
31+
def is_idle(self) -> bool:
32+
"""``True`` if the timer is idle (not started or finished)."""
33+
return self.state == "idle"
34+
35+
@property
36+
def duration(self) -> str | None:
37+
"""Configured duration (e.g. ``"0:05:00"``)."""
38+
val = self.attributes.get("duration")
39+
return str(val) if val is not None else None
40+
41+
@property
42+
def remaining(self) -> str | None:
43+
"""Time remaining (e.g. ``"0:04:30"``)."""
44+
val = self.attributes.get("remaining")
45+
return str(val) if val is not None else None
46+
47+
@property
48+
def finishes_at(self) -> str | None:
49+
"""ISO-8601 datetime when the timer will finish, if active."""
50+
val = self.attributes.get("finishes_at")
51+
return str(val) if val is not None else None
52+
53+
# -- Actions --
54+
55+
async def start(self, *, duration: str | None = None) -> None:
56+
"""Start (or restart) the timer.
57+
58+
Parameters
59+
----------
60+
duration : str or None, optional
61+
Override duration (e.g. ``"00:05:00"``).
62+
"""
63+
data: dict[str, Any] | None = {"duration": duration} if duration else None
64+
await self.call_service("start", data)
65+
66+
async def pause(self) -> None:
67+
"""Pause the timer."""
68+
await self.call_service("pause")
69+
70+
async def cancel(self) -> None:
71+
"""Cancel the timer (returns to idle)."""
72+
await self.call_service("cancel")
73+
74+
async def finish(self) -> None:
75+
"""Finish the timer immediately."""
76+
await self.call_service("finish")
77+
78+
async def change(self, *, duration: str) -> None:
79+
"""Add or subtract time from a running timer.
80+
81+
Parameters
82+
----------
83+
duration : str
84+
Duration to add/subtract (e.g. ``"00:01:00"`` or ``"-00:00:30"``).
85+
"""
86+
await self.call_service("change", {"duration": duration})
87+
88+
# -- Listener decorators --
89+
90+
def on_start(self, func: Any) -> Any:
91+
"""Register a listener for when the timer starts (becomes active).
92+
93+
Callback: ``(old_state, new_state)``.
94+
"""
95+
return self._register_state_transition_listener("active", func)
96+
97+
def on_pause(self, func: Any) -> Any:
98+
"""Register a listener for when the timer is paused.
99+
100+
Callback: ``(old_state, new_state)``.
101+
"""
102+
return self._register_state_transition_listener("paused", func)
103+
104+
def on_idle(self, func: Any) -> Any:
105+
"""Register a listener for when the timer becomes idle (finished or cancelled).
106+
107+
Callback: ``(old_state, new_state)``.
108+
"""
109+
return self._register_state_transition_listener("idle", func)

tests/test_domains.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,70 @@ async def test_entity_refresh_missing(client: HAClient) -> None:
251251
light = client.light("missing")
252252
await light.async_refresh()
253253
assert light.state == "unavailable"
254+
255+
256+
async def test_timer_actions(client: HAClient, fake_ha: FakeHA) -> None:
257+
t = client.timer("my_timer")
258+
await t.start()
259+
await t.start(duration="00:05:00")
260+
await t.pause()
261+
await t.cancel()
262+
await t.finish()
263+
await t.change(duration="00:01:00")
264+
assert [c["service"] for c in fake_ha.ws_service_calls] == [
265+
"start",
266+
"start",
267+
"pause",
268+
"cancel",
269+
"finish",
270+
"change",
271+
]
272+
# First start has no extra service_data beyond entity_id
273+
assert "duration" not in fake_ha.ws_service_calls[0].get("service_data", {})
274+
# Second start carries duration
275+
assert fake_ha.ws_service_calls[1]["service_data"]["duration"] == "00:05:00"
276+
# Change carries duration
277+
assert fake_ha.ws_service_calls[5]["service_data"]["duration"] == "00:01:00"
278+
279+
280+
async def test_timer_state_properties() -> None:
281+
ha = HAClient("http://x", "t")
282+
try:
283+
t = ha.timer("my_timer")
284+
t._apply_state(
285+
{
286+
"state": "active",
287+
"attributes": {
288+
"duration": "0:05:00",
289+
"remaining": "0:04:30",
290+
"finishes_at": "2024-01-01T12:05:00+00:00",
291+
},
292+
}
293+
)
294+
assert t.is_active
295+
assert not t.is_paused
296+
assert not t.is_idle
297+
assert t.duration == "0:05:00"
298+
assert t.remaining == "0:04:30"
299+
assert t.finishes_at == "2024-01-01T12:05:00+00:00"
300+
301+
t._apply_state(
302+
{
303+
"state": "paused",
304+
"attributes": {"duration": "0:05:00", "remaining": "0:03:00"},
305+
}
306+
)
307+
assert not t.is_active
308+
assert t.is_paused
309+
assert not t.is_idle
310+
assert t.remaining == "0:03:00"
311+
assert t.finishes_at is None
312+
313+
t._apply_state({"state": "idle", "attributes": {"duration": "0:05:00"}})
314+
assert not t.is_active
315+
assert not t.is_paused
316+
assert t.is_idle
317+
assert t.remaining is None
318+
assert t.finishes_at is None
319+
finally:
320+
await ha.close()

0 commit comments

Comments
 (0)