Skip to content

Commit 32199b5

Browse files
authored
Merge pull request #43 from graphras-com/feature/scene-domain-rewrite
feat: reimplement Scene domain with create, apply, and delete support
2 parents 077ca29 + e087e7a commit 32199b5

8 files changed

Lines changed: 247 additions & 1 deletion

File tree

docs/reference/domains/scene.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Scene
2+
3+
::: haclient.domains.scene

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ nav:
3535
- Sensor: reference/domains/sensor.md
3636
- Binary Sensor: reference/domains/binary_sensor.md
3737
- Media Player: reference/domains/media_player.md
38+
- Scene: reference/domains/scene.md
3839
- Timer: reference/domains/timer.md
3940

4041
plugins:

src/haclient/client.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,73 @@ def scene(self, name: str) -> Scene:
429429

430430
return self._get_or_create("scene", name, _Scene)
431431

432+
async def create_scene(
433+
self,
434+
scene_id: str,
435+
entities: dict[str, dict[str, Any]],
436+
*,
437+
snapshot_entities: list[str] | None = None,
438+
) -> Scene:
439+
"""Create a dynamic scene and return the `Scene` object.
440+
441+
This calls the ``scene.create`` service, which creates (or updates)
442+
a scene at runtime. The resulting scene can later be deleted with
443+
`Scene.delete`.
444+
445+
Parameters
446+
----------
447+
scene_id : str
448+
The object-id for the new scene (e.g. ``"romantic"`` becomes
449+
``scene.romantic``).
450+
entities : dict[str, dict[str, Any]]
451+
Mapping of entity IDs to the state/attribute dicts that the
452+
scene should apply. For example::
453+
454+
{"light.ceiling": {"state": "on", "brightness": 120}}
455+
snapshot_entities : list of str or None, optional
456+
Entity IDs whose **current** state should be captured into
457+
the scene instead of using explicit values.
458+
459+
Returns
460+
-------
461+
Scene
462+
The newly created (or updated) `Scene` instance.
463+
"""
464+
from .domains.scene import Scene as _Scene
465+
466+
data: dict[str, Any] = {
467+
"scene_id": scene_id,
468+
"entities": entities,
469+
}
470+
if snapshot_entities is not None:
471+
data["snapshot_entities"] = snapshot_entities
472+
473+
await self._call_service("scene", "create", data)
474+
return self._get_or_create("scene", scene_id, _Scene)
475+
476+
async def apply_scene(
477+
self,
478+
entities: dict[str, dict[str, Any]],
479+
*,
480+
transition: float | None = None,
481+
) -> None:
482+
"""Apply entity states without creating a persistent scene.
483+
484+
This calls the ``scene.apply`` service. It works like activating
485+
a scene, but the state combination is not saved.
486+
487+
Parameters
488+
----------
489+
entities : dict[str, dict[str, Any]]
490+
Mapping of entity IDs to desired state/attribute dicts.
491+
transition : float or None, optional
492+
Transition time in seconds for entities that support it.
493+
"""
494+
data: dict[str, Any] = {"entities": entities}
495+
if transition is not None:
496+
data["transition"] = transition
497+
await self._call_service("scene", "apply", data)
498+
432499
def timer(self, name: str | None = None, *, persistent: bool = False) -> Timer:
433500
"""Return a `Timer`, creating the Python object if needed.
434501

src/haclient/domains/scene.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
"""``scene`` domain implementation."""
1+
"""``scene`` domain implementation.
2+
3+
Scenes apply a pre-defined set of entity states in one shot. They are
4+
fire-and-forget: there is no ``turn_off`` counterpart. The entity
5+
``state`` is the ISO-8601 timestamp of the last activation (or
6+
``"unavailable"`` / ``"unknown"`` when not applicable).
7+
8+
Dynamic scenes (created via `HAClient.create_scene`) can also be
9+
deleted through the `Scene.delete` method.
10+
"""
211

312
from __future__ import annotations
413

@@ -87,6 +96,20 @@ async def activate(self, *, transition: float | None = None) -> None:
8796
data = {"transition": transition}
8897
await self._call_service("turn_on", data)
8998

99+
async def delete(self) -> None:
100+
"""Delete this dynamically-created scene.
101+
102+
Only scenes created via ``HAClient.create_scene`` (i.e. the
103+
``scene.create`` service) can be deleted. Attempting to delete
104+
a YAML-defined scene will raise an error from Home Assistant.
105+
106+
Raises
107+
------
108+
HAClientError
109+
If Home Assistant rejects the deletion.
110+
"""
111+
await self._call_service("delete")
112+
90113
# -- Listener decorators --
91114

92115
def on_activate(self, func: Any) -> Any:

src/haclient/sync.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,51 @@ def scene(self, name: str) -> Any:
254254
"""
255255
return _SyncProxy(self._client.scene(name), self._loop_thread)
256256

257+
def create_scene(
258+
self,
259+
scene_id: str,
260+
entities: dict[str, dict[str, Any]],
261+
*,
262+
snapshot_entities: list[str] | None = None,
263+
) -> Any:
264+
"""Create a dynamic scene synchronously.
265+
266+
Parameters
267+
----------
268+
scene_id : str
269+
The object-id for the new scene.
270+
entities : dict[str, dict[str, Any]]
271+
Mapping of entity IDs to desired state/attribute dicts.
272+
snapshot_entities : list of str or None, optional
273+
Entity IDs whose current state should be captured.
274+
275+
Returns
276+
-------
277+
Any
278+
A sync proxy wrapping the new `Scene`.
279+
"""
280+
scene = self._loop_thread.submit(
281+
self._client.create_scene(scene_id, entities, snapshot_entities=snapshot_entities)
282+
)
283+
return _SyncProxy(scene, self._loop_thread)
284+
285+
def apply_scene(
286+
self,
287+
entities: dict[str, dict[str, Any]],
288+
*,
289+
transition: float | None = None,
290+
) -> None:
291+
"""Apply entity states without creating a persistent scene.
292+
293+
Parameters
294+
----------
295+
entities : dict[str, dict[str, Any]]
296+
Mapping of entity IDs to desired state/attribute dicts.
297+
transition : float or None, optional
298+
Transition time in seconds.
299+
"""
300+
self._loop_thread.submit(self._client.apply_scene(entities, transition=transition))
301+
257302
def timer(self, name: str) -> Any:
258303
"""Return a sync proxy wrapping the async `Timer`.
259304

tests/test_client.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,74 @@ async def test_call_service_via_rest_fallback(fake_ha: FakeHA) -> None:
115115
assert fake_ha.rest_service_calls == [("switch", "toggle", {"entity_id": "switch.x"})]
116116

117117

118+
async def test_create_scene(fake_ha: FakeHA) -> None:
119+
ha = HAClient(fake_ha.base_url, fake_ha.token, ping_interval=0)
120+
try:
121+
await ha.connect()
122+
scene = await ha.create_scene(
123+
"romantic",
124+
{"light.ceiling": {"state": "on", "brightness": 80}},
125+
)
126+
assert scene.entity_id == "scene.romantic"
127+
calls = fake_ha.ws_service_calls
128+
assert len(calls) == 1
129+
assert calls[0]["domain"] == "scene"
130+
assert calls[0]["service"] == "create"
131+
assert calls[0]["service_data"]["scene_id"] == "romantic"
132+
assert calls[0]["service_data"]["entities"] == {
133+
"light.ceiling": {"state": "on", "brightness": 80}
134+
}
135+
finally:
136+
await ha.close()
137+
138+
139+
async def test_create_scene_with_snapshot(fake_ha: FakeHA) -> None:
140+
ha = HAClient(fake_ha.base_url, fake_ha.token, ping_interval=0)
141+
try:
142+
await ha.connect()
143+
scene = await ha.create_scene(
144+
"snapshot_test",
145+
{"light.ceiling": {"state": "on"}},
146+
snapshot_entities=["light.lamp"],
147+
)
148+
assert scene.entity_id == "scene.snapshot_test"
149+
calls = fake_ha.ws_service_calls
150+
assert calls[0]["service_data"]["snapshot_entities"] == ["light.lamp"]
151+
finally:
152+
await ha.close()
153+
154+
155+
async def test_apply_scene(fake_ha: FakeHA) -> None:
156+
ha = HAClient(fake_ha.base_url, fake_ha.token, ping_interval=0)
157+
try:
158+
await ha.connect()
159+
await ha.apply_scene({"light.ceiling": {"state": "on", "brightness": 200}})
160+
calls = fake_ha.ws_service_calls
161+
assert len(calls) == 1
162+
assert calls[0]["domain"] == "scene"
163+
assert calls[0]["service"] == "apply"
164+
assert calls[0]["service_data"]["entities"] == {
165+
"light.ceiling": {"state": "on", "brightness": 200}
166+
}
167+
assert "transition" not in calls[0]["service_data"]
168+
finally:
169+
await ha.close()
170+
171+
172+
async def test_apply_scene_with_transition(fake_ha: FakeHA) -> None:
173+
ha = HAClient(fake_ha.base_url, fake_ha.token, ping_interval=0)
174+
try:
175+
await ha.connect()
176+
await ha.apply_scene(
177+
{"light.ceiling": {"state": "on"}},
178+
transition=3.0,
179+
)
180+
calls = fake_ha.ws_service_calls
181+
assert calls[0]["service_data"]["transition"] == 3.0
182+
finally:
183+
await ha.close()
184+
185+
118186
async def test_invalid_entity_id_direct_construction() -> None:
119187
ha = HAClient("http://x", "t")
120188
from haclient import Light

tests/test_domains.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,16 @@ def _listener(old: Any, new: Any) -> None:
470470
assert fired[0][1] == "2024-06-15T20:30:00+00:00"
471471

472472

473+
async def test_scene_delete(client: HAClient, fake_ha: FakeHA) -> None:
474+
sc = client.scene("romantic")
475+
await sc.delete()
476+
calls = fake_ha.ws_service_calls
477+
assert len(calls) == 1
478+
assert calls[0]["domain"] == "scene"
479+
assert calls[0]["service"] == "delete"
480+
assert calls[0]["service_data"]["entity_id"] == "scene.romantic"
481+
482+
473483
async def test_timer_time_remaining_active() -> None:
474484
"""``time_remaining`` computes live seconds from ``finishes_at`` when active."""
475485
import datetime

tests/test_sync.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,32 @@ def run() -> dict[str, str]:
122122
assert names["binary_sensor"] == "binary_sensor.b"
123123
assert names["scene"] == "scene.sc"
124124
assert names["timer"] == "timer.tm"
125+
126+
127+
async def test_sync_create_scene(fake_ha: FakeHA) -> None:
128+
def run() -> str:
129+
client = SyncHAClient(fake_ha.base_url, fake_ha.token, ping_interval=0)
130+
try:
131+
client.connect()
132+
scene = client.create_scene(
133+
"romantic",
134+
{"light.ceiling": {"state": "on", "brightness": 80}},
135+
)
136+
return scene.entity_id # type: ignore[no-any-return]
137+
finally:
138+
client.close()
139+
140+
eid = await asyncio.get_running_loop().run_in_executor(None, run)
141+
assert eid == "scene.romantic"
142+
143+
144+
async def test_sync_apply_scene(fake_ha: FakeHA) -> None:
145+
def run() -> None:
146+
client = SyncHAClient(fake_ha.base_url, fake_ha.token, ping_interval=0)
147+
try:
148+
client.connect()
149+
client.apply_scene({"light.ceiling": {"state": "on"}})
150+
finally:
151+
client.close()
152+
153+
await asyncio.get_running_loop().run_in_executor(None, run)

0 commit comments

Comments
 (0)