Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/haclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
Light,
MediaPlayer,
NowPlaying,
Scene,
Sensor,
Switch,
)
Expand Down Expand Up @@ -51,6 +52,7 @@
"Light",
"MediaPlayer",
"NowPlaying",
"Scene",
"Sensor",
"Switch",
"SyncHAClient",
Expand Down
18 changes: 18 additions & 0 deletions src/haclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions src/haclient/domains/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,6 +18,7 @@
"Light",
"MediaPlayer",
"NowPlaying",
"Scene",
"Sensor",
"Switch",
"Timer",
Expand Down
100 changes: 100 additions & 0 deletions src/haclient/domains/scene.py
Original file line number Diff line number Diff line change
@@ -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)
80 changes: 80 additions & 0 deletions tests/test_domains.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import asyncio
from typing import Any

import pytest
Expand Down Expand Up @@ -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"
Loading