Skip to content

Commit f7f6289

Browse files
authored
Merge pull request #65 from graphras-com/feat/humidifier-domain
feat(domains): add Humidifier domain (#38)
2 parents c8c8843 + 2b8063a commit f7f6289

3 files changed

Lines changed: 297 additions & 0 deletions

File tree

src/haclient/domains/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from haclient.domains.binary_sensor import BinarySensor
99
from haclient.domains.climate import Climate
1010
from haclient.domains.cover import Cover
11+
from haclient.domains.humidifier import Humidifier
1112
from haclient.domains.light import Light
1213
from haclient.domains.lock import Lock
1314
from haclient.domains.media_player import FavoriteItem, MediaPlayer, NowPlaying
@@ -22,6 +23,7 @@
2223
"Climate",
2324
"Cover",
2425
"FavoriteItem",
26+
"Humidifier",
2527
"Light",
2628
"Lock",
2729
"MediaPlayer",

src/haclient/domains/humidifier.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""``humidifier`` domain implementation."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from haclient.core.plugins import DomainSpec, register_domain
8+
from haclient.entity.base import Entity
9+
10+
11+
class Humidifier(Entity):
12+
"""A Home Assistant humidifier (or dehumidifier) entity.
13+
14+
The public API uses ``on()`` / ``off()`` / ``toggle()`` as
15+
intent-specific names rather than the raw HA ``turn_on`` /
16+
``turn_off`` services, and exposes humidity-specific state and
17+
actions.
18+
19+
Mode operations degrade safely: `set_mode` checks the entity's
20+
reported `available_modes` and raises ``ValueError`` for unsupported
21+
modes, while devices that do not report modes at all expose an
22+
empty `available_modes` list and a `mode` of ``None``.
23+
"""
24+
25+
domain = "humidifier"
26+
27+
# -- Listener decorators ------------------------------------------
28+
29+
def on_turn_on(self, func: Any) -> Any:
30+
"""Register a listener for when the humidifier turns on.
31+
32+
Parameters
33+
----------
34+
func : callable
35+
Sync or async zero-argument callable invoked on every
36+
transition into the ``on`` state.
37+
38+
Returns
39+
-------
40+
callable
41+
The same *func*, returned for decorator use.
42+
"""
43+
return self._register_state_transition_listener("on", func)
44+
45+
def on_turn_off(self, func: Any) -> Any:
46+
"""Register a listener for when the humidifier turns off.
47+
48+
Parameters
49+
----------
50+
func : callable
51+
Sync or async zero-argument callable invoked on every
52+
transition into the ``off`` state.
53+
54+
Returns
55+
-------
56+
callable
57+
The same *func*, returned for decorator use.
58+
"""
59+
return self._register_state_transition_listener("off", func)
60+
61+
def on_humidity_change(self, func: Any) -> Any:
62+
"""Register a listener for target humidity changes.
63+
64+
Parameters
65+
----------
66+
func : callable
67+
Callable receiving the new target ``humidity`` value.
68+
69+
Returns
70+
-------
71+
callable
72+
The same *func*, returned for decorator use.
73+
"""
74+
return self._register_attr_listener("humidity", func)
75+
76+
def on_mode_change(self, func: Any) -> Any:
77+
"""Register a listener for operating mode changes.
78+
79+
Parameters
80+
----------
81+
func : callable
82+
Callable receiving the new mode string.
83+
84+
Returns
85+
-------
86+
callable
87+
The same *func*, returned for decorator use.
88+
"""
89+
return self._register_attr_listener("mode", func)
90+
91+
# -- State properties ---------------------------------------------
92+
93+
@property
94+
def is_on(self) -> bool:
95+
"""Whether the humidifier is currently on."""
96+
return self.state == "on"
97+
98+
@property
99+
def target_humidity(self) -> int | None:
100+
"""Configured target humidity, in percent, if reported."""
101+
value = self.attributes.get("humidity")
102+
return int(value) if isinstance(value, (int, float)) else None
103+
104+
@property
105+
def current_humidity(self) -> int | None:
106+
"""Currently measured humidity, in percent.
107+
108+
Returns ``None`` when the device does not report a reading.
109+
"""
110+
value = self.attributes.get("current_humidity")
111+
return int(value) if isinstance(value, (int, float)) else None
112+
113+
@property
114+
def mode(self) -> str | None:
115+
"""Active operating mode, or ``None`` when the device has none."""
116+
value = self.attributes.get("mode")
117+
return str(value) if isinstance(value, str) else None
118+
119+
@property
120+
def available_modes(self) -> list[str]:
121+
"""Operating modes supported by the device.
122+
123+
Returns an empty list when the device does not advertise modes.
124+
"""
125+
modes = self.attributes.get("available_modes")
126+
return [str(m) for m in modes] if isinstance(modes, list) else []
127+
128+
@property
129+
def device_class(self) -> str | None:
130+
"""Device class (``"humidifier"`` or ``"dehumidifier"``)."""
131+
value = self.attributes.get("device_class")
132+
return str(value) if isinstance(value, str) else None
133+
134+
# -- Actions ------------------------------------------------------
135+
136+
async def on(self) -> None:
137+
"""Activate the humidifier."""
138+
await self._call_service("turn_on")
139+
140+
async def off(self) -> None:
141+
"""Deactivate the humidifier."""
142+
await self._call_service("turn_off")
143+
144+
async def toggle(self) -> None:
145+
"""Toggle the humidifier state."""
146+
await self._call_service("toggle")
147+
148+
async def set_humidity(self, humidity: int) -> None:
149+
"""Set the target humidity, in percent.
150+
151+
Parameters
152+
----------
153+
humidity : int
154+
Target humidity between 0 and 100 (inclusive).
155+
156+
Raises
157+
------
158+
ValueError
159+
If *humidity* is outside the 0-100 range.
160+
"""
161+
value = int(humidity)
162+
if not 0 <= value <= 100:
163+
raise ValueError("humidity must be between 0 and 100")
164+
await self._call_service("set_humidity", {"humidity": value})
165+
166+
async def set_mode(self, mode: str) -> None:
167+
"""Set the operating mode, when supported.
168+
169+
Parameters
170+
----------
171+
mode : str
172+
Mode to activate. Must be one of `available_modes` when the
173+
device reports any; the call is silently skipped for devices
174+
that do not support modes at all (empty `available_modes`).
175+
176+
Raises
177+
------
178+
ValueError
179+
If the device reports `available_modes` and *mode* is not
180+
in that list.
181+
"""
182+
modes = self.available_modes
183+
if not modes:
184+
# Graceful degradation: device exposes no modes.
185+
return
186+
if mode not in modes:
187+
raise ValueError(
188+
f"mode {mode!r} not in available_modes {modes!r}",
189+
)
190+
await self._call_service("set_mode", {"mode": mode})
191+
192+
193+
SPEC: DomainSpec[Humidifier] = register_domain(DomainSpec(name="humidifier", entity_cls=Humidifier))
194+
"""The `DomainSpec` registered with the shared `DomainRegistry`."""

tests/test_domains.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,3 +884,104 @@ def _on_position(old: Any, new: Any) -> None:
884884
# Position changes fire for both transitions (order is not guaranteed
885885
# across distinct ``push_state_changed`` events).
886886
assert sorted(positions) == [(0, 100), (100, 0)]
887+
888+
889+
async def test_humidifier_actions(client: HAClient, fake_ha: FakeHA) -> None:
890+
h = client.humidifier("bedroom")
891+
await h.on()
892+
await h.set_humidity(50)
893+
await h.off()
894+
await h.toggle()
895+
calls = fake_ha.ws_service_calls
896+
assert [c["service"] for c in calls] == ["turn_on", "set_humidity", "turn_off", "toggle"]
897+
assert calls[1]["service_data"]["humidity"] == 50
898+
899+
h._apply_state(
900+
{
901+
"state": "on",
902+
"attributes": {
903+
"humidity": 45,
904+
"current_humidity": 38,
905+
"mode": "auto",
906+
"available_modes": ["auto", "sleep", "baby"],
907+
"device_class": "humidifier",
908+
},
909+
}
910+
)
911+
assert h.is_on
912+
assert h.target_humidity == 45
913+
assert h.current_humidity == 38
914+
assert h.mode == "auto"
915+
assert h.available_modes == ["auto", "sleep", "baby"]
916+
assert h.device_class == "humidifier"
917+
918+
await h.set_mode("sleep")
919+
assert fake_ha.ws_service_calls[-1]["service"] == "set_mode"
920+
assert fake_ha.ws_service_calls[-1]["service_data"]["mode"] == "sleep"
921+
922+
with pytest.raises(ValueError):
923+
await h.set_mode("nope")
924+
925+
with pytest.raises(ValueError):
926+
await h.set_humidity(150)
927+
with pytest.raises(ValueError):
928+
await h.set_humidity(-1)
929+
930+
931+
async def test_humidifier_degrades_when_unsupported(client: HAClient, fake_ha: FakeHA) -> None:
932+
h = client.humidifier("basement")
933+
# Device reports no modes and no current humidity reading.
934+
h._apply_state({"state": "off", "attributes": {}})
935+
assert not h.is_on
936+
assert h.target_humidity is None
937+
assert h.current_humidity is None
938+
assert h.mode is None
939+
assert h.available_modes == []
940+
assert h.device_class is None
941+
942+
# set_mode is a no-op when the device exposes no modes.
943+
before = len(fake_ha.ws_service_calls)
944+
await h.set_mode("auto")
945+
assert len(fake_ha.ws_service_calls) == before
946+
947+
948+
async def test_humidifier_listeners(client: HAClient, fake_ha: FakeHA) -> None:
949+
h = client.humidifier("nursery")
950+
951+
turned_on: list[tuple[Any, Any]] = []
952+
turned_off: list[tuple[Any, Any]] = []
953+
humidity_events: list[tuple[Any, Any]] = []
954+
mode_events: list[tuple[Any, Any]] = []
955+
956+
@h.on_turn_on
957+
def _on(old: Any, new: Any) -> None:
958+
turned_on.append((old, new))
959+
960+
@h.on_turn_off
961+
def _off(old: Any, new: Any) -> None:
962+
turned_off.append((old, new))
963+
964+
@h.on_humidity_change
965+
def _hum(old: Any, new: Any) -> None:
966+
humidity_events.append((old, new))
967+
968+
@h.on_mode_change
969+
def _mode(old: Any, new: Any) -> None:
970+
mode_events.append((old, new))
971+
972+
await fake_ha.push_state_changed(
973+
"humidifier.nursery",
974+
{"state": "on", "attributes": {"humidity": 55, "mode": "sleep"}},
975+
{"state": "off", "attributes": {"humidity": 40, "mode": "auto"}},
976+
)
977+
await fake_ha.push_state_changed(
978+
"humidifier.nursery",
979+
{"state": "off", "attributes": {"humidity": 55, "mode": "sleep"}},
980+
{"state": "on", "attributes": {"humidity": 55, "mode": "sleep"}},
981+
)
982+
983+
await asyncio.sleep(0.05)
984+
assert turned_on == [("off", "on")]
985+
assert turned_off == [("on", "off")]
986+
assert humidity_events == [(40, 55)]
987+
assert mode_events == [("auto", "sleep")]

0 commit comments

Comments
 (0)