Переглянути джерело

Add SwitchBot Standing Fan (FAN2) support (#459)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
7eaves 14 годин тому
батько
коміт
89d10ae221

+ 10 - 1
switchbot/__init__.py

@@ -18,16 +18,20 @@ from .const import (
     ClimateMode,
     ColorMode,
     FanMode,
+    HorizontalOscillationAngle,
     HumidifierAction,
     HumidifierMode,
     HumidifierWaterLevel,
     LockStatus,
+    NightLightState,
     SmartThermostatRadiatorMode,
+    StandingFanMode,
     StripLightColorMode,
     SwitchbotAccountConnectionError,
     SwitchbotApiError,
     SwitchbotAuthenticationError,
     SwitchbotModel,
+    VerticalOscillationAngle,
 )
 from .devices.air_purifier import SwitchbotAirPurifier
 from .devices.art_frame import SwitchbotArtFrame
@@ -44,7 +48,7 @@ from .devices.device import (
     fetch_cloud_devices,
 )
 from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
-from .devices.fan import SwitchbotFan
+from .devices.fan import SwitchbotFan, SwitchbotStandingFan
 from .devices.humidifier import SwitchbotHumidifier
 from .devices.keypad_vision import SwitchbotKeypadVision
 from .devices.light_strip import (
@@ -78,11 +82,14 @@ __all__ = [
     "ColorMode",
     "FanMode",
     "GetSwitchbotDevices",
+    "HorizontalOscillationAngle",
     "HumidifierAction",
     "HumidifierMode",
     "HumidifierWaterLevel",
     "LockStatus",
+    "NightLightState",
     "SmartThermostatRadiatorMode",
+    "StandingFanMode",
     "StripLightColorMode",
     "SwitchBotAdvertisement",
     "Switchbot",
@@ -119,10 +126,12 @@ __all__ = [
     "SwitchbotRgbicNeonLight",
     "SwitchbotRollerShade",
     "SwitchbotSmartThermostatRadiator",
+    "SwitchbotStandingFan",
     "SwitchbotStripLight3",
     "SwitchbotSupportedType",
     "SwitchbotSupportedType",
     "SwitchbotVacuum",
+    "VerticalOscillationAngle",
     "close_stale_connections",
     "close_stale_connections_by_address",
     "fetch_cloud_devices",

+ 14 - 2
switchbot/adv_parser.py

@@ -20,7 +20,7 @@ from .adv_parsers.ceiling_light import process_woceiling
 from .adv_parsers.climate_panel import process_climate_panel
 from .adv_parsers.contact import process_wocontact
 from .adv_parsers.curtain import process_wocurtain
-from .adv_parsers.fan import process_fan
+from .adv_parsers.fan import process_fan, process_standing_fan
 from .adv_parsers.hub2 import process_wohub2
 from .adv_parsers.hub3 import process_hub3
 from .adv_parsers.hubmini_matter import process_hubmini_matter
@@ -75,7 +75,7 @@ class SwitchbotSupportedType(TypedDict):
 
     modelName: SwitchbotModel
     modelFriendlyName: str
-    func: Callable[[bytes, bytes | None], dict[str, bool | int]]
+    func: Callable[[bytes | None, bytes | None], dict[str, bool | int | str | None]]
     manufacturer_id: int | None
     manufacturer_data_length: int | None
 
@@ -833,6 +833,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_wolock_pro,
         "manufacturer_id": 2409,
     },
+    b"\x00\x11\x07\x60": {
+        "modelName": SwitchbotModel.STANDING_FAN,
+        "modelFriendlyName": "Standing Fan",
+        "func": process_standing_fan,
+        "manufacturer_id": 2409,
+    },
+    b"\x01\x11\x07\x60": {
+        "modelName": SwitchbotModel.STANDING_FAN,
+        "modelFriendlyName": "Standing Fan",
+        "func": process_standing_fan,
+        "manufacturer_id": 2409,
+    },
     b"\x00\x10\x53\xb0": {
         "modelName": SwitchbotModel.WEATHER_STATION,
         "modelFriendlyName": "Weather Station",

+ 28 - 6
switchbot/adv_parsers/fan.py

@@ -2,11 +2,18 @@
 
 from __future__ import annotations
 
-from ..const.fan import FanMode
+from ..const.fan import FanMode, StandingFanMode
 
+_FAN_MODE_MAP: dict[int, str] = {m.value: m.name.lower() for m in FanMode}
+_STANDING_FAN_MODE_MAP: dict[int, str] = {
+    m.value: m.name.lower() for m in StandingFanMode
+}
 
-def process_fan(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]:
-    """Process fan services data."""
+
+def _parse_fan(
+    mfr_data: bytes | None, mode_map: dict[int, str]
+) -> dict[str, bool | int | str | None]:
+    """Shared fan advertisement parse, parameterized on the mode map."""
     if mfr_data is None:
         return {}
 
@@ -15,7 +22,6 @@ def process_fan(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool |
     _seq_num = device_data[0]
     _isOn = bool(device_data[1] & 0b10000000)
     _mode = (device_data[1] & 0b01110000) >> 4
-    _mode = FanMode(_mode).name.lower() if 1 <= _mode <= 4 else None
     _nightLight = (device_data[1] & 0b00001100) >> 2
     _oscillate_left_and_right = bool(device_data[1] & 0b00000010)
     _oscillate_up_and_down = bool(device_data[1] & 0b00000001)
@@ -25,9 +31,25 @@ def process_fan(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool |
     return {
         "sequence_number": _seq_num,
         "isOn": _isOn,
-        "mode": _mode,
+        "mode": mode_map.get(_mode),
         "nightLight": _nightLight,
-        "oscillating": _oscillate_left_and_right | _oscillate_up_and_down,
+        "oscillating": _oscillate_left_and_right or _oscillate_up_and_down,
+        "oscillating_horizontal": _oscillate_left_and_right,
+        "oscillating_vertical": _oscillate_up_and_down,
         "battery": _battery,
         "speed": _speed,
     }
+
+
+def process_fan(
+    data: bytes | None, mfr_data: bytes | None
+) -> dict[str, bool | int | str | None]:
+    """Process Circulator Fan services data (modes 1-4)."""
+    return _parse_fan(mfr_data, _FAN_MODE_MAP)
+
+
+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)

+ 12 - 1
switchbot/const/__init__.py

@@ -10,7 +10,13 @@ from .evaporative_humidifier import (
     HumidifierMode,
     HumidifierWaterLevel,
 )
-from .fan import FanMode
+from .fan import (
+    FanMode,
+    HorizontalOscillationAngle,
+    NightLightState,
+    StandingFanMode,
+    VerticalOscillationAngle,
+)
 from .light import (
     BulbColorMode,
     CeilingLightColorMode,
@@ -80,6 +86,7 @@ class SwitchbotModel(StrEnum):
     ROLLER_SHADE = "Roller Shade"
     HUBMINI_MATTER = "HubMini Matter"
     CIRCULATOR_FAN = "Circulator Fan"
+    STANDING_FAN = "Standing Fan"
     K20_VACUUM = "K20 Vacuum"
     S10_VACUUM = "S10 Vacuum"
     K10_VACUUM = "K10+ Vacuum"
@@ -128,14 +135,18 @@ __all__ = [
     "ClimateMode",
     "ColorMode",
     "FanMode",
+    "HorizontalOscillationAngle",
     "HumidifierAction",
     "HumidifierMode",
     "HumidifierWaterLevel",
     "LockStatus",
+    "NightLightState",
     "SmartThermostatRadiatorMode",
+    "StandingFanMode",
     "StripLightColorMode",
     "SwitchbotAccountConnectionError",
     "SwitchbotApiError",
     "SwitchbotAuthenticationError",
     "SwitchbotModel",
+    "VerticalOscillationAngle",
 ]

+ 48 - 0
switchbot/const/fan.py

@@ -12,3 +12,51 @@ class FanMode(Enum):
     @classmethod
     def get_modes(cls) -> list[str]:
         return [mode.name.lower() for mode in cls]
+
+
+class StandingFanMode(Enum):
+    NORMAL = 1
+    NATURAL = 2
+    SLEEP = 3
+    BABY = 4
+    CUSTOM_NATURAL = 5
+
+    @classmethod
+    def get_modes(cls) -> list[str]:
+        return [mode.name.lower() for mode in cls]
+
+
+class NightLightState(Enum):
+    """Standing Fan night-light command values."""
+
+    LEVEL_1 = 1
+    LEVEL_2 = 2
+    OFF = 3
+
+
+class HorizontalOscillationAngle(Enum):
+    """
+    Horizontal oscillation angle command values.
+
+    For the horizontal axis the device byte is the same as the
+    user-facing angle in degrees.
+    """
+
+    ANGLE_30 = 30
+    ANGLE_60 = 60
+    ANGLE_90 = 90
+
+
+class VerticalOscillationAngle(Enum):
+    """
+    Vertical oscillation angle command values.
+
+    The Standing Fan uses a different byte encoding on the vertical axis
+    than on the horizontal one. Byte 0x5A (decimal 90) is interpreted as
+    an axis halt, so 90° maps to byte 0x5F (95). 30° and 60° match their
+    degree values.
+    """
+
+    ANGLE_30 = 30
+    ANGLE_60 = 60
+    ANGLE_90 = 95

+ 134 - 11
switchbot/devices/fan.py

@@ -3,9 +3,16 @@
 from __future__ import annotations
 
 import logging
-from typing import Any
-
-from ..const.fan import FanMode
+from enum import Enum
+from typing import Any, ClassVar
+
+from ..const.fan import (
+    FanMode,
+    HorizontalOscillationAngle,
+    NightLightState,
+    StandingFanMode,
+    VerticalOscillationAngle,
+)
 from .device import (
     DEVICE_GET_BASIC_SETTINGS_KEY,
     SwitchbotSequenceDevice,
@@ -16,14 +23,30 @@ _LOGGER = logging.getLogger(__name__)
 
 
 COMMAND_HEAD = "570f41"
+# Circulator Fan (single-axis): start/stop oscillation with V kept unchanged.
+# These also serve as the explicit horizontal-only commands since the byte
+# layout is identical.
 COMMAND_START_OSCILLATION = f"{COMMAND_HEAD}020101ff"
 COMMAND_STOP_OSCILLATION = f"{COMMAND_HEAD}020102ff"
+COMMAND_START_HORIZONTAL_OSCILLATION = COMMAND_START_OSCILLATION
+COMMAND_STOP_HORIZONTAL_OSCILLATION = COMMAND_STOP_OSCILLATION
+COMMAND_START_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff01"  # H keep, V start
+COMMAND_STOP_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff02"  # H keep, V stop
+# Standing Fan (dual-axis): start/stop both axes at once.
+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
 COMMAND_SET_MODE = {
     FanMode.NORMAL.name.lower(): f"{COMMAND_HEAD}030101ff",
     FanMode.NATURAL.name.lower(): f"{COMMAND_HEAD}030102ff",
     FanMode.SLEEP.name.lower(): f"{COMMAND_HEAD}030103",
     FanMode.BABY.name.lower(): f"{COMMAND_HEAD}030104",
 }
+COMMAND_SET_STANDING_FAN_MODE = {
+    **COMMAND_SET_MODE,
+    StandingFanMode.CUSTOM_NATURAL.name.lower(): f"{COMMAND_HEAD}030105",
+}
 COMMAND_SET_PERCENTAGE = f"{COMMAND_HEAD}0302"  #  +speed
 COMMAND_GET_BASIC_INFO = "570f428102"
 
@@ -33,6 +56,10 @@ class SwitchbotFan(SwitchbotSequenceDevice):
 
     _turn_on_command = f"{COMMAND_HEAD}0101"
     _turn_off_command = f"{COMMAND_HEAD}0102"
+    _mode_enum: ClassVar[type[Enum]] = FanMode
+    _command_set_mode: ClassVar[dict[str, str]] = COMMAND_SET_MODE
+    _command_start_oscillation: ClassVar[str] = COMMAND_START_OSCILLATION
+    _command_stop_oscillation: ClassVar[str] = COMMAND_STOP_OSCILLATION
 
     async def get_basic_info(self) -> dict[str, Any] | None:
         """Get device basic settings."""
@@ -44,20 +71,32 @@ class SwitchbotFan(SwitchbotSequenceDevice):
         _LOGGER.debug("data: %s", _data)
         battery = _data[2] & 0b01111111
         isOn = bool(_data[3] & 0b10000000)
-        oscillating = bool(_data[3] & 0b01100000)
+        oscillating_horizontal = bool(_data[3] & 0b01000000)
+        oscillating_vertical = bool(_data[3] & 0b00100000)
+        oscillating = oscillating_horizontal or oscillating_vertical
         _mode = _data[8] & 0b00000111
-        mode = FanMode(_mode).name.lower() if 1 <= _mode <= 4 else None
+        mode_enum = self._mode_enum
+        max_mode = max(m.value for m in mode_enum)
+        mode = mode_enum(_mode).name.lower() if 1 <= _mode <= max_mode else None
         speed = _data[9]
         firmware = _data1[2] / 10.0
 
-        return {
+        info: dict[str, Any] = {
             "battery": battery,
             "isOn": isOn,
             "oscillating": oscillating,
+            "oscillating_horizontal": oscillating_horizontal,
+            "oscillating_vertical": oscillating_vertical,
             "mode": mode,
             "speed": speed,
             "firmware": firmware,
         }
+        # Night light is only meaningful for models that expose it. Copy from
+        # the latest advertisement parse if the parser put it there.
+        night_light = self._get_adv_value("nightLight")
+        if night_light is not None:
+            info["nightLight"] = night_light
+        return info
 
     async def _get_basic_info(self, cmd: str) -> bytes | None:
         """Return basic info of device."""
@@ -72,19 +111,47 @@ class SwitchbotFan(SwitchbotSequenceDevice):
     @update_after_operation
     async def set_preset_mode(self, preset_mode: str) -> bool:
         """Send command to set fan preset_mode."""
-        return await self._send_command(COMMAND_SET_MODE[preset_mode])
+        result = await self._send_command(self._command_set_mode[preset_mode])
+        return self._check_command_result(result, 0, {1})
 
     @update_after_operation
     async def set_percentage(self, percentage: int) -> bool:
         """Send command to set fan percentage."""
-        return await self._send_command(f"{COMMAND_SET_PERCENTAGE}{percentage:02X}")
+        result = await self._send_command(f"{COMMAND_SET_PERCENTAGE}{percentage:02X}")
+        return self._check_command_result(result, 0, {1})
 
     @update_after_operation
     async def set_oscillation(self, oscillating: bool) -> bool:
         """Send command to set fan oscillation"""
-        if oscillating:
-            return await self._send_command(COMMAND_START_OSCILLATION)
-        return await self._send_command(COMMAND_STOP_OSCILLATION)
+        cmd = (
+            self._command_start_oscillation
+            if oscillating
+            else self._command_stop_oscillation
+        )
+        result = await self._send_command(cmd)
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def set_horizontal_oscillation(self, oscillating: bool) -> bool:
+        """Send command to set fan horizontal (left-right) oscillation only."""
+        cmd = (
+            COMMAND_START_HORIZONTAL_OSCILLATION
+            if oscillating
+            else COMMAND_STOP_HORIZONTAL_OSCILLATION
+        )
+        result = await self._send_command(cmd)
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def set_vertical_oscillation(self, oscillating: bool) -> bool:
+        """Send command to set fan vertical (up-down) oscillation only."""
+        cmd = (
+            COMMAND_START_VERTICAL_OSCILLATION
+            if oscillating
+            else COMMAND_STOP_VERTICAL_OSCILLATION
+        )
+        result = await self._send_command(cmd)
+        return self._check_command_result(result, 0, {1})
 
     def get_current_percentage(self) -> Any:
         """Return cached percentage."""
@@ -98,6 +165,62 @@ class SwitchbotFan(SwitchbotSequenceDevice):
         """Return cached oscillating."""
         return self._get_adv_value("oscillating")
 
+    def get_horizontal_oscillating_state(self) -> Any:
+        """Return cached horizontal (left-right) oscillating state."""
+        return self._get_adv_value("oscillating_horizontal")
+
+    def get_vertical_oscillating_state(self) -> Any:
+        """Return cached vertical (up-down) oscillating state."""
+        return self._get_adv_value("oscillating_vertical")
+
     def get_current_mode(self) -> Any:
         """Return cached mode."""
         return self._get_adv_value("mode")
+
+
+class SwitchbotStandingFan(SwitchbotFan):
+    """Representation of a Switchbot Standing Fan (FAN2)."""
+
+    _mode_enum: ClassVar[type[Enum]] = StandingFanMode
+    _command_set_mode: ClassVar[dict[str, str]] = COMMAND_SET_STANDING_FAN_MODE
+    _command_start_oscillation: ClassVar[str] = COMMAND_START_OSCILLATION_ALL_AXES
+    _command_stop_oscillation: ClassVar[str] = COMMAND_STOP_OSCILLATION_ALL_AXES
+
+    @update_after_operation
+    async def set_horizontal_oscillation_angle(
+        self, angle: HorizontalOscillationAngle | int
+    ) -> bool:
+        """Set horizontal oscillation angle (30 / 60 / 90 degrees)."""
+        value = HorizontalOscillationAngle(angle).value
+        cmd = f"{COMMAND_SET_OSCILLATION_PARAMS}{value:02X}FFFFFF"
+        result = await self._send_command(cmd)
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def set_vertical_oscillation_angle(
+        self, angle: VerticalOscillationAngle | int
+    ) -> bool:
+        """
+        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),
+        which the firmware interprets as an axis halt. Use
+        `VerticalOscillationAngle` (or the raw byte values 30 / 60 / 95).
+        """
+        value = VerticalOscillationAngle(angle).value
+        cmd = f"{COMMAND_SET_OSCILLATION_PARAMS}FFFF{value:02X}FF"
+        result = await self._send_command(cmd)
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def set_night_light(self, state: NightLightState | int) -> bool:
+        """Set night-light state (LEVEL_1, LEVEL_2, OFF)."""
+        value = NightLightState(state).value
+        cmd = f"{COMMAND_SET_NIGHT_LIGHT}{value:02X}FFFF"
+        result = await self._send_command(cmd)
+        return self._check_command_result(result, 0, {1})
+
+    def get_night_light_state(self) -> int | None:
+        """Return cached night light state."""
+        return self._get_adv_value("nightLight")

+ 34 - 0
tests/test_adv_parser.py

@@ -1827,6 +1827,8 @@ def test_circulator_fan_active() -> None:
                 "mode": "baby",
                 "nightLight": 3,
                 "oscillating": False,
+                "oscillating_horizontal": False,
+                "oscillating_vertical": False,
                 "battery": 82,
                 "speed": 57,
             },
@@ -1861,6 +1863,8 @@ def test_circulator_fan_passive() -> None:
                 "mode": "baby",
                 "nightLight": 3,
                 "oscillating": False,
+                "oscillating_horizontal": False,
+                "oscillating_vertical": False,
                 "battery": 82,
                 "speed": 57,
             },
@@ -1900,6 +1904,36 @@ def test_circulator_fan_with_empty_data() -> None:
     )
 
 
+def test_standing_fan_custom_natural_with_per_axis_oscillation() -> None:
+    """
+    Standing Fan in CUSTOM_NATURAL (mode 5) + both axes oscillating.
+
+    Uses a realistic 7-byte service_data ending with the 4-byte
+    STANDING_FAN suffix so auto-detection (no explicit model arg)
+    resolves the model via `_find_model_from_service_data_suffix`.
+    """
+    # Mode field is bits [6:4] of device_data[1].
+    # Mode 5 (custom_natural) = 0b0101 0000, plus isOn=0b1000_0000, plus
+    # horizontal=0b10, vertical=0b01 → 0b1101 0011 = 0xD3.
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={2409: b"\xb0\xe9\xfe\x01\x02\x03~\xd3R9"},
+        service_data={
+            "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x11\x07\x60"
+        },
+        rssi=-97,
+    )
+    # No explicit model — exercises suffix-based auto-detection.
+    result = parse_advertisement_data(ble_device, adv_data)
+    assert result is not None
+    assert result.data["modelName"] == SwitchbotModel.STANDING_FAN
+    data = result.data["data"]
+    assert data["mode"] == "custom_natural"
+    assert data["oscillating"] is True
+    assert data["oscillating_horizontal"] is True
+    assert data["oscillating_vertical"] is True
+
+
 def test_k20_active() -> None:
     """Test parsing k20 with active data."""
     ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")

+ 445 - 1
tests/test_fan.py

@@ -4,8 +4,16 @@ import pytest
 from bleak.backends.device import BLEDevice
 
 from switchbot import SwitchBotAdvertisement, SwitchbotModel
-from switchbot.const.fan import FanMode
+from switchbot.const.fan import (
+    FanMode,
+    HorizontalOscillationAngle,
+    NightLightState,
+    StandingFanMode,
+    VerticalOscillationAngle,
+)
 from switchbot.devices import fan
+from switchbot.devices.device import SwitchbotOperationError
+from switchbot.devices.fan import SwitchbotStandingFan
 
 from .test_adv_parser import generate_ble_device
 
@@ -159,6 +167,147 @@ async def test_set_oscillation():
     assert fan_device.get_oscillating_state() is True
 
 
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("oscillating", "expected_cmd"),
+    [
+        (True, fan.COMMAND_START_OSCILLATION),
+        (False, fan.COMMAND_STOP_OSCILLATION),
+    ],
+)
+async def test_circulator_fan_set_oscillation_command(oscillating, expected_cmd):
+    """Circulator Fan keeps the original single-axis (V kept) payload."""
+    fan_device = create_device_for_command_testing({"oscillating": oscillating})
+    await fan_device.set_oscillation(oscillating)
+    fan_device._send_command.assert_called_once()
+    cmd = fan_device._send_command.call_args[0][0]
+    assert cmd == expected_cmd
+
+
+def test_circulator_fan_oscillation_command_constants():
+    """Lock the bytes for the Circulator Fan oscillation commands."""
+    # These are master-version bytes preserved for backward compatibility.
+    assert fan.COMMAND_START_OSCILLATION == "570f41020101ff"
+    assert fan.COMMAND_STOP_OSCILLATION == "570f41020102ff"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("oscillating", "expected_cmd"),
+    [
+        (True, fan.COMMAND_START_OSCILLATION_ALL_AXES),
+        (False, fan.COMMAND_STOP_OSCILLATION_ALL_AXES),
+    ],
+)
+async def test_standing_fan_set_oscillation_command(oscillating, expected_cmd):
+    """Standing Fan oscillation toggles both axes at once."""
+    standing_fan = create_standing_fan_for_testing({"oscillating": oscillating})
+    await standing_fan.set_oscillation(oscillating)
+    standing_fan._send_command.assert_called_once()
+    cmd = standing_fan._send_command.call_args[0][0]
+    assert cmd == expected_cmd
+
+
+def test_standing_fan_oscillation_command_constants():
+    """Lock the bytes for the Standing Fan dual-axis oscillation commands."""
+    assert fan.COMMAND_START_OSCILLATION_ALL_AXES == "570f4102010101"
+    assert fan.COMMAND_STOP_OSCILLATION_ALL_AXES == "570f4102010202"
+
+
+def _fan_with_real_result_check(init_data: dict | None = None):
+    """
+    Command-test fixture that uses the real _check_command_result.
+
+    Unlike `create_device_for_command_testing`, this keeps the real
+    `_check_command_result` so setter methods exercise the success-byte
+    validation path.
+    """
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    fan_device = fan.SwitchbotFan(ble_device, model=SwitchbotModel.CIRCULATOR_FAN)
+    fan_device.update_from_advertisement(make_advertisement_data(ble_device, init_data))
+    fan_device._send_command = AsyncMock()
+    fan_device.update = AsyncMock()
+    return fan_device
+
+
+def _standing_fan_with_real_result_check(init_data: dict | None = None):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    standing_fan = SwitchbotStandingFan(ble_device, model=SwitchbotModel.STANDING_FAN)
+    standing_fan.update_from_advertisement(
+        make_advertisement_data(ble_device, init_data)
+    )
+    standing_fan._send_command = AsyncMock()
+    standing_fan.update = AsyncMock()
+    return standing_fan
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("response", "expected"),
+    [
+        # Success byte is 1.
+        (b"\x01", True),
+        (b"\x01\xff", True),
+        # Known fan error payloads.
+        (b"\x00", False),
+        (b"\x07", False),
+    ],
+)
+@pytest.mark.parametrize(
+    "invoke",
+    [
+        lambda d: d.set_preset_mode("baby"),
+        lambda d: d.set_percentage(80),
+        lambda d: d.set_oscillation(True),
+        lambda d: d.set_oscillation(False),
+        lambda d: d.set_horizontal_oscillation(True),
+        lambda d: d.set_vertical_oscillation(True),
+    ],
+)
+async def test_circulator_fan_setters_validate_success_byte(response, expected, invoke):
+    """Every Circulator Fan setter returns True only on success-byte 1."""
+    device = _fan_with_real_result_check()
+    device._send_command.return_value = response
+    assert await invoke(device) is expected
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("response", "expected"),
+    [
+        (b"\x01", True),
+        (b"\x01\xff", True),
+        (b"\x00", False),
+        (b"\x07", False),
+    ],
+)
+@pytest.mark.parametrize(
+    "invoke",
+    [
+        lambda d: d.set_horizontal_oscillation_angle(
+            HorizontalOscillationAngle.ANGLE_60
+        ),
+        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),
+    ],
+)
+async def test_standing_fan_setters_validate_success_byte(response, expected, invoke):
+    """Every Standing Fan setter returns True only on success-byte 1."""
+    device = _standing_fan_with_real_result_check()
+    device._send_command.return_value = response
+    assert await invoke(device) is expected
+
+
+@pytest.mark.asyncio
+async def test_fan_setter_raises_on_none_response():
+    """None responses raise SwitchbotOperationError via _check_command_result."""
+    device = _fan_with_real_result_check()
+    device._send_command.return_value = None
+    with pytest.raises(SwitchbotOperationError):
+        await device.set_oscillation(True)
+
+
 @pytest.mark.asyncio
 async def test_turn_on():
     fan_device = create_device_for_command_testing({"isOn": True})
@@ -175,3 +324,298 @@ async def test_turn_off():
 
 def test_get_modes():
     assert FanMode.get_modes() == ["normal", "natural", "sleep", "baby"]
+
+
+def create_standing_fan_for_testing(init_data: dict | None = None):
+    """Create a SwitchbotStandingFan instance for command testing."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    standing_fan = SwitchbotStandingFan(ble_device, model=SwitchbotModel.STANDING_FAN)
+    standing_fan.update_from_advertisement(
+        make_advertisement_data(ble_device, init_data)
+    )
+    standing_fan._send_command = AsyncMock()
+    standing_fan._check_command_result = MagicMock()
+    standing_fan.update = AsyncMock()
+    return standing_fan
+
+
+def test_standing_fan_inherits_from_switchbot_fan():
+    assert issubclass(SwitchbotStandingFan, fan.SwitchbotFan)
+
+
+def test_standing_fan_instantiation():
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    standing_fan = SwitchbotStandingFan(ble_device, model=SwitchbotModel.STANDING_FAN)
+    assert standing_fan is not None
+
+
+def test_standing_fan_get_modes():
+    assert StandingFanMode.get_modes() == [
+        "normal",
+        "natural",
+        "sleep",
+        "baby",
+        "custom_natural",
+    ]
+
+
+@pytest.mark.asyncio
+async def test_standing_fan_turn_on():
+    standing_fan = create_standing_fan_for_testing({"isOn": True})
+    await standing_fan.turn_on()
+    assert standing_fan.is_on() is True
+
+
+@pytest.mark.asyncio
+async def test_standing_fan_turn_off():
+    standing_fan = create_standing_fan_for_testing({"isOn": False})
+    await standing_fan.turn_off()
+    assert standing_fan.is_on() is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "mode",
+    ["normal", "natural", "sleep", "baby", "custom_natural"],
+)
+async def test_standing_fan_set_preset_mode(mode):
+    standing_fan = create_standing_fan_for_testing({"mode": mode})
+    await standing_fan.set_preset_mode(mode)
+    assert standing_fan.get_current_mode() == mode
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("basic_info", "firmware_info", "result"),
+    [
+        (
+            bytearray(b"\x01\x02W\x82g\xf5\xde4\x01=dPP\x03\x14P\x00\x00\x00\x00"),
+            bytearray(b"\x01W\x0b\x17\x01"),
+            {
+                "battery": 87,
+                "isOn": True,
+                "oscillating": False,
+                "oscillating_horizontal": False,
+                "oscillating_vertical": False,
+                "mode": "normal",
+                "speed": 61,
+                "firmware": 1.1,
+            },
+        ),
+        (
+            bytearray(b"\x01\x02U\xc2g\xf5\xde4\x04+dPP\x03\x14P\x00\x00\x00\x00"),
+            bytearray(b"\x01U\x0b\x17\x01"),
+            {
+                "battery": 85,
+                "isOn": True,
+                "oscillating": True,
+                "oscillating_horizontal": True,
+                "oscillating_vertical": False,
+                "mode": "baby",
+                "speed": 43,
+                "firmware": 1.1,
+            },
+        ),
+        (
+            bytearray(b"\x01\x02U\xe2g\xf5\xde4\x05+dPP\x03\x14P\x00\x00\x00\x00"),
+            bytearray(b"\x01U\x0b\x17\x01"),
+            {
+                "battery": 85,
+                "isOn": True,
+                "oscillating": True,
+                "oscillating_horizontal": True,
+                "oscillating_vertical": True,
+                "mode": "custom_natural",
+                "speed": 43,
+                "firmware": 1.1,
+            },
+        ),
+    ],
+)
+async def test_standing_fan_get_basic_info(basic_info, firmware_info, result):
+    # Preload nightLight via the fixture adv data so get_basic_info can surface it.
+    standing_fan = create_standing_fan_for_testing({"nightLight": 3})
+
+    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 == result | {"nightLight": 3}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("basic_info", "firmware_info"),
+    [(True, False), (False, True), (False, False)],
+)
+async def test_standing_fan_get_basic_info_returns_none(basic_info, firmware_info):
+    standing_fan = create_standing_fan_for_testing()
+
+    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)
+
+    assert await standing_fan.get_basic_info() is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "angle",
+    [
+        HorizontalOscillationAngle.ANGLE_30,
+        HorizontalOscillationAngle.ANGLE_60,
+        HorizontalOscillationAngle.ANGLE_90,
+    ],
+)
+async def test_standing_fan_set_horizontal_oscillation_angle(angle):
+    standing_fan = create_standing_fan_for_testing()
+    await standing_fan.set_horizontal_oscillation_angle(angle)
+    standing_fan._send_command.assert_called_once()
+    cmd = standing_fan._send_command.call_args[0][0]
+    assert cmd == f"{fan.COMMAND_SET_OSCILLATION_PARAMS}{angle.value:02X}FFFFFF"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("angle", [30, 60, 90])
+async def test_standing_fan_set_horizontal_oscillation_angle_int(angle):
+    """Raw int inputs are coerced through HorizontalOscillationAngle(angle)."""
+    standing_fan = create_standing_fan_for_testing()
+    await standing_fan.set_horizontal_oscillation_angle(angle)
+    cmd = standing_fan._send_command.call_args[0][0]
+    assert cmd == f"{fan.COMMAND_SET_OSCILLATION_PARAMS}{angle:02X}FFFFFF"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("angle", [0, 45, 120, -1])
+async def test_standing_fan_set_horizontal_oscillation_angle_invalid(angle):
+    standing_fan = create_standing_fan_for_testing()
+    with pytest.raises(ValueError, match="is not a valid"):
+        await standing_fan.set_horizontal_oscillation_angle(angle)
+    standing_fan._send_command.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "angle",
+    [
+        VerticalOscillationAngle.ANGLE_30,
+        VerticalOscillationAngle.ANGLE_60,
+        # Vertical 90° maps to byte 0x5F (95); byte 0x5A (90) halts the axis.
+        VerticalOscillationAngle.ANGLE_90,
+    ],
+)
+async def test_standing_fan_set_vertical_oscillation_angle(angle):
+    standing_fan = create_standing_fan_for_testing()
+    await standing_fan.set_vertical_oscillation_angle(angle)
+    standing_fan._send_command.assert_called_once()
+    cmd = standing_fan._send_command.call_args[0][0]
+    assert cmd == f"{fan.COMMAND_SET_OSCILLATION_PARAMS}FFFF{angle.value:02X}FF"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("byte_value", [30, 60, 95])
+async def test_standing_fan_set_vertical_oscillation_angle_int(byte_value):
+    """Raw-int callers pass the device byte value (30 / 60 / 95)."""
+    standing_fan = create_standing_fan_for_testing()
+    await standing_fan.set_vertical_oscillation_angle(byte_value)
+    cmd = standing_fan._send_command.call_args[0][0]
+    assert cmd == f"{fan.COMMAND_SET_OSCILLATION_PARAMS}FFFF{byte_value:02X}FF"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("angle", [0, 45, 120, -1])
+async def test_standing_fan_set_vertical_oscillation_angle_invalid(angle):
+    standing_fan = create_standing_fan_for_testing()
+    with pytest.raises(ValueError, match="is not a valid"):
+        await standing_fan.set_vertical_oscillation_angle(angle)
+    standing_fan._send_command.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "state",
+    [NightLightState.LEVEL_1, NightLightState.LEVEL_2, NightLightState.OFF],
+)
+async def test_standing_fan_set_night_light(state):
+    standing_fan = create_standing_fan_for_testing()
+    await standing_fan.set_night_light(state)
+    standing_fan._send_command.assert_called_once()
+    cmd = standing_fan._send_command.call_args[0][0]
+    assert cmd == f"{fan.COMMAND_SET_NIGHT_LIGHT}{state.value:02X}FFFF"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("state", [1, 2, 3])
+async def test_standing_fan_set_night_light_int(state):
+    """Raw int inputs are coerced through NightLightState(state)."""
+    standing_fan = create_standing_fan_for_testing()
+    await standing_fan.set_night_light(state)
+    cmd = standing_fan._send_command.call_args[0][0]
+    assert cmd == f"{fan.COMMAND_SET_NIGHT_LIGHT}{state:02X}FFFF"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("state", [0, 4, 99, -1])
+async def test_standing_fan_set_night_light_invalid(state):
+    standing_fan = create_standing_fan_for_testing()
+    with pytest.raises(ValueError, match="is not a valid"):
+        await standing_fan.set_night_light(state)
+    standing_fan._send_command.assert_not_called()
+
+
+def test_standing_fan_get_night_light_state():
+    standing_fan = create_standing_fan_for_testing({"nightLight": 1})
+    assert standing_fan.get_night_light_state() == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("oscillating", "expected_cmd"),
+    [
+        (True, fan.COMMAND_START_HORIZONTAL_OSCILLATION),
+        (False, fan.COMMAND_STOP_HORIZONTAL_OSCILLATION),
+    ],
+)
+async def test_standing_fan_set_horizontal_oscillation(oscillating, expected_cmd):
+    standing_fan = create_standing_fan_for_testing({"oscillating": oscillating})
+    await standing_fan.set_horizontal_oscillation(oscillating)
+    standing_fan._send_command.assert_called_once()
+    cmd = standing_fan._send_command.call_args[0][0]
+    assert cmd == expected_cmd
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("oscillating", "expected_cmd"),
+    [
+        (True, fan.COMMAND_START_VERTICAL_OSCILLATION),
+        (False, fan.COMMAND_STOP_VERTICAL_OSCILLATION),
+    ],
+)
+async def test_standing_fan_set_vertical_oscillation(oscillating, expected_cmd):
+    standing_fan = create_standing_fan_for_testing({"oscillating": oscillating})
+    await standing_fan.set_vertical_oscillation(oscillating)
+    standing_fan._send_command.assert_called_once()
+    cmd = standing_fan._send_command.call_args[0][0]
+    assert cmd == expected_cmd
+
+
+def test_standing_fan_get_horizontal_oscillating_state():
+    standing_fan = create_standing_fan_for_testing({"oscillating_horizontal": True})
+    assert standing_fan.get_horizontal_oscillating_state() is True
+
+
+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