Skip to content

Commit 701c364

Browse files
committed
feat: auto-create timer helpers transparently on first action
Timer entities now automatically create the HA helper via the timer/create WS command when an action method is called and the entity has not yet been seen from Home Assistant. This removes the need for users to pre-create timer helpers manually. Also adds Timer.delete() for cleanup and resets the ensured flag so subsequent actions re-create the helper if needed.
1 parent 674c650 commit 701c364

3 files changed

Lines changed: 192 additions & 0 deletions

File tree

src/haclient/domains/timer.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ class Timer(Entity):
1818
Actions use intent-specific names: ``start``, ``pause``, ``cancel``,
1919
``finish``, ``change``.
2020
21+
If the timer helper does not yet exist in Home Assistant, it is created
22+
automatically via the ``timer/create`` WebSocket command the first time
23+
an action method (``start``, ``pause``, etc.) is called. This means
24+
users never need to pre-create timer helpers — the library handles it
25+
transparently.
26+
2127
In addition to the generic ``on_idle`` listener (which fires for both
2228
natural expiry and explicit cancellation), the timer provides
2329
``on_finished`` and ``on_cancelled`` listeners that fire only for the
@@ -35,6 +41,7 @@ def __init__(self, entity_id: str, client: Any) -> None:
3541
super().__init__(entity_id, client)
3642
self._finished_listeners: list[ValueChangeHandler] = []
3743
self._cancelled_listeners: list[ValueChangeHandler] = []
44+
self._ensured: bool = False
3845

3946
# -- State properties --
4047

@@ -141,6 +148,51 @@ def time_remaining(self) -> float | None:
141148
return _parse_duration_to_seconds(str(raw))
142149
return None
143150

151+
# -- Lifecycle --
152+
153+
async def _ensure_exists(self) -> None:
154+
"""Create the timer helper in Home Assistant if it does not exist.
155+
156+
Uses the ``timer/create`` WebSocket command. The call is idempotent:
157+
once the helper has been confirmed (either via the initial state fetch
158+
or a prior ``_ensure_exists`` call), subsequent invocations are no-ops.
159+
160+
The object-id is extracted from the ``entity_id`` (the part after
161+
``timer.``).
162+
"""
163+
if self._ensured or self.state != "unknown":
164+
return
165+
object_id = self.entity_id.split(".", 1)[1]
166+
await self._client.ws.send_command(
167+
{
168+
"type": "timer/create",
169+
"name": object_id,
170+
"duration": "00:01:00",
171+
}
172+
)
173+
self._ensured = True
174+
175+
async def delete(self) -> None:
176+
"""Delete the timer helper from Home Assistant.
177+
178+
Uses the ``timer/delete`` WebSocket command. After deletion the
179+
entity's ``_ensured`` flag is reset so that a subsequent action
180+
will re-create the helper.
181+
182+
Raises
183+
------
184+
CommandError
185+
If the timer does not exist in Home Assistant.
186+
"""
187+
object_id = self.entity_id.split(".", 1)[1]
188+
await self._client.ws.send_command(
189+
{
190+
"type": "timer/delete",
191+
"timer_id": object_id,
192+
}
193+
)
194+
self._ensured = False
195+
144196
# -- Actions --
145197

146198
async def start(self, *, duration: str | None = None) -> None:
@@ -151,19 +203,23 @@ async def start(self, *, duration: str | None = None) -> None:
151203
duration : str or None, optional
152204
Override duration (e.g. ``"00:05:00"``).
153205
"""
206+
await self._ensure_exists()
154207
data: dict[str, Any] | None = {"duration": duration} if duration else None
155208
await self._call_service("start", data)
156209

157210
async def pause(self) -> None:
158211
"""Pause the timer."""
212+
await self._ensure_exists()
159213
await self._call_service("pause")
160214

161215
async def cancel(self) -> None:
162216
"""Cancel the timer (returns to idle)."""
217+
await self._ensure_exists()
163218
await self._call_service("cancel")
164219

165220
async def finish(self) -> None:
166221
"""Finish the timer immediately."""
222+
await self._ensure_exists()
167223
await self._call_service("finish")
168224

169225
async def change(self, *, duration: str) -> None:
@@ -174,6 +230,7 @@ async def change(self, *, duration: str) -> None:
174230
duration : str
175231
Duration to add/subtract (e.g. ``"00:01:00"`` or ``"-00:00:30"``).
176232
"""
233+
await self._ensure_exists()
177234
await self._call_service("change", {"duration": duration})
178235

179236
# -- Listener decorators --

