diff --git a/src/haclient/__init__.py b/src/haclient/__init__.py index 5d368cd..58ac672 100644 --- a/src/haclient/__init__.py +++ b/src/haclient/__init__.py @@ -19,6 +19,7 @@ Light, MediaPlayer, NowPlaying, + Scene, Sensor, Switch, ) @@ -51,6 +52,7 @@ "Light", "MediaPlayer", "NowPlaying", + "Scene", "Sensor", "Switch", "SyncHAClient", diff --git a/src/haclient/client.py b/src/haclient/client.py index 35652d1..61c0096 100644 --- a/src/haclient/client.py +++ b/src/haclient/client.py @@ -38,6 +38,7 @@ from .domains.cover import Cover from .domains.light import Light from .domains.media_player import MediaPlayer + from .domains.scene import Scene from .domains.sensor import Sensor from .domains.switch import Switch from .domains.timer import Timer @@ -383,6 +384,23 @@ def binary_sensor(self, name: str) -> BinarySensor: return self._get_or_create("binary_sensor", name, _BinarySensor) + def scene(self, name: str) -> Scene: + """Return the `Scene` for *name*, creating it if needed. + + Parameters + ---------- + name : str + Short object-id or fully-qualified entity id. + + Returns + ------- + Scene + The scene entity. + """ + from .domains.scene import Scene as _Scene + + return self._get_or_create("scene", name, _Scene) + def timer(self, name: str) -> Timer: """Return the `Timer` for *name*, creating it if needed. diff --git a/src/haclient/domains/__init__.py b/src/haclient/domains/__init__.py index d44a25f..b1ba076 100644 --- a/src/haclient/domains/__init__.py +++ b/src/haclient/domains/__init__.py @@ -5,6 +5,7 @@ from .cover import Cover from .light import Light from .media_player import FavoriteItem, MediaPlayer, NowPlaying +from .scene import Scene from .sensor import Sensor from .switch import Switch from .timer import Timer @@ -17,6 +18,7 @@ "Light", "MediaPlayer", "NowPlaying", + "Scene", "Sensor", "Switch", "Timer", diff --git a/src/haclient/domains/scene.py b/src/haclient/domains/scene.py new file mode 100644 index 0000000..f1e41e8 --- /dev/null +++ b/src/haclient/domains/scene.py @@ -0,0 +1,100 @@ +"""``scene`` domain implementation.""" + +from __future__ import annotations + +from typing import Any + +from ..entity import Entity + + +class Scene(Entity): + """A Home Assistant scene entity. + + Scenes are fire-and-forget: activating a scene applies a set of + pre-defined entity states. There is no ``turn_off`` counterpart. + The entity ``state`` is the ISO-8601 timestamp of the last activation + (or ``"unavailable"`` / ``"unknown"`` when not applicable). + """ + + domain = "scene" + + # -- State properties -- + + @property + def last_activated(self) -> str | None: + """ISO-8601 timestamp of the last activation. + + Returns + ------- + str or None + The timestamp string, or ``None`` if the scene state is + ``"unavailable"`` or ``"unknown"``. + """ + if self.state in ("unavailable", "unknown", None): + return None + return self.state + + @property + def entity_ids(self) -> list[str]: + """Entity IDs controlled by this scene. + + Returns + ------- + list of str + The entity IDs, or an empty list if not available. + """ + val = self.attributes.get("entity_id") + if isinstance(val, list): + return [str(v) for v in val] + return [] + + @property + def name(self) -> str | None: + """Human-readable name of the scene. + + Returns + ------- + str or None + The friendly name, or ``None`` if not set. + """ + val = self.attributes.get("friendly_name") + return str(val) if val is not None else None + + @property + def icon(self) -> str | None: + """Icon identifier for the scene (e.g. ``"mdi:palette"``). + + Returns + ------- + str or None + The icon string, or ``None`` if not set. + """ + val = self.attributes.get("icon") + return str(val) if val is not None else None + + # -- Actions -- + + async def activate(self, *, transition: float | None = None) -> None: + """Activate the scene. + + Parameters + ---------- + transition : float or None, optional + Transition time in seconds (only affects lights that support it). + """ + data: dict[str, Any] | None = None + if transition is not None: + data = {"transition": transition} + await self.call_service("turn_on", data) + + # -- Listener decorators -- + + def on_activate(self, func: Any) -> Any: + """Register a listener that fires when the scene is activated. + + The listener fires whenever the scene's state changes (the timestamp + is updated on each activation). + + Callback: ``(old_state, new_state)``. + """ + return self._register_state_value_listener(func) diff --git a/tests/test_domains.py b/tests/test_domains.py index 67baba2..7ec0c17 100644 --- a/tests/test_domains.py +++ b/tests/test_domains.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from typing import Any import pytest @@ -318,3 +319,82 @@ async def test_timer_state_properties() -> None: assert t.finishes_at is None finally: await ha.close() + + +async def test_scene_activate(client: HAClient, fake_ha: FakeHA) -> None: + sc = client.scene("romantic") + await sc.activate() + await sc.activate(transition=2.5) + calls = fake_ha.ws_service_calls + assert [c["service"] for c in calls] == ["turn_on", "turn_on"] + assert "service_data" not in calls[0] or "transition" not in calls[0].get("service_data", {}) + assert calls[1]["service_data"]["transition"] == 2.5 + + +async def test_scene_state_properties() -> None: + ha = HAClient("http://x", "t") + try: + sc = ha.scene("romantic") + sc._apply_state( + { + "state": "2024-06-15T20:30:00+00:00", + "attributes": { + "friendly_name": "Romantic", + "icon": "mdi:candle", + "entity_id": ["light.ceiling", "light.lamp"], + }, + } + ) + assert sc.last_activated == "2024-06-15T20:30:00+00:00" + assert sc.name == "Romantic" + assert sc.icon == "mdi:candle" + assert sc.entity_ids == ["light.ceiling", "light.lamp"] + finally: + await ha.close() + + +async def test_scene_unavailable_state() -> None: + ha = HAClient("http://x", "t") + try: + sc = ha.scene("broken") + sc._apply_state({"state": "unavailable", "attributes": {}}) + assert sc.last_activated is None + sc._apply_state({"state": "unknown", "attributes": {}}) + assert sc.last_activated is None + finally: + await ha.close() + + +async def test_scene_empty_attributes() -> None: + ha = HAClient("http://x", "t") + try: + sc = ha.scene("minimal") + sc._apply_state( + { + "state": "2024-01-01T00:00:00+00:00", + "attributes": {}, + } + ) + assert sc.entity_ids == [] + assert sc.name is None + assert sc.icon is None + finally: + await ha.close() + + +async def test_scene_on_activate_listener(client: HAClient, fake_ha: FakeHA) -> None: + sc = client.scene("romantic") + fired: list[tuple[Any, Any]] = [] + + @sc.on_activate + def _listener(old: Any, new: Any) -> None: + fired.append((old, new)) + + await fake_ha.push_state_changed( + "scene.romantic", + old_state={"state": "2024-06-15T20:00:00+00:00", "attributes": {}}, + new_state={"state": "2024-06-15T20:30:00+00:00", "attributes": {}}, + ) + await asyncio.sleep(0.05) + assert len(fired) == 1 + assert fired[0][1] == "2024-06-15T20:30:00+00:00"