Ver código fonte

Add Standing Fan controls and oscillation angle read-back (#522)

Co-authored-by: J. Nick Koston <nick@koston.org>
Paul Bottein 1 dia atrás
pai
commit
93047ad1d5
3 arquivos alterados com 198 adições e 5 exclusões
  1. 10 3
      switchbot/adv_parsers/fan.py
  2. 82 1
      switchbot/devices/fan.py
  3. 106 1
      tests/test_fan.py

+ 10 - 3
switchbot/adv_parsers/fan.py

@@ -11,7 +11,10 @@ _STANDING_FAN_MODE_MAP: dict[int, str] = {
 
 
 def _parse_fan(
-    mfr_data: bytes | None, mode_map: dict[int, str]
+    mfr_data: bytes | None,
+    mode_map: dict[int, str],
+    *,
+    with_charging: bool = False,
 ) -> dict[str, bool | int | str | None]:
     """Shared fan advertisement parse, parameterized on the mode map."""
     if mfr_data is None or len(mfr_data) < 10:
@@ -28,7 +31,7 @@ def _parse_fan(
     _battery = device_data[2] & 0b01111111
     _speed = device_data[3] & 0b01111111
 
-    return {
+    result: dict[str, bool | int | str | None] = {
         "sequence_number": _seq_num,
         "isOn": _isOn,
         "mode": mode_map.get(_mode),
@@ -39,6 +42,10 @@ def _parse_fan(
         "battery": _battery,
         "speed": _speed,
     }
+    if with_charging:
+        # Bit 7 of the battery byte is the charging flag (Standing Fan only).
+        result["charging"] = bool(device_data[2] & 0b10000000)
+    return result
 
 
 def process_fan(
@@ -52,4 +59,4 @@ def process_standing_fan(
     data: bytes | None, mfr_data: bytes | None
 ) -> dict[str, bool | int | str | None]:
     """Process Standing Fan services data (modes 1-5; adds CUSTOM_NATURAL)."""
-    return _parse_fan(mfr_data, _STANDING_FAN_MODE_MAP)
+    return _parse_fan(mfr_data, _STANDING_FAN_MODE_MAP, with_charging=True)

+ 82 - 1
switchbot/devices/fan.py

@@ -37,6 +37,11 @@ COMMAND_START_OSCILLATION_ALL_AXES = f"{COMMAND_HEAD}02010101"
 COMMAND_STOP_OSCILLATION_ALL_AXES = f"{COMMAND_HEAD}02010202"
 COMMAND_SET_OSCILLATION_PARAMS = f"{COMMAND_HEAD}0202"  # +angles
 COMMAND_SET_NIGHT_LIGHT = f"{COMMAND_HEAD}0502"  # +state
+# Standing Fan (FAN2) extra controls.
+COMMAND_SET_DISPLAY_LIGHT = f"{COMMAND_HEAD}0501"  # +state + FFFF (front LED display)
+COMMAND_SET_SOUND = f"{COMMAND_HEAD}0601"  # +level (64 on / 00 off)
+COMMAND_SET_AUTO_RECENTER = f"{COMMAND_HEAD}0205"  # +both axes (0101 on / 0202 off)
+COMMAND_SET_CHILD_LOCK = f"{COMMAND_HEAD}07"  # +state (01 on / 02 off)
 COMMAND_SET_MODE = {
     FanMode.NORMAL.name.lower(): f"{COMMAND_HEAD}030101ff",
     FanMode.NATURAL.name.lower(): f"{COMMAND_HEAD}030102ff",
@@ -69,6 +74,10 @@ class SwitchbotFan(SwitchbotSequenceDevice):
             return None
 
         _LOGGER.debug("data: %s", _data)
+        return self._parse_basic_info(_data, _data1)
+
+    def _parse_basic_info(self, _data: bytes, _data1: bytes) -> dict[str, Any]:
+        """Decode the basic-info connection response into a state dict."""
         battery = _data[2] & 0b01111111
         isOn = bool(_data[3] & 0b10000000)
         oscillating_horizontal = bool(_data[3] & 0b01000000)
@@ -186,6 +195,22 @@ class SwitchbotStandingFan(SwitchbotFan):
     _command_start_oscillation: ClassVar[str] = COMMAND_START_OSCILLATION_ALL_AXES
     _command_stop_oscillation: ClassVar[str] = COMMAND_STOP_OSCILLATION_ALL_AXES
 
+    def _parse_basic_info(self, _data: bytes, _data1: bytes) -> dict[str, Any]:
+        """Add the Standing-Fan-only fields to the basic-info response."""
+        info = super()._parse_basic_info(_data, _data1)
+        # Sweep angle as the raw device byte: horizontal is the angle in degrees
+        # (30/60/90); vertical encodes 90 as 95 (see VerticalOscillationAngle).
+        info["oscillating_horizontal_angle"] = _data[4]
+        info["oscillating_vertical_angle"] = _data[6]
+        info["charging"] = bool(_data[2] & 0b10000000)
+        info["child_lock"] = bool(_data[3] & 0b00000001)
+        info["display"] = bool(_data[3] & 0b00000010)
+        # bit 4 = horizontal axis, bit 3 = vertical; the app toggles both at once.
+        info["auto_recenter"] = bool(_data[3] & 0b00011000)
+        if len(_data) > 10:
+            info["sound"] = bool(_data[10] & 0b01111111)
+        return info
+
     @update_after_operation
     async def set_horizontal_oscillation_angle(
         self, angle: HorizontalOscillationAngle | int
@@ -204,7 +229,7 @@ class SwitchbotStandingFan(SwitchbotFan):
         Set vertical oscillation angle (30 / 60 / 90 degrees).
 
         The device uses a different byte encoding on the vertical axis than
-        on the horizontal one 90° maps to byte 0x5F (95), not 0x5A (90),
+        on the horizontal one: 90° maps to byte 0x5F (95), not 0x5A (90),
         which the firmware interprets as an axis halt. Use
         `VerticalOscillationAngle` (or the raw byte values 30 / 60 / 95).
         """
@@ -221,6 +246,62 @@ class SwitchbotStandingFan(SwitchbotFan):
         result = await self._send_command(cmd)
         return self._check_command_result(result, 0, {1})
 
+    @update_after_operation
+    async def set_child_lock(self, enabled: bool) -> bool:
+        """Enable or disable the child lock."""
+        cmd = f"{COMMAND_SET_CHILD_LOCK}{'01' if enabled else '02'}"
+        result = await self._send_command(cmd)
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def set_display(self, enabled: bool) -> bool:
+        """Turn the front display (LED) on or off."""
+        cmd = f"{COMMAND_SET_DISPLAY_LIGHT}{'01' if enabled else '02'}FFFF"
+        result = await self._send_command(cmd)
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def set_sound(self, enabled: bool) -> bool:
+        """Turn the key tone (buzzer) on or off."""
+        cmd = f"{COMMAND_SET_SOUND}{'64' if enabled else '00'}"
+        result = await self._send_command(cmd)
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def set_auto_recenter(self, enabled: bool) -> bool:
+        """Enable or disable auto return-to-center on both axes."""
+        cmd = f"{COMMAND_SET_AUTO_RECENTER}{'0101' if enabled else '0202'}"
+        result = await self._send_command(cmd)
+        return self._check_command_result(result, 0, {1})
+
+    def get_horizontal_oscillation_angle(self) -> int | None:
+        """Return cached horizontal oscillation angle (raw device byte)."""
+        return self._get_adv_value("oscillating_horizontal_angle")
+
+    def get_vertical_oscillation_angle(self) -> int | None:
+        """Return cached vertical oscillation angle (raw device byte; 90° = 95)."""
+        return self._get_adv_value("oscillating_vertical_angle")
+
     def get_night_light_state(self) -> int | None:
         """Return cached night light state."""
         return self._get_adv_value("nightLight")
+
+    def is_charging(self) -> bool | None:
+        """Return cached charging state."""
+        return self._get_adv_value("charging")
+
+    def get_child_lock(self) -> bool | None:
+        """Return cached child-lock state."""
+        return self._get_adv_value("child_lock")
+
+    def get_display(self) -> bool | None:
+        """Return cached front-display (LED) state."""
+        return self._get_adv_value("display")
+
+    def get_sound(self) -> bool | None:
+        """Return cached key-tone (buzzer) state."""
+        return self._get_adv_value("sound")
+
+    def get_auto_recenter(self) -> bool | None:
+        """Return cached auto-recenter (return-to-center) state."""
+        return self._get_adv_value("auto_recenter")

+ 106 - 1
tests/test_fan.py

@@ -4,6 +4,7 @@ import pytest
 from bleak.backends.device import BLEDevice
 
 from switchbot import SwitchBotAdvertisement, SwitchbotModel
+from switchbot.adv_parsers.fan import process_standing_fan
 from switchbot.const.fan import (
     FanMode,
     HorizontalOscillationAngle,
@@ -290,6 +291,10 @@ async def test_circulator_fan_setters_validate_success_byte(response, expected,
         lambda d: d.set_vertical_oscillation_angle(VerticalOscillationAngle.ANGLE_90),
         lambda d: d.set_night_light(NightLightState.LEVEL_1),
         lambda d: d.set_night_light(NightLightState.OFF),
+        lambda d: d.set_child_lock(True),
+        lambda d: d.set_display(False),
+        lambda d: d.set_sound(True),
+        lambda d: d.set_auto_recenter(False),
     ],
 )
 async def test_standing_fan_setters_validate_success_byte(response, expected, invoke):
@@ -446,7 +451,10 @@ async def test_standing_fan_get_basic_info(basic_info, firmware_info, result):
     standing_fan._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
 
     info = await standing_fan.get_basic_info()
-    assert info == result | {"nightLight": 3}
+    # Standing Fan adds extra keys (charging, angles, child_lock, ...); assert the
+    # core fields are a subset rather than requiring exact equality.
+    expected = result | {"nightLight": 3}
+    assert expected.items() <= info.items()
 
 
 @pytest.mark.asyncio
@@ -619,3 +627,100 @@ def test_standing_fan_get_horizontal_oscillating_state():
 def test_standing_fan_get_vertical_oscillating_state():
     standing_fan = create_standing_fan_for_testing({"oscillating_vertical": True})
     assert standing_fan.get_vertical_oscillating_state() is True
+
+
+@pytest.mark.asyncio
+async def test_standing_fan_get_basic_info_extended():
+    """The Standing Fan decodes angles, charging, child lock, etc. from status."""
+    standing_fan = create_standing_fan_for_testing({"nightLight": 2})
+    # byte: 2=battery|charge, 3=status bits, 4=h angle, 6=v angle (95=90deg),
+    # 8=mode (low nibble), 9=speed, 10=sound.
+    basic_info = bytearray(b"\x01\x02\xd5\xd3\x3c\x00\x5f\x00\x32\x32\x64")
+    firmware_info = bytearray(b"\x01W\x0b\x17\x01")
+
+    async def mock_get_basic_info(arg):
+        if arg == fan.COMMAND_GET_BASIC_INFO:
+            return basic_info
+        if arg == fan.DEVICE_GET_BASIC_SETTINGS_KEY:
+            return firmware_info
+        return None
+
+    standing_fan._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
+
+    info = await standing_fan.get_basic_info()
+    assert info["battery"] == 85
+    assert info["charging"] is True
+    assert info["isOn"] is True
+    assert info["oscillating_horizontal"] is True
+    assert info["oscillating_vertical"] is False
+    assert info["oscillating_horizontal_angle"] == 60
+    assert info["oscillating_vertical_angle"] == 95
+    assert info["child_lock"] is True
+    assert info["display"] is True
+    assert info["auto_recenter"] is True
+    assert info["sound"] is True
+    assert info["mode"] == "natural"
+    assert info["speed"] == 50
+    assert info["firmware"] == 1.1
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("invoke", "expected_cmd"),
+    [
+        (lambda d: d.set_child_lock(True), f"{fan.COMMAND_SET_CHILD_LOCK}01"),
+        (lambda d: d.set_child_lock(False), f"{fan.COMMAND_SET_CHILD_LOCK}02"),
+        (lambda d: d.set_display(True), f"{fan.COMMAND_SET_DISPLAY_LIGHT}01FFFF"),
+        (lambda d: d.set_display(False), f"{fan.COMMAND_SET_DISPLAY_LIGHT}02FFFF"),
+        (lambda d: d.set_sound(True), f"{fan.COMMAND_SET_SOUND}64"),
+        (lambda d: d.set_sound(False), f"{fan.COMMAND_SET_SOUND}00"),
+        (lambda d: d.set_auto_recenter(True), f"{fan.COMMAND_SET_AUTO_RECENTER}0101"),
+        (lambda d: d.set_auto_recenter(False), f"{fan.COMMAND_SET_AUTO_RECENTER}0202"),
+    ],
+)
+async def test_standing_fan_extra_setter_commands(invoke, expected_cmd):
+    standing_fan = create_standing_fan_for_testing()
+    await invoke(standing_fan)
+    standing_fan._send_command.assert_called_once()
+    assert standing_fan._send_command.call_args[0][0] == expected_cmd
+
+
+@pytest.mark.parametrize(
+    ("getter", "key", "value"),
+    [
+        (
+            lambda d: d.get_horizontal_oscillation_angle(),
+            "oscillating_horizontal_angle",
+            60,
+        ),
+        (
+            lambda d: d.get_vertical_oscillation_angle(),
+            "oscillating_vertical_angle",
+            95,
+        ),
+        (lambda d: d.is_charging(), "charging", True),
+        (lambda d: d.get_child_lock(), "child_lock", True),
+        (lambda d: d.get_display(), "display", False),
+        (lambda d: d.get_sound(), "sound", True),
+        (lambda d: d.get_auto_recenter(), "auto_recenter", True),
+    ],
+)
+def test_standing_fan_cached_getters(getter, key, value):
+    standing_fan = create_standing_fan_for_testing({key: value})
+    assert getter(standing_fan) == value
+
+
+@pytest.mark.parametrize(
+    ("battery_byte", "charging", "battery"),
+    [(0xD5, True, 85), (0x55, False, 85)],
+)
+def test_process_standing_fan_charging(battery_byte, charging, battery):
+    mfr_data = bytes([0, 1, 2, 3, 4, 5, 0x01, 0x80, battery_byte, 0x32])
+    result = process_standing_fan(None, mfr_data)
+    assert result["charging"] is charging
+    assert result["battery"] == battery
+
+
+def test_process_standing_fan_charging_short_payload():
+    assert process_standing_fan(None, None) == {}
+    assert process_standing_fan(None, b"\x00") == {}