Skip to content

Commit e037d02

Browse files
authored
Merge pull request #69 from graphras-com/feature/fan-domain
feat(domains): add Fan domain (#37)
2 parents bf17b53 + cad86b5 commit e037d02

3 files changed

Lines changed: 615 additions & 0 deletions

File tree

src/haclient/domains/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from haclient.domains.climate import Climate
1111
from haclient.domains.cover import Cover
1212
from haclient.domains.event import Event
13+
from haclient.domains.fan import Fan
1314
from haclient.domains.humidifier import Humidifier
1415
from haclient.domains.light import Light
1516
from haclient.domains.lock import Lock
@@ -27,6 +28,7 @@
2728
"Climate",
2829
"Cover",
2930
"Event",
31+
"Fan",
3032
"FavoriteItem",
3133
"Humidifier",
3234
"Light",

src/haclient/domains/fan.py

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
"""``fan`` domain implementation."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from haclient.core.plugins import DomainSpec, register_domain
9+
from haclient.entity.base import Entity
10+
11+
_LOGGER = logging.getLogger(__name__)
12+
13+
# Home Assistant ``FanEntityFeature`` bitmask.
14+
# See homeassistant/components/fan/const.py.
15+
_FEATURE_SET_SPEED = 1
16+
_FEATURE_OSCILLATE = 2
17+
_FEATURE_DIRECTION = 4
18+
_FEATURE_PRESET_MODE = 8
19+
20+
# Canonical Home Assistant fan direction values.
21+
_DIRECTION_FORWARD = "forward"
22+
_DIRECTION_REVERSE = "reverse"
23+
_VALID_DIRECTIONS = frozenset({_DIRECTION_FORWARD, _DIRECTION_REVERSE})
24+
25+
26+
class Fan(Entity):
27+
"""A Home Assistant fan entity.
28+
29+
The public API uses intent-specific actions (``on``, ``off``,
30+
``toggle``, ``set_percentage``, ``set_preset_mode``,
31+
``set_direction``, ``oscillate``) and exposes structured state
32+
(``is_on``, ``percentage``, ``preset_mode``, ``preset_modes``,
33+
``oscillating``, ``direction``) rather than raw service calls.
34+
35+
Methods that depend on optional fan capabilities degrade safely: if
36+
the underlying hardware does not advertise the relevant
37+
``FanEntityFeature`` bit in ``supported_features``, the call becomes
38+
a no-op that logs a debug message instead of raising. Callers that
39+
need to know whether an action will actually be dispatched can
40+
pre-check with the ``supports_*`` properties.
41+
"""
42+
43+
domain = "fan"
44+
45+
# -- Listener decorators ------------------------------------------
46+
47+
def on_turn_on(self, func: Any) -> Any:
48+
"""Register a listener for when the fan turns on.
49+
50+
Parameters
51+
----------
52+
func : callable
53+
Sync or async callable invoked with ``(old_state, new_state)``
54+
on every transition into the ``on`` state.
55+
56+
Returns
57+
-------
58+
callable
59+
The same *func*, returned for decorator use.
60+
"""
61+
return self._register_state_transition_listener("on", func)
62+
63+
def on_turn_off(self, func: Any) -> Any:
64+
"""Register a listener for when the fan turns off.
65+
66+
Parameters
67+
----------
68+
func : callable
69+
Sync or async callable invoked with ``(old_state, new_state)``
70+
on every transition into the ``off`` state.
71+
72+
Returns
73+
-------
74+
callable
75+
The same *func*, returned for decorator use.
76+
"""
77+
return self._register_state_transition_listener("off", func)
78+
79+
def on_speed_change(self, func: Any) -> Any:
80+
"""Register a listener for fan speed (``percentage``) changes.
81+
82+
Parameters
83+
----------
84+
func : callable
85+
Callable receiving the new ``percentage`` value as
86+
``(old_value, new_value)``.
87+
88+
Returns
89+
-------
90+
callable
91+
The same *func*, returned for decorator use.
92+
"""
93+
return self._register_attr_listener("percentage", func)
94+
95+
def on_direction_change(self, func: Any) -> Any:
96+
"""Register a listener for fan direction changes.
97+
98+
Parameters
99+
----------
100+
func : callable
101+
Callable receiving the new direction string as
102+
``(old_value, new_value)``.
103+
104+
Returns
105+
-------
106+
callable
107+
The same *func*, returned for decorator use.
108+
"""
109+
return self._register_attr_listener("direction", func)
110+
111+
# -- Feature detection --------------------------------------------
112+
113+
def _has_feature(self, mask: int) -> bool:
114+
"""Return ``True`` when ``supported_features`` advertises *mask*.
115+
116+
Parameters
117+
----------
118+
mask : int
119+
One of the ``FanEntityFeature`` bit constants.
120+
121+
Returns
122+
-------
123+
bool
124+
``True`` if the entity reports an integer
125+
``supported_features`` bitmask, otherwise ``False``.
126+
"""
127+
features = self.attributes.get("supported_features")
128+
if not isinstance(features, int):
129+
return False
130+
return bool(features & mask)
131+
132+
@property
133+
def supports_set_speed(self) -> bool:
134+
"""Whether the device advertises ``FanEntityFeature.SET_SPEED``."""
135+
return self._has_feature(_FEATURE_SET_SPEED)
136+
137+
@property
138+
def supports_oscillate(self) -> bool:
139+
"""Whether the device advertises ``FanEntityFeature.OSCILLATE``."""
140+
return self._has_feature(_FEATURE_OSCILLATE)
141+
142+
@property
143+
def supports_direction(self) -> bool:
144+
"""Whether the device advertises ``FanEntityFeature.DIRECTION``."""
145+
return self._has_feature(_FEATURE_DIRECTION)
146+
147+
@property
148+
def supports_preset_mode(self) -> bool:
149+
"""Whether the device advertises ``FanEntityFeature.PRESET_MODE``."""
150+
return self._has_feature(_FEATURE_PRESET_MODE)
151+
152+
# -- State properties ---------------------------------------------
153+
154+
@property
155+
def is_on(self) -> bool:
156+
"""Whether the fan is currently on."""
157+
return self.state == "on"
158+
159+
@property
160+
def percentage(self) -> int | None:
161+
"""Current fan speed in percent, or ``None`` when not reported."""
162+
value = self.attributes.get("percentage")
163+
return int(value) if isinstance(value, (int, float)) else None
164+
165+
@property
166+
def preset_mode(self) -> str | None:
167+
"""Active preset mode, or ``None`` when the device has none."""
168+
value = self.attributes.get("preset_mode")
169+
return str(value) if isinstance(value, str) else None
170+
171+
@property
172+
def preset_modes(self) -> list[str]:
173+
"""Preset modes supported by the device.
174+
175+
Returns an empty list when the device does not advertise modes.
176+
Non-string entries in the underlying attribute are filtered out.
177+
"""
178+
modes = self.attributes.get("preset_modes")
179+
if not isinstance(modes, list):
180+
return []
181+
return [m for m in modes if isinstance(m, str)]
182+
183+
@property
184+
def oscillating(self) -> bool | None:
185+
"""Whether the fan is currently oscillating.
186+
187+
Returns ``None`` when the device does not report this attribute.
188+
"""
189+
value = self.attributes.get("oscillating")
190+
return bool(value) if isinstance(value, bool) else None
191+
192+
@property
193+
def direction(self) -> str | None:
194+
"""Current fan direction (``"forward"`` or ``"reverse"``).
195+
196+
Returns ``None`` when the device does not report a direction.
197+
"""
198+
value = self.attributes.get("direction")
199+
return str(value) if isinstance(value, str) else None
200+
201+
# -- Actions ------------------------------------------------------
202+
203+
async def on(self) -> None:
204+
"""Turn the fan on."""
205+
await self._call_service("turn_on")
206+
207+
async def off(self) -> None:
208+
"""Turn the fan off."""
209+
await self._call_service("turn_off")
210+
211+
async def toggle(self) -> None:
212+
"""Toggle the fan state."""
213+
await self._call_service("toggle")
214+
215+
async def set_percentage(self, percentage: int) -> None:
216+
"""Set the fan speed, in percent.
217+
218+
Parameters
219+
----------
220+
percentage : int
221+
Target speed between 0 and 100 (inclusive). ``0`` typically
222+
turns the fan off.
223+
224+
Raises
225+
------
226+
ValueError
227+
If *percentage* is outside the 0-100 range.
228+
229+
Notes
230+
-----
231+
Degrades safely: if the fan does not advertise the
232+
``SET_SPEED`` feature, this method logs a debug message and
233+
returns without raising. Callers can pre-check with
234+
`supports_set_speed`.
235+
"""
236+
value = int(percentage)
237+
if not 0 <= value <= 100:
238+
raise ValueError("percentage must be between 0 and 100")
239+
if not self.supports_set_speed:
240+
_LOGGER.debug(
241+
"set_percentage() unsupported for %s; skipping (no FanEntityFeature.SET_SPEED)",
242+
self.entity_id,
243+
)
244+
return
245+
await self._call_service("set_percentage", {"percentage": value})
246+
247+
async def set_preset_mode(self, mode: str) -> None:
248+
"""Activate a named preset mode, when supported.
249+
250+
Parameters
251+
----------
252+
mode : str
253+
Preset mode to activate. Must be one of `preset_modes` when
254+
the device reports any.
255+
256+
Raises
257+
------
258+
ValueError
259+
If the device reports `preset_modes` and *mode* is not in
260+
that list.
261+
262+
Notes
263+
-----
264+
Degrades safely: if the fan does not advertise the
265+
``PRESET_MODE`` feature, or reports no preset modes at all,
266+
this method logs a debug message and returns without raising.
267+
Callers can pre-check with `supports_preset_mode`.
268+
"""
269+
if not self.supports_preset_mode:
270+
_LOGGER.debug(
271+
"set_preset_mode() unsupported for %s; skipping (no FanEntityFeature.PRESET_MODE)",
272+
self.entity_id,
273+
)
274+
return
275+
modes = self.preset_modes
276+
if not modes:
277+
# Graceful degradation: device exposes no preset modes.
278+
_LOGGER.debug(
279+
"set_preset_mode() skipped for %s; device reports no preset_modes",
280+
self.entity_id,
281+
)
282+
return
283+
if mode not in modes:
284+
raise ValueError(
285+
f"preset_mode {mode!r} not in preset_modes {modes!r}",
286+
)
287+
await self._call_service("set_preset_mode", {"preset_mode": mode})
288+
289+
async def set_direction(self, direction: str) -> None:
290+
"""Set the fan rotation direction, when supported.
291+
292+
Parameters
293+
----------
294+
direction : str
295+
Either ``"forward"`` or ``"reverse"``.
296+
297+
Raises
298+
------
299+
ValueError
300+
If *direction* is not ``"forward"`` or ``"reverse"``.
301+
302+
Notes
303+
-----
304+
Degrades safely: if the fan does not advertise the
305+
``DIRECTION`` feature, this method logs a debug message and
306+
returns without raising. Callers can pre-check with
307+
`supports_direction`.
308+
"""
309+
value = str(direction)
310+
if value not in _VALID_DIRECTIONS:
311+
raise ValueError(
312+
f"direction must be one of {sorted(_VALID_DIRECTIONS)!r}, got {direction!r}",
313+
)
314+
if not self.supports_direction:
315+
_LOGGER.debug(
316+
"set_direction() unsupported for %s; skipping (no FanEntityFeature.DIRECTION)",
317+
self.entity_id,
318+
)
319+
return
320+
await self._call_service("set_direction", {"direction": value})
321+
322+
async def oscillate(self, oscillating: bool) -> None:
323+
"""Toggle oscillation on or off, when supported.
324+
325+
Parameters
326+
----------
327+
oscillating : bool
328+
``True`` to oscillate, ``False`` to stop oscillating.
329+
330+
Notes
331+
-----
332+
Degrades safely: if the fan does not advertise the
333+
``OSCILLATE`` feature, this method logs a debug message and
334+
returns without raising. Callers can pre-check with
335+
`supports_oscillate`.
336+
"""
337+
if not self.supports_oscillate:
338+
_LOGGER.debug(
339+
"oscillate() unsupported for %s; skipping (no FanEntityFeature.OSCILLATE)",
340+
self.entity_id,
341+
)
342+
return
343+
await self._call_service("oscillate", {"oscillating": bool(oscillating)})
344+
345+
346+
SPEC: DomainSpec[Fan] = register_domain(DomainSpec(name="fan", entity_cls=Fan))
347+
"""The `DomainSpec` registered with the shared `DomainRegistry`."""

0 commit comments

Comments
 (0)