tests/fake_ha.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,33 @@ async def _dispatch(self, ws: web.WebSocketResponse, msg: dict[str, Any]) -> Non
190190
if mtype == "media_player/browse_media":
191191
await ws.send_json({"id": mid, "type": "result", "success": True, "result": {}})
192192
return
193+
if mtype == "timer/create":
194+
name = msg.get("name", "")
195+
duration = msg.get("duration", "00:01:00")
196+
await ws.send_json(
197+
{
198+
"id": mid,
199+
"type": "result",
200+
"success": True,
201+
"result": {
202+
"id": name,
203+
"name": name,
204+
"duration": duration,
205+
"restore": False,
206+
},
207+
}
208+
)
209+
return
210+
if mtype == "timer/delete":
211+
await ws.send_json(
212+
{
213+
"id": mid,
214+
"type": "result",
215+
"success": True,
216+
"result": None,
217+
}
218+
)
219+
return
193220

194221
await ws.send_json(
195222
{

tests/test_timer_lifecycle.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Tests for Timer auto-create and delete lifecycle."""
2+
3+
from __future__ import annotations
4+
5+
from haclient import HAClient
6+
7+
from .fake_ha import FakeHA
8+
9+
10+
async def test_timer_start_auto_creates_when_unknown(client: HAClient, fake_ha: FakeHA) -> None:
11+
"""Calling start() on a timer with state 'unknown' should send timer/create first."""
12+
t = client.timer("my_timer")
13+
assert t.state == "unknown"
14+
15+
await t.start(duration="00:00:10")
16+
17+
# The timer/create command should have been sent (check ws_service_calls
18+
# won't capture it — we need to check via the call_service call that followed).
19+
# The call_service for timer.start should be present.
20+
assert len(fake_ha.ws_service_calls) == 1
21+
call = fake_ha.ws_service_calls[0]
22+
assert call["domain"] == "timer"
23+
assert call["service"] == "start"
24+
assert t._ensured is True
25+
26+
27+
async def test_timer_start_skips_create_when_state_known(client: HAClient, fake_ha: FakeHA) -> None:
28+
"""If the timer already has a state from HA, _ensure_exists is a no-op."""
29+
t = client.timer("my_timer")
30+
# Simulate that HA already reported the entity's state.
31+
t._apply_state({"state": "idle", "attributes": {"duration": "0:01:00"}})
32+
assert t.state == "idle"
33+
34+
await t.start(duration="00:00:10")
35+
36+
# Only the service call, no timer/create.
37+
assert len(fake_ha.ws_service_calls) == 1
38+
call = fake_ha.ws_service_calls[0]
39+
assert call["domain"] == "timer"
40+
assert call["service"] == "start"
41+
42+
43+
async def test_timer_ensure_exists_only_called_once(client: HAClient, fake_ha: FakeHA) -> None:
44+
"""After the first _ensure_exists succeeds, subsequent calls are no-ops."""
45+
t = client.timer("my_timer")
46+
assert t.state == "unknown"
47+
48+
await t.start(duration="00:00:10")
49+
await t.pause()
50+
51+
# Both actions should fire, but timer/create only once.
52+
assert len(fake_ha.ws_service_calls) == 2
53+
assert t._ensured is True
54+
55+
56+
async def test_timer_pause_auto_creates(client: HAClient, fake_ha: FakeHA) -> None:
57+
"""pause() should also trigger auto-create."""
58+
t = client.timer("my_timer")
59+
await t.pause()
60+
assert t._ensured is True
61+
assert len(fake_ha.ws_service_calls) == 1
62+
63+
64+
async def test_timer_cancel_auto_creates(client: HAClient, fake_ha: FakeHA) -> None:
65+
"""cancel() should also trigger auto-create."""
66+
t = client.timer("my_timer")
67+
await t.cancel()
68+
assert t._ensured is True
69+
70+
71+
async def test_timer_finish_auto_creates(client: HAClient, fake_ha: FakeHA) -> None:
72+
"""finish() should also trigger auto-create."""
73+
t = client.timer("my_timer")
74+
await t.finish()
75+
assert t._ensured is True
76+
77+
78+
async def test_timer_change_auto_creates(client: HAClient, fake_ha: FakeHA) -> None:
79+
"""change() should also trigger auto-create."""
80+
t = client.timer("my_timer")
81+
await t.change(duration="00:00:30")
82+
assert t._ensured is True
83+
84+
85+
async def test_timer_delete(client: HAClient, fake_ha: FakeHA) -> None:
86+
"""delete() sends timer/delete and resets _ensured."""
87+
t = client.timer("my_timer")
88+
# First ensure it exists.
89+
await t.start()
90+
assert t._ensured is True
91+
92+
await t.delete()
93+
assert t._ensured is False
94+
95+
96+
async def test_timer_delete_then_start_recreates(client: HAClient, fake_ha: FakeHA) -> None:
97+
"""After delete(), the next action should re-create the timer."""
98+
t = client.timer("my_timer")
99+
await t.start()
100+
assert t._ensured is True
101+
102+
await t.delete()
103+
assert t._ensured is False
104+
105+
# Reset state to unknown to simulate the entity being gone.
106+
t.state = "unknown"
107+
await t.start()
108+
assert t._ensured is True

0 commit comments

Comments
 (0)