Skip to content

Commit 2f416ee

Browse files
authored
Merge pull request #28 from graphras-com/feat/scene-domain
feat: add Scene domain for Home Assistant scene entities
2 parents 3f9084b + 894f017 commit 2f416ee

5 files changed

Lines changed: 202 additions & 0 deletions

File tree

src/haclient/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
Light,
2020
MediaPlayer,
2121
NowPlaying,
22+
Scene,
2223
Sensor,
2324
Switch,
2425
)
@@ -51,6 +52,7 @@
5152
"Light",
5253
"MediaPlayer",
5354
"NowPlaying",
55+
"Scene",
5456
"Sensor",
5557
"Switch",
5658
"SyncHAClient",

src/haclient/client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from .domains.cover import Cover
3939
from .domains.light import Light
4040
from .domains.media_player import MediaPlayer
41+
from .domains.scene import Scene
4142
from .domains.sensor import Sensor
4243
from .domains.switch import Switch
4344
from .domains.timer import Timer
@@ -383,6 +384,23 @@ def binary_sensor(self, name: str) -> BinarySensor:
383384

384385
return self._get_or_create("binary_sensor", name, _BinarySensor)
385386

387+
def scene(self, name: str) -> Scene:
388+
"""Return the `Scene` for *name*, creating it if needed.
389+
390+
Parameters
391+
----------
392+
name : str
393+
Short object-id or fully-qualified entity id.
394+
395+
Returns
396+
-------
397+
Scene
398+
The scene entity.
399+
"""
400+
from .domains.scene import Scene as _Scene
401+
402+
return self._get_or_create("scene", name, _Scene)
403+
386404
def timer(self, name: str) -> Timer:
387405
"""Return the `Timer` for *name*, creating it if needed.
388406

src/haclient/domains/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .cover import Cover
66
from .light import Light
77
from .media_player import FavoriteItem, MediaPlayer, NowPlaying
8+
from .scene import Scene
89
from .sensor import Sensor
910
from .switch import Switch
1011
from .timer import Timer
@@ -17,6 +18,7 @@
1718
"Light",
1819
"MediaPlayer",
1920
"NowPlaying",
21+
"Scene",
2022
"Sensor",
2123
"Switch",
2224
"Timer",

src/haclient/domains/scene.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""``scene`` domain implementation."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from ..entity import Entity
8+
9+
10+
class Scene(Entity):
11+
"""A Home Assistant scene entity.
12+
13+
Scenes are fire-and-forget: activating a scene applies a set of
14+
pre-defined entity states. There is no ``turn_off`` counterpart.
15+
The entity ``state`` is the ISO-8601 timestamp of the last activation
16+
(or ``"unavailable"`` / ``"unknown"`` when not applicable).
17+
"""
18+
19+
domain = "scene"
20+
21+
# -- State properties --
22+
23+
@property
24+
def last_activated(self) -> str | None:
25+
"""ISO-8601 timestamp of the last activation.
26+
27+
Returns
28+
-------
29+
str or None
30+
The timestamp string, or ``None`` if the scene state is
31+
``"unavailable"`` or ``"unknown"``.
32+
"""
33+
if self.state in ("unavailable", "unknown", None):
34+
return None
35+
return self.state
36+
37+
@property
38+
def entity_ids(self) -> list[str]:
39+
"""Entity IDs controlled by this scene.
40+
41+
Returns
42+
-------
43+
list of str
44+
The entity IDs, or an empty list if not available.
45+
"""
46+
val = self.attributes.get("entity_id")
47+
if isinstance(val, list):
48+
return [str(v) for v in val]
49+
return []
50+
51+
@property
52+
def name(self) -> str | None:
53+
"""Human-readable name of the scene.
54+
55+
Returns
56+
-------
57+
str or None
58+
The friendly name, or ``None`` if not set.
59+
"""
60+
val = self.attributes.get("friendly_name")
61+
return str(val) if val is not None else None
62+
63+
@property
64+
def icon(self) -> str | None:
65+
"""Icon identifier for the scene (e.g. ``"mdi:palette"``).
66+
67+
Returns
68+
-------
69+
str or None
70+
The icon string, or ``None`` if not set.
71+
"""
72+
val = self.attributes.get("icon")
73+
return str(val) if val is not None else None
74+
75+
# -- Actions --
76+
77+
async def activate(self, *, transition: float | None = None) -> None:
78+
"""Activate the scene.
79+
80+
Parameters
81+
----------
82+
transition : float or None, optional
83+
Transition time in seconds (only affects lights that support it).
84+
"""
85+
data: dict[str, Any] | None = None
86+
if transition is not None:
87+
data = {"transition": transition}
88+
await self.call_service("turn_on", data)
89+
90+
# -- Listener decorators --
91+
92+
def on_activate(self, func: Any) -> Any:
93+
"""Register a listener that fires when the scene is activated.
94+
95+
The listener fires whenever the scene's state changes (the timestamp
96+
is updated on each activation).
97+
98+
Callback: ``(old_state, new_state)``.
99+
"""
100+
return self._register_state_value_listener(func)

tests/test_domains.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import asyncio
56
from typing import Any
67

78
import pytest
@@ -318,3 +319,82 @@ async def test_timer_state_properties() -> None:
318319
assert t.finishes_at is None
319320
finally:
320321
await ha.close()
322+
323+
324+
async def test_scene_activate(client: HAClient, fake_ha: FakeHA) -> None:
325+
sc = client.scene("romantic")
326+
await sc.activate()
327+
await sc.activate(transition=2.5)
328+
calls = fake_ha.ws_service_calls
329+
assert [c["service"] for c in calls] == ["turn_on", "turn_on"]
330+
assert "service_data" not in calls[0] or "transition" not in calls[0].get("service_data", {})
331+
assert calls[1]["service_data"]["transition"] == 2.5
332+
333+
334+
async def test_scene_state_properties() -> None:
335+
ha = HAClient("http://x", "t")
336+
try:
337+
sc = ha.scene("romantic")
338+
sc._apply_state(
339+
{
340+
"state": "2024-06-15T20:30:00+00:00",
341+
"attributes": {
342+
"friendly_name": "Romantic",
343+
"icon": "mdi:candle",
344+
"entity_id": ["light.ceiling", "light.lamp"],
345+
},
346+
}
347+
)
348+
assert sc.last_activated == "2024-06-15T20:30:00+00:00"
349+
assert sc.name == "Romantic"
350+
assert sc.icon == "mdi:candle"
351+
assert sc.entity_ids == ["light.ceiling", "light.lamp"]
352+
finally:
353+
await ha.close()
354+
355+
356+
async def test_scene_unavailable_state() -> None:
357+
ha = HAClient("http://x", "t")
358+
try:
359+
sc = ha.scene("broken")
360+
sc._apply_state({"state": "unavailable", "attributes": {}})
361+
assert sc.last_activated is None
362+
sc._apply_state({"state": "unknown", "attributes": {}})
363+
assert sc.last_activated is None
364+
finally:
365+
await ha.close()
366+
367+
368+
async def test_scene_empty_attributes() -> None:
369+
ha = HAClient("http://x", "t")
370+
try:
371+
sc = ha.scene("minimal")
372+
sc._apply_state(
373+
{
374+
"state": "2024-01-01T00:00:00+00:00",
375+
"attributes": {},
376+
}
377+
)
378+
assert sc.entity_ids == []
379+
assert sc.name is None
380+
assert sc.icon is None
381+
finally:
382+
await ha.close()
383+
384+
385+
async def test_scene_on_activate_listener(client: HAClient, fake_ha: FakeHA) -> None:
386+
sc = client.scene("romantic")
387+
fired: list[tuple[Any, Any]] = []
388+
389+
@sc.on_activate
390+
def _listener(old: Any, new: Any) -> None:
391+
fired.append((old, new))
392+
393+
await fake_ha.push_state_changed(
394+
"scene.romantic",
395+
old_state={"state": "2024-06-15T20:00:00+00:00", "attributes": {}},
396+
new_state={"state": "2024-06-15T20:30:00+00:00", "attributes": {}},
397+
)
398+
await asyncio.sleep(0.05)
399+
assert len(fired) == 1
400+
assert fired[0][1] == "2024-06-15T20:30:00+00:00"

0 commit comments

Comments
 (0)