diff --git a/src/haclient/domains/__init__.py b/src/haclient/domains/__init__.py index b49c3f6..0102553 100644 --- a/src/haclient/domains/__init__.py +++ b/src/haclient/domains/__init__.py @@ -10,6 +10,7 @@ from haclient.domains.climate import Climate from haclient.domains.cover import Cover from haclient.domains.event import Event +from haclient.domains.fan import Fan from haclient.domains.humidifier import Humidifier from haclient.domains.light import Light from haclient.domains.lock import Lock @@ -27,6 +28,7 @@ "Climate", "Cover", "Event", + "Fan", "FavoriteItem", "Humidifier", "Light", diff --git a/src/haclient/domains/fan.py b/src/haclient/domains/fan.py new file mode 100644 index 0000000..372cd7a --- /dev/null +++ b/src/haclient/domains/fan.py @@ -0,0 +1,347 @@ +"""``fan`` domain implementation.""" + +from __future__ import annotations + +import logging +from typing import Any + +from haclient.core.plugins import DomainSpec, register_domain +from haclient.entity.base import Entity + +_LOGGER = logging.getLogger(__name__) + +# Home Assistant ``FanEntityFeature`` bitmask. +# See homeassistant/components/fan/const.py. +_FEATURE_SET_SPEED = 1 +_FEATURE_OSCILLATE = 2 +_FEATURE_DIRECTION = 4 +_FEATURE_PRESET_MODE = 8 + +# Canonical Home Assistant fan direction values. +_DIRECTION_FORWARD = "forward" +_DIRECTION_REVERSE = "reverse" +_VALID_DIRECTIONS = frozenset({_DIRECTION_FORWARD, _DIRECTION_REVERSE}) + + +class Fan(Entity): + """A Home Assistant fan entity. + + The public API uses intent-specific actions (``on``, ``off``, + ``toggle``, ``set_percentage``, ``set_preset_mode``, + ``set_direction``, ``oscillate``) and exposes structured state + (``is_on``, ``percentage``, ``preset_mode``, ``preset_modes``, + ``oscillating``, ``direction``) rather than raw service calls. + + Methods that depend on optional fan capabilities degrade safely: if + the underlying hardware does not advertise the relevant + ``FanEntityFeature`` bit in ``supported_features``, the call becomes + a no-op that logs a debug message instead of raising. Callers that + need to know whether an action will actually be dispatched can + pre-check with the ``supports_*`` properties. + """ + + domain = "fan" + + # -- Listener decorators ------------------------------------------ + + def on_turn_on(self, func: Any) -> Any: + """Register a listener for when the fan turns on. + + Parameters + ---------- + func : callable + Sync or async callable invoked with ``(old_state, new_state)`` + on every transition into the ``on`` state. + + Returns + ------- + callable + The same *func*, returned for decorator use. + """ + return self._register_state_transition_listener("on", func) + + def on_turn_off(self, func: Any) -> Any: + """Register a listener for when the fan turns off. + + Parameters + ---------- + func : callable + Sync or async callable invoked with ``(old_state, new_state)`` + on every transition into the ``off`` state. + + Returns + ------- + callable + The same *func*, returned for decorator use. + """ + return self._register_state_transition_listener("off", func) + + def on_speed_change(self, func: Any) -> Any: + """Register a listener for fan speed (``percentage``) changes. + + Parameters + ---------- + func : callable + Callable receiving the new ``percentage`` value as + ``(old_value, new_value)``. + + Returns + ------- + callable + The same *func*, returned for decorator use. + """ + return self._register_attr_listener("percentage", func) + + def on_direction_change(self, func: Any) -> Any: + """Register a listener for fan direction changes. + + Parameters + ---------- + func : callable + Callable receiving the new direction string as + ``(old_value, new_value)``. + + Returns + ------- + callable + The same *func*, returned for decorator use. + """ + return self._register_attr_listener("direction", func) + + # -- Feature detection -------------------------------------------- + + def _has_feature(self, mask: int) -> bool: + """Return ``True`` when ``supported_features`` advertises *mask*. + + Parameters + ---------- + mask : int + One of the ``FanEntityFeature`` bit constants. + + Returns + ------- + bool + ``True`` if the entity reports an integer + ``supported_features`` bitmask, otherwise ``False``. + """ + features = self.attributes.get("supported_features") + if not isinstance(features, int): + return False + return bool(features & mask) + + @property + def supports_set_speed(self) -> bool: + """Whether the device advertises ``FanEntityFeature.SET_SPEED``.""" + return self._has_feature(_FEATURE_SET_SPEED) + + @property + def supports_oscillate(self) -> bool: + """Whether the device advertises ``FanEntityFeature.OSCILLATE``.""" + return self._has_feature(_FEATURE_OSCILLATE) + + @property + def supports_direction(self) -> bool: + """Whether the device advertises ``FanEntityFeature.DIRECTION``.""" + return self._has_feature(_FEATURE_DIRECTION) + + @property + def supports_preset_mode(self) -> bool: + """Whether the device advertises ``FanEntityFeature.PRESET_MODE``.""" + return self._has_feature(_FEATURE_PRESET_MODE) + + # -- State properties --------------------------------------------- + + @property + def is_on(self) -> bool: + """Whether the fan is currently on.""" + return self.state == "on" + + @property + def percentage(self) -> int | None: + """Current fan speed in percent, or ``None`` when not reported.""" + value = self.attributes.get("percentage") + return int(value) if isinstance(value, (int, float)) else None + + @property + def preset_mode(self) -> str | None: + """Active preset mode, or ``None`` when the device has none.""" + value = self.attributes.get("preset_mode") + return str(value) if isinstance(value, str) else None + + @property + def preset_modes(self) -> list[str]: + """Preset modes supported by the device. + + Returns an empty list when the device does not advertise modes. + Non-string entries in the underlying attribute are filtered out. + """ + modes = self.attributes.get("preset_modes") + if not isinstance(modes, list): + return [] + return [m for m in modes if isinstance(m, str)] + + @property + def oscillating(self) -> bool | None: + """Whether the fan is currently oscillating. + + Returns ``None`` when the device does not report this attribute. + """ + value = self.attributes.get("oscillating") + return bool(value) if isinstance(value, bool) else None + + @property + def direction(self) -> str | None: + """Current fan direction (``"forward"`` or ``"reverse"``). + + Returns ``None`` when the device does not report a direction. + """ + value = self.attributes.get("direction") + return str(value) if isinstance(value, str) else None + + # -- Actions ------------------------------------------------------ + + async def on(self) -> None: + """Turn the fan on.""" + await self._call_service("turn_on") + + async def off(self) -> None: + """Turn the fan off.""" + await self._call_service("turn_off") + + async def toggle(self) -> None: + """Toggle the fan state.""" + await self._call_service("toggle") + + async def set_percentage(self, percentage: int) -> None: + """Set the fan speed, in percent. + + Parameters + ---------- + percentage : int + Target speed between 0 and 100 (inclusive). ``0`` typically + turns the fan off. + + Raises + ------ + ValueError + If *percentage* is outside the 0-100 range. + + Notes + ----- + Degrades safely: if the fan does not advertise the + ``SET_SPEED`` feature, this method logs a debug message and + returns without raising. Callers can pre-check with + `supports_set_speed`. + """ + value = int(percentage) + if not 0 <= value <= 100: + raise ValueError("percentage must be between 0 and 100") + if not self.supports_set_speed: + _LOGGER.debug( + "set_percentage() unsupported for %s; skipping (no FanEntityFeature.SET_SPEED)", + self.entity_id, + ) + return + await self._call_service("set_percentage", {"percentage": value}) + + async def set_preset_mode(self, mode: str) -> None: + """Activate a named preset mode, when supported. + + Parameters + ---------- + mode : str + Preset mode to activate. Must be one of `preset_modes` when + the device reports any. + + Raises + ------ + ValueError + If the device reports `preset_modes` and *mode* is not in + that list. + + Notes + ----- + Degrades safely: if the fan does not advertise the + ``PRESET_MODE`` feature, or reports no preset modes at all, + this method logs a debug message and returns without raising. + Callers can pre-check with `supports_preset_mode`. + """ + if not self.supports_preset_mode: + _LOGGER.debug( + "set_preset_mode() unsupported for %s; skipping (no FanEntityFeature.PRESET_MODE)", + self.entity_id, + ) + return + modes = self.preset_modes + if not modes: + # Graceful degradation: device exposes no preset modes. + _LOGGER.debug( + "set_preset_mode() skipped for %s; device reports no preset_modes", + self.entity_id, + ) + return + if mode not in modes: + raise ValueError( + f"preset_mode {mode!r} not in preset_modes {modes!r}", + ) + await self._call_service("set_preset_mode", {"preset_mode": mode}) + + async def set_direction(self, direction: str) -> None: + """Set the fan rotation direction, when supported. + + Parameters + ---------- + direction : str + Either ``"forward"`` or ``"reverse"``. + + Raises + ------ + ValueError + If *direction* is not ``"forward"`` or ``"reverse"``. + + Notes + ----- + Degrades safely: if the fan does not advertise the + ``DIRECTION`` feature, this method logs a debug message and + returns without raising. Callers can pre-check with + `supports_direction`. + """ + value = str(direction) + if value not in _VALID_DIRECTIONS: + raise ValueError( + f"direction must be one of {sorted(_VALID_DIRECTIONS)!r}, got {direction!r}", + ) + if not self.supports_direction: + _LOGGER.debug( + "set_direction() unsupported for %s; skipping (no FanEntityFeature.DIRECTION)", + self.entity_id, + ) + return + await self._call_service("set_direction", {"direction": value}) + + async def oscillate(self, oscillating: bool) -> None: + """Toggle oscillation on or off, when supported. + + Parameters + ---------- + oscillating : bool + ``True`` to oscillate, ``False`` to stop oscillating. + + Notes + ----- + Degrades safely: if the fan does not advertise the + ``OSCILLATE`` feature, this method logs a debug message and + returns without raising. Callers can pre-check with + `supports_oscillate`. + """ + if not self.supports_oscillate: + _LOGGER.debug( + "oscillate() unsupported for %s; skipping (no FanEntityFeature.OSCILLATE)", + self.entity_id, + ) + return + await self._call_service("oscillate", {"oscillating": bool(oscillating)}) + + +SPEC: DomainSpec[Fan] = register_domain(DomainSpec(name="fan", entity_cls=Fan)) +"""The `DomainSpec` registered with the shared `DomainRegistry`.""" diff --git a/tests/test_domains.py b/tests/test_domains.py index 2f8532d..b4aefa0 100644 --- a/tests/test_domains.py +++ b/tests/test_domains.py @@ -1315,3 +1315,269 @@ def _on_fan(old: Any, new: Any) -> None: assert errored == [("docked", "error")] assert battery == [(80, 75), (75, 90)] assert fan == [("balanced", "turbo"), ("turbo", "balanced")] + + +# --------------------------------------------------------------------------- +# Fan +# --------------------------------------------------------------------------- + + +# FanEntityFeature: SET_SPEED=1, OSCILLATE=2, DIRECTION=4, PRESET_MODE=8. +_FAN_FULL_FEATURES = 1 | 2 | 4 | 8 + + +async def test_fan_actions_full_featured(client: HAClient, fake_ha: FakeHA) -> None: + """A full-featured fan dispatches every intent-specific service.""" + ceiling = client.fan("bedroom_ceiling") + ceiling._apply_state( + { + "state": "on", + "attributes": { + "supported_features": _FAN_FULL_FEATURES, + "preset_modes": ["sleep", "auto", "natural"], + }, + } + ) + + await ceiling.on() + await ceiling.set_percentage(50) + await ceiling.oscillate(True) + await ceiling.set_direction("reverse") + await ceiling.set_preset_mode("sleep") + await ceiling.off() + await ceiling.toggle() + + services = [c["service"] for c in fake_ha.ws_service_calls] + assert services == [ + "turn_on", + "set_percentage", + "oscillate", + "set_direction", + "set_preset_mode", + "turn_off", + "toggle", + ] + assert all(c["domain"] == "fan" for c in fake_ha.ws_service_calls) + assert all( + c["service_data"]["entity_id"] == "fan.bedroom_ceiling" for c in fake_ha.ws_service_calls + ) + + pct = _find_call(fake_ha, "set_percentage") + assert pct["service_data"]["percentage"] == 50 + + osc = _find_call(fake_ha, "oscillate") + assert osc["service_data"]["oscillating"] is True + + direction = _find_call(fake_ha, "set_direction") + assert direction["service_data"]["direction"] == "reverse" + + preset = _find_call(fake_ha, "set_preset_mode") + assert preset["service_data"]["preset_mode"] == "sleep" + + +async def test_fan_state_props() -> None: + """Fan state properties reflect the underlying attributes.""" + ha = HAClient.from_url("http://x", token="t", load_plugins=False) + try: + ceiling = ha.fan("bedroom_ceiling") + ceiling._apply_state( + { + "state": "on", + "attributes": { + "percentage": 66, + "preset_mode": "auto", + "preset_modes": ["auto", "sleep", "natural"], + "oscillating": False, + "direction": "forward", + "supported_features": _FAN_FULL_FEATURES, + }, + } + ) + assert ceiling.is_on + assert ceiling.percentage == 66 + assert ceiling.preset_mode == "auto" + assert ceiling.preset_modes == ["auto", "sleep", "natural"] + assert ceiling.oscillating is False + assert ceiling.direction == "forward" + assert ceiling.supports_set_speed + assert ceiling.supports_oscillate + assert ceiling.supports_direction + assert ceiling.supports_preset_mode + + # Missing / malformed attributes degrade to None / empty. + ceiling._apply_state({"state": "off", "attributes": {}}) + assert not ceiling.is_on + assert ceiling.percentage is None + assert ceiling.preset_mode is None + assert ceiling.preset_modes == [] + assert ceiling.oscillating is None + assert ceiling.direction is None + + # Non-string preset_modes entries are filtered out. + ceiling._apply_state({"state": "off", "attributes": {"preset_modes": ["a", 1, "b", None]}}) + assert ceiling.preset_modes == ["a", "b"] + + # Non-list preset_modes returns empty list. + ceiling._apply_state({"state": "off", "attributes": {"preset_modes": "not-a-list"}}) + assert ceiling.preset_modes == [] + + # Non-bool oscillating returns None. + ceiling._apply_state({"state": "off", "attributes": {"oscillating": "yes"}}) + assert ceiling.oscillating is None + + # Non-int supported_features is treated as unsupported. + ceiling._apply_state({"state": "off", "attributes": {"supported_features": "15"}}) + assert ceiling.supports_set_speed is False + assert ceiling.supports_oscillate is False + assert ceiling.supports_direction is False + assert ceiling.supports_preset_mode is False + finally: + await ha.close() + + +async def test_fan_set_percentage_validation(client: HAClient, fake_ha: FakeHA) -> None: + """``set_percentage`` enforces the 0-100 range before checking support.""" + ceiling = client.fan("bedroom_ceiling") + ceiling._apply_state({"state": "on", "attributes": {"supported_features": _FAN_FULL_FEATURES}}) + + with pytest.raises(ValueError): + await ceiling.set_percentage(150) + with pytest.raises(ValueError): + await ceiling.set_percentage(-1) + + # Boundary values are accepted. + await ceiling.set_percentage(0) + await ceiling.set_percentage(100) + services = [c["service"] for c in fake_ha.ws_service_calls] + assert services == ["set_percentage", "set_percentage"] + + +async def test_fan_set_direction_validation(client: HAClient, fake_ha: FakeHA) -> None: + """``set_direction`` rejects unknown direction strings.""" + ceiling = client.fan("bedroom_ceiling") + ceiling._apply_state({"state": "on", "attributes": {"supported_features": _FAN_FULL_FEATURES}}) + + with pytest.raises(ValueError): + await ceiling.set_direction("sideways") + + await ceiling.set_direction("forward") + await ceiling.set_direction("reverse") + assert [c["service_data"]["direction"] for c in fake_ha.ws_service_calls] == [ + "forward", + "reverse", + ] + + +async def test_fan_set_preset_mode_rejects_unknown(client: HAClient, fake_ha: FakeHA) -> None: + """``set_preset_mode`` rejects modes not in ``preset_modes``.""" + ceiling = client.fan("bedroom_ceiling") + ceiling._apply_state( + { + "state": "on", + "attributes": { + "supported_features": _FAN_FULL_FEATURES, + "preset_modes": ["auto", "sleep"], + }, + } + ) + + with pytest.raises(ValueError): + await ceiling.set_preset_mode("hurricane") + # No service call should be recorded for the rejected mode. + assert fake_ha.ws_service_calls == [] + + +async def test_fan_unsupported_features_are_noops(client: HAClient, fake_ha: FakeHA) -> None: + """Optional actions degrade safely when ``supported_features`` lacks the bit.""" + basic = client.fan("basic") + # No supported_features attribute at all. + basic._apply_state({"state": "on", "attributes": {}}) + + assert basic.supports_set_speed is False + assert basic.supports_oscillate is False + assert basic.supports_direction is False + assert basic.supports_preset_mode is False + + # All gated actions become no-ops; only the unconditional turn_on / + # turn_off / toggle reach the bus. + await basic.set_percentage(50) + await basic.oscillate(True) + await basic.set_direction("forward") + await basic.set_preset_mode("sleep") + + assert fake_ha.ws_service_calls == [] + + # on/off/toggle do not depend on supported_features. + await basic.on() + await basic.off() + await basic.toggle() + assert [c["service"] for c in fake_ha.ws_service_calls] == [ + "turn_on", + "turn_off", + "toggle", + ] + + +async def test_fan_set_preset_mode_skipped_when_no_modes(client: HAClient, fake_ha: FakeHA) -> None: + """``set_preset_mode`` is a no-op when ``preset_modes`` is empty.""" + ceiling = client.fan("ceiling") + # Advertise PRESET_MODE (bit 8) but withhold the preset_modes list. + ceiling._apply_state( + { + "state": "on", + "attributes": {"supported_features": 8}, + } + ) + await ceiling.set_preset_mode("sleep") + assert fake_ha.ws_service_calls == [] + + +async def test_fan_listeners(client: HAClient, fake_ha: FakeHA) -> None: + """Fan listener decorators fire on the relevant transitions.""" + ceiling = client.fan("study") + turned_on: list[tuple[Any, Any]] = [] + turned_off: list[tuple[Any, Any]] = [] + speed: list[tuple[Any, Any]] = [] + direction: list[tuple[Any, Any]] = [] + + @ceiling.on_turn_on + def _on(old: Any, new: Any) -> None: + turned_on.append((old, new)) + + @ceiling.on_turn_off + def _off(old: Any, new: Any) -> None: + turned_off.append((old, new)) + + @ceiling.on_speed_change + def _speed(old: Any, new: Any) -> None: + speed.append((old, new)) + + @ceiling.on_direction_change + def _dir(old: Any, new: Any) -> None: + direction.append((old, new)) + + # Signature: push_state_changed(entity_id, new_state, old_state). + # off -> on, percentage 0 -> 50. + await fake_ha.push_state_changed( + "fan.study", + {"state": "on", "attributes": {"percentage": 50, "direction": "forward"}}, + {"state": "off", "attributes": {"percentage": 0, "direction": "forward"}}, + ) + # on -> on, percentage 50 -> 75, direction forward -> reverse. + await fake_ha.push_state_changed( + "fan.study", + {"state": "on", "attributes": {"percentage": 75, "direction": "reverse"}}, + {"state": "on", "attributes": {"percentage": 50, "direction": "forward"}}, + ) + # on -> off, percentage 75 -> 0, direction reverse -> forward. + await fake_ha.push_state_changed( + "fan.study", + {"state": "off", "attributes": {"percentage": 0, "direction": "forward"}}, + {"state": "on", "attributes": {"percentage": 75, "direction": "reverse"}}, + ) + await asyncio.sleep(0.05) + + assert turned_on == [("off", "on")] + assert turned_off == [("on", "off")] + assert speed == [(0, 50), (50, 75), (75, 0)] + assert direction == [("forward", "reverse"), ("reverse", "forward")]