|
| 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