Browse Source

feat: add Circulator Fan Pro (W1160) support (#508)

Co-authored-by: Claude Opus 4.8 <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@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Onero-testdev 3 days ago
parent
commit
5c2e6c8811

+ 8 - 1
switchbot/__init__.py

@@ -14,6 +14,7 @@ from .const import (
     AirQualityLevel,
     BulbColorMode,
     CeilingLightColorMode,
+    CirculatorFanProMode,
     ClimateAction,
     ClimateMode,
     ColorMode,
@@ -48,7 +49,11 @@ from .devices.device import (
     fetch_cloud_devices,
 )
 from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
-from .devices.fan import SwitchbotFan, SwitchbotStandingFan
+from .devices.fan import (
+    SwitchbotCirculatorFanPro,
+    SwitchbotFan,
+    SwitchbotStandingFan,
+)
 from .devices.humidifier import SwitchbotHumidifier
 from .devices.keypad_vision import SwitchbotKeypadVision
 from .devices.light_strip import (
@@ -80,6 +85,7 @@ __all__ = [
     "AirQualityLevel",
     "BulbColorMode",
     "CeilingLightColorMode",
+    "CirculatorFanProMode",
     "ClimateAction",
     "ClimateMode",
     "ColorMode",
@@ -106,6 +112,7 @@ __all__ = [
     "SwitchbotBulb",
     "SwitchbotCandleWarmerLamp",
     "SwitchbotCeilingLight",
+    "SwitchbotCirculatorFanPro",
     "SwitchbotCurtain",
     "SwitchbotDevice",
     "SwitchbotEncryptedDevice",

+ 17 - 1
switchbot/adv_parser.py

@@ -20,7 +20,11 @@ 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, process_standing_fan
+from .adv_parsers.fan import (
+    process_circulator_fan_pro,
+    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
@@ -714,6 +718,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_rgbicww_ceiling_light,
         "manufacturer_id": 2409,
     },
+    b"\x00\x11\xb3@": {
+        "modelName": SwitchbotModel.CIRCULATOR_FAN_PRO,
+        "modelFriendlyName": "Circulator Fan Pro",
+        "func": process_circulator_fan_pro,
+        "manufacturer_id": 2409,
+    },
+    b"\x01\x11\xb3@": {
+        "modelName": SwitchbotModel.CIRCULATOR_FAN_PRO,
+        "modelFriendlyName": "Circulator Fan Pro",
+        "func": process_circulator_fan_pro,
+        "manufacturer_id": 2409,
+    },
     b"\x00\x10\xd0\xb7": {
         "modelName": SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
         "modelFriendlyName": "Permanent Outdoor Light",

+ 49 - 1
switchbot/adv_parsers/fan.py

@@ -2,12 +2,15 @@
 
 from __future__ import annotations
 
-from ..const.fan import FanMode, StandingFanMode
+from ..const.fan import CirculatorFanProMode, 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
 }
+_CIRCULATOR_FAN_PRO_MODE_MAP: dict[int, str] = {
+    m.value: m.name.lower() for m in CirculatorFanProMode
+}
 
 
 def _parse_fan(
@@ -60,3 +63,48 @@ def process_standing_fan(
 ) -> 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, with_charging=True)
+
+
+def process_circulator_fan_pro(
+    data: bytes | None, mfr_data: bytes | None
+) -> dict[str, bool | int | str | None]:
+    """
+    Process Circulator Fan Pro (W1160) advertisement.
+
+    The Pro shares the W1071 Modern Ceiling Fan broadcast layout, which differs
+    from the legacy Circulator Fan: battery and the fan-state byte are swapped.
+    The fan-state byte carries a two-level night light (bit2 = on/off, bit3 =
+    level: 0 high / 1 low). Byte offsets are relative to the manufacturer data,
+    after the leading 6-byte MAC.
+    """
+    if mfr_data is None or len(mfr_data) < 10:
+        return {}
+
+    device_data = mfr_data[6:]
+
+    _seq_num = device_data[0]
+    _charging = bool(device_data[1] & 0b10000000)
+    _battery = device_data[1] & 0b01111111
+    _state = device_data[2]
+    _isOn = bool(_state & 0b10000000)
+    _mode = (_state & 0b01110000) >> 4
+    _night_light_on = bool(_state & 0b00000100)
+    # bit3: 0 = level 1 (high / bright), 1 = level 2 (low / dim)
+    _night_light_level = 2 if _state & 0b00001000 else 1
+    _oscillate_left_and_right = bool(_state & 0b00000010)
+    _oscillate_up_and_down = bool(_state & 0b00000001)
+    _speed = device_data[3] & 0b01111111
+
+    return {
+        "sequence_number": _seq_num,
+        "isOn": _isOn,
+        "mode": _CIRCULATOR_FAN_PRO_MODE_MAP.get(_mode),
+        "night_light_is_on": _night_light_on,
+        "night_light_level": _night_light_level if _night_light_on else 0,
+        "oscillating": _oscillate_left_and_right or _oscillate_up_and_down,
+        "oscillating_horizontal": _oscillate_left_and_right,
+        "oscillating_vertical": _oscillate_up_and_down,
+        "battery": _battery,
+        "charging": _charging,
+        "speed": _speed,
+    }

+ 3 - 0
switchbot/const/__init__.py

@@ -11,6 +11,7 @@ from .evaporative_humidifier import (
     HumidifierWaterLevel,
 )
 from .fan import (
+    CirculatorFanProMode,
     FanMode,
     HorizontalOscillationAngle,
     NightLightState,
@@ -87,6 +88,7 @@ class SwitchbotModel(StrEnum):
     ROLLER_SHADE = "Roller Shade"
     HUBMINI_MATTER = "HubMini Matter"
     CIRCULATOR_FAN = "Circulator Fan"
+    CIRCULATOR_FAN_PRO = "Circulator Fan Pro"
     STANDING_FAN = "Standing Fan"
     K20_VACUUM = "K20 Vacuum"
     S10_VACUUM = "S10 Vacuum"
@@ -135,6 +137,7 @@ __all__ = [
     "AirQualityLevel",
     "BulbColorMode",
     "CeilingLightColorMode",
+    "CirculatorFanProMode",
     "ClimateAction",
     "ClimateMode",
     "ColorMode",

+ 17 - 0
switchbot/const/fan.py

@@ -26,6 +26,23 @@ class StandingFanMode(Enum):
         return [mode.name.lower() for mode in cls]
 
 
+class CirculatorFanProMode(Enum):
+    """
+    Circulator Fan Pro (W1160) running modes.
+
+    Mode 0x04 is hurricane, not the baby mode of the legacy fan.
+    """
+
+    NORMAL = 1
+    NATURAL = 2
+    SLEEP = 3
+    HURRICANE = 4
+
+    @classmethod
+    def get_modes(cls) -> list[str]:
+        return [mode.name.lower() for mode in cls]
+
+
 class NightLightState(Enum):
     """Standing Fan night-light command values."""
 

+ 1 - 0
switchbot/devices/device.py

@@ -105,6 +105,7 @@ API_MODEL_TO_ENUM: dict[str, SwitchbotModel] = {
     "W1102004": SwitchbotModel.RGBICWW_FLOOR_LAMP,
     "W1163000": SwitchbotModel.RGBICWW_LIGHT_BARS,
     "W1162000": SwitchbotModel.RGBICWW_CEILING_LIGHT,
+    "W1160000": SwitchbotModel.CIRCULATOR_FAN_PRO,
     "W1104000": SwitchbotModel.PLUG_MINI_EU,
     "W1128000": SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
     "W1111000": SwitchbotModel.CLIMATE_PANEL,

+ 119 - 0
switchbot/devices/fan.py

@@ -6,7 +6,9 @@ import logging
 from enum import Enum
 from typing import Any, ClassVar
 
+from ..const import SwitchbotModel
 from ..const.fan import (
+    CirculatorFanProMode,
     FanMode,
     HorizontalOscillationAngle,
     NightLightState,
@@ -15,6 +17,7 @@ from ..const.fan import (
 )
 from .device import (
     DEVICE_GET_BASIC_SETTINGS_KEY,
+    SwitchbotEncryptedDevice,
     SwitchbotSequenceDevice,
     update_after_operation,
 )
@@ -186,6 +189,11 @@ class SwitchbotFan(SwitchbotSequenceDevice):
         """Return cached mode."""
         return self._get_adv_value("mode")
 
+    @property
+    def fan_modes(self) -> list[str]:
+        """Return the supported preset (wind) modes for this device."""
+        return self._mode_enum.get_modes()
+
 
 class SwitchbotStandingFan(SwitchbotFan):
     """Representation of a Switchbot Standing Fan (FAN2)."""
@@ -309,3 +317,114 @@ class SwitchbotStandingFan(SwitchbotFan):
     def get_auto_recenter(self) -> bool | None:
         """Return cached auto-recenter (return-to-center) state."""
         return self._get_adv_value("auto_recenter")
+
+
+class SwitchbotCirculatorFanPro(SwitchbotEncryptedDevice, SwitchbotFan):
+    """
+    Representation of a Switchbot Circulator Fan Pro (W1160).
+
+    The Pro uses extended commands (``57 0F <subcmd> …``) with a control-source
+    byte (0x29 = Home Assistant), wrapped in the encrypted command shell, so it
+    extends SwitchbotEncryptedDevice. Fan power uses subcommand 0x41 (open/close
+    sub-op 0x11); the night light uses subcommand 0x96 and supports on/off plus
+    a choice between two brightness levels via ``turn_on_light(low=...)``
+    (level 1 / bright or level 2 / dim).
+    """
+
+    _model = SwitchbotModel.CIRCULATOR_FAN_PRO
+
+    # Fan power: ext 0x0F, subcmd 0x41, 0x11 = power, 0x29 = control source
+    # (Home Assistant), byte5 0x01 = on / 0x00 = off / 0x02 = toggle.
+    _turn_on_command = "570f41112901"
+    _turn_off_command = "570f41112900"
+    # Preset mode: the 0x11 power command also carries the running mode in byte6
+    # (turning the fan on). The Pro's mode 0x04 is hurricane, not the legacy baby.
+    _mode_enum: ClassVar[type[Enum]] = CirculatorFanProMode
+    _command_set_mode: ClassVar[dict[str, str]] = {
+        mode.name.lower(): f"570f41112901{mode.value:02X}"
+        for mode in CirculatorFanProMode
+    }
+    # Night light: ext 0x0F, subcmd 0x96, byte3 0x0A, byte4 0x02, then a state
+    # byte: bit0 = on/off (0 off / 1 on), bit1 = level (0 high / 1 low).
+    # off = 0x00, on high = 0x01, on low = 0x03.
+    _night_light_command = "570f960a02{}"
+    # Oscillation: ext 0x0F, subcmd 0x02, 0x29 = control source (Home Assistant),
+    # then per-axis action bytes [horizontal, vertical] where 0x01 = start,
+    # 0x02 = stop, 0xFF = keep current. The Pro is dual-axis, so the all-axes
+    # start/stop variants toggle both axes at once.
+    _command_start_oscillation: ClassVar[str] = "570f4102290101"
+    _command_stop_oscillation: ClassVar[str] = "570f4102290202"
+    _command_start_horizontal_oscillation: ClassVar[str] = "570f41022901ff"
+    _command_stop_horizontal_oscillation: ClassVar[str] = "570f41022902ff"
+    _command_start_vertical_oscillation: ClassVar[str] = "570f410229ff01"
+    _command_stop_vertical_oscillation: ClassVar[str] = "570f410229ff02"
+
+    async def get_basic_info(self) -> dict[str, Any] | None:
+        """
+        Get device basic info.
+
+        The Pro carries all runtime state (fan + night light) in its
+        advertisement, so only the firmware is read here.
+        """
+        if not (_data1 := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)):
+            return None
+        if len(_data1) <= 2:
+            return None
+        return {"firmware": _data1[2] / 10.0}
+
+    @update_after_operation
+    async def set_percentage(self, percentage: int) -> bool:
+        """
+        Set the fan speed (1-100).
+
+        Speed lives in byte7 of the 0x11 power command and only applies in
+        direct mode, so this sends "on + direct mode + speed".
+        """
+        percentage = max(1, min(100, percentage))
+        result = await self._send_command(f"570f4111290101{percentage:02X}")
+        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 = (
+            self._command_start_horizontal_oscillation
+            if oscillating
+            else self._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 = (
+            self._command_start_vertical_oscillation
+            if oscillating
+            else self._command_stop_vertical_oscillation
+        )
+        result = await self._send_command(cmd)
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def turn_on_light(self, low: bool = False) -> bool:
+        """Turn the night light on (low selects level 2 / dim, else level 1 / bright)."""
+        state = 0x03 if low else 0x01
+        result = await self._send_command(
+            self._night_light_command.format(f"{state:02X}")
+        )
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def turn_off_light(self) -> bool:
+        """Turn the night light off."""
+        result = await self._send_command(self._night_light_command.format("00"))
+        return self._check_command_result(result, 0, {1})
+
+    def is_night_light_on(self) -> bool | None:
+        """Return the cached night-light power state."""
+        return self._get_adv_value("night_light_is_on")
+
+    def get_night_light_level(self) -> int | None:
+        """Return the cached night-light level (1 high, 2 low, 0 off)."""
+        return self._get_adv_value("night_light_level")

+ 93 - 0
tests/test_adv_parser.py

@@ -13,6 +13,7 @@ from switchbot.adv_parser import (
     parse_advertisement_data,
     populate_model_to_mac_cache,
 )
+from switchbot.adv_parsers.fan import process_circulator_fan_pro
 from switchbot.const.lock import LockStatus
 from switchbot.models import SwitchBotAdvertisement
 
@@ -1963,6 +1964,98 @@ def test_circulator_fan_passive() -> None:
     )
 
 
+def test_circulator_fan_pro_active() -> None:
+    """
+    Test parsing Circulator Fan Pro (W1160) with active data.
+
+    Real W1160 capture (fan on, light off, no swing). The Pro shares the W1071
+    Modern Ceiling Fan broadcast layout (battery at offset 7, fan state at 8,
+    speed at 9, CCT light at 10, color temp at 11-12) and is routed by its own
+    4-byte service-data suffix (0x00 0x11 0xB3 0x40).
+    """
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={
+            2409: b"\xb0\xe9\xfe\xfd\xc0\xb1\x9a\xd9\x98\x0b\x00\x00\x00\x00\x00\x00"
+        },
+        service_data={
+            "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00Y\x00\x11\xb3@"
+        },
+        rssi=-97,
+    )
+    result = parse_advertisement_data(
+        ble_device, adv_data, SwitchbotModel.CIRCULATOR_FAN_PRO
+    )
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": b"\x00\x00Y\x00\x11\xb3@",
+            "data": {
+                "sequence_number": 154,
+                "isOn": True,
+                "mode": "normal",
+                "night_light_is_on": False,
+                "night_light_level": 0,
+                "oscillating": False,
+                "oscillating_horizontal": False,
+                "oscillating_vertical": False,
+                "battery": 89,
+                "charging": True,
+                "speed": 11,
+            },
+            "isEncrypted": False,
+            "model": b"\x00\x11\xb3@",
+            "modelFriendlyName": "Circulator Fan Pro",
+            "modelName": SwitchbotModel.CIRCULATOR_FAN_PRO,
+        },
+        device=ble_device,
+        rssi=-97,
+        active=True,
+    )
+
+
+def test_circulator_fan_pro_routes_by_service_data_suffix() -> None:
+    """The Pro is identified by its service-data suffix without an explicit model."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={
+            2409: b"\xb0\xe9\xfe\xfd\xc0\xb1\x9a\xd9\x98\x0b\x00\x00\x00\x00\x00\x00"
+        },
+        service_data={
+            "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00Y\x00\x11\xb3@"
+        },
+        rssi=-97,
+    )
+    result = parse_advertisement_data(ble_device, adv_data)
+    assert result is not None
+    assert result.data["modelName"] == SwitchbotModel.CIRCULATOR_FAN_PRO
+    assert result.data["modelFriendlyName"] == "Circulator Fan Pro"
+
+
+@pytest.mark.parametrize(
+    ("mfr_data", "is_on", "level"),
+    [
+        # state byte (offset 8): 0x98 off, 0x94 on/high (L1), 0x9c on/low (L2)
+        (b"\xb0\xe9\xfe\xfd\xc0\xb1\x9a\xd9\x98\x0b\x00\x00\x00\x00\x00\x00", False, 0),
+        (b"\xb0\xe9\xfe\xfd\xc0\xb1\x39\x64\x94\x0b\x00\x00\x00\x00\x00\x00", True, 1),
+        (b"\xb0\xe9\xfe\xfd\xc0\xb1\x3b\xe4\x9c\x0b\x00\x00\x00\x00\x00\x00", True, 2),
+    ],
+)
+def test_circulator_fan_pro_night_light(
+    mfr_data: bytes, is_on: bool, level: int
+) -> None:
+    """The Pro night light is parsed from the fan-state byte (bit2 on, bit3 level)."""
+    data = process_circulator_fan_pro(None, mfr_data)
+    assert data["night_light_is_on"] is is_on
+    assert data["night_light_level"] == level
+
+
+@pytest.mark.parametrize("mfr_data", [None, b"\xb0\xe9\xfe\xfd\xc0\xb1\x9a"])
+def test_circulator_fan_pro_short_data(mfr_data: bytes | None) -> None:
+    """Short or missing manufacturer data yields an empty parse."""
+    assert process_circulator_fan_pro(None, mfr_data) == {}
+
+
 def test_circulator_fan_with_empty_data() -> None:
     """Test parsing circulator fan with empty data."""
     ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")

+ 225 - 5
tests/test_fan.py

@@ -13,8 +13,8 @@ from switchbot.const.fan import (
     VerticalOscillationAngle,
 )
 from switchbot.devices import fan
-from switchbot.devices.device import SwitchbotOperationError
-from switchbot.devices.fan import SwitchbotStandingFan
+from switchbot.devices.device import SwitchbotEncryptedDevice, SwitchbotOperationError
+from switchbot.devices.fan import SwitchbotCirculatorFanPro, SwitchbotStandingFan
 
 from .test_adv_parser import generate_ble_device
 
@@ -31,7 +31,11 @@ def create_device_for_command_testing(
     return fan_device
 
 
-def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None):
+def make_advertisement_data(
+    ble_device: BLEDevice,
+    init_data: dict | None = None,
+    model: SwitchbotModel = SwitchbotModel.CIRCULATOR_FAN,
+):
     """Set advertisement data with defaults."""
     if init_data is None:
         init_data = {}
@@ -51,8 +55,8 @@ def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None
             | init_data,
             "isEncrypted": False,
             "model": ",",
-            "modelFriendlyName": "Circulator Fan",
-            "modelName": SwitchbotModel.CIRCULATOR_FAN,
+            "modelFriendlyName": model.value,
+            "modelName": model,
         },
         device=ble_device,
         rssi=-80,
@@ -389,6 +393,222 @@ async def test_standing_fan_set_preset_mode(mode):
     assert standing_fan.get_current_mode() == mode
 
 
+def create_circulator_fan_pro_for_testing(init_data: dict | None = None):
+    """Create an encrypted SwitchbotCirculatorFanPro instance for testing."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    fan_device = SwitchbotCirculatorFanPro(
+        ble_device,
+        "ff",
+        "ffffffffffffffffffffffffffffffff",
+        model=SwitchbotModel.CIRCULATOR_FAN_PRO,
+    )
+    fan_device.update_from_advertisement(
+        make_advertisement_data(
+            ble_device, init_data, model=SwitchbotModel.CIRCULATOR_FAN_PRO
+        )
+    )
+    fan_device._send_command = AsyncMock()
+    fan_device._check_command_result = MagicMock()
+    fan_device.update = AsyncMock()
+    return fan_device
+
+
+def test_circulator_fan_pro_inherits_from_switchbot_fan():
+    assert issubclass(SwitchbotCirculatorFanPro, fan.SwitchbotFan)
+
+
+def test_circulator_fan_pro_is_encrypted_device():
+    assert issubclass(SwitchbotCirculatorFanPro, SwitchbotEncryptedDevice)
+
+
+def test_circulator_fan_pro_instantiation():
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    fan_device = SwitchbotCirculatorFanPro(
+        ble_device, "ff", "ffffffffffffffffffffffffffffffff"
+    )
+    assert fan_device is not None
+    assert fan_device._model == SwitchbotModel.CIRCULATOR_FAN_PRO
+
+
+@pytest.mark.asyncio
+async def test_circulator_fan_pro_turn_on():
+    fan_device = create_circulator_fan_pro_for_testing({"isOn": True})
+    await fan_device.turn_on()
+    assert fan_device.is_on() is True
+
+
+@pytest.mark.asyncio
+async def test_circulator_fan_pro_turn_off():
+    fan_device = create_circulator_fan_pro_for_testing({"isOn": False})
+    await fan_device.turn_off()
+    assert fan_device.is_on() is False
+
+
+@pytest.mark.asyncio
+async def test_circulator_fan_pro_set_percentage():
+    fan_device = create_circulator_fan_pro_for_testing({"speed": 80})
+    await fan_device.set_percentage(80)
+    fan_device._send_command.assert_awaited_once_with("570f411129010150")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("percentage", "expected_cmd"),
+    [
+        (0, "570f411129010101"),  # clamped up to 1
+        (1, "570f411129010101"),
+        (100, "570f411129010164"),
+        (150, "570f411129010164"),  # clamped down to 100
+    ],
+)
+async def test_circulator_fan_pro_set_percentage_clamped(percentage, expected_cmd):
+    fan_device = create_circulator_fan_pro_for_testing()
+    await fan_device.set_percentage(percentage)
+    fan_device._send_command.assert_awaited_once_with(expected_cmd)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("oscillating", "expected_cmd"),
+    [
+        (True, "570f4102290101"),  # start both axes
+        (False, "570f4102290202"),  # stop both axes
+    ],
+)
+async def test_circulator_fan_pro_set_oscillation(oscillating, expected_cmd):
+    fan_device = create_circulator_fan_pro_for_testing()
+    await fan_device.set_oscillation(oscillating)
+    fan_device._send_command.assert_awaited_once_with(expected_cmd)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("oscillating", "expected_cmd"),
+    [
+        (True, "570f41022901ff"),  # start horizontal, keep vertical
+        (False, "570f41022902ff"),  # stop horizontal, keep vertical
+    ],
+)
+async def test_circulator_fan_pro_set_horizontal_oscillation(oscillating, expected_cmd):
+    fan_device = create_circulator_fan_pro_for_testing()
+    await fan_device.set_horizontal_oscillation(oscillating)
+    fan_device._send_command.assert_awaited_once_with(expected_cmd)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("oscillating", "expected_cmd"),
+    [
+        (True, "570f410229ff01"),  # keep horizontal, start vertical
+        (False, "570f410229ff02"),  # keep horizontal, stop vertical
+    ],
+)
+async def test_circulator_fan_pro_set_vertical_oscillation(oscillating, expected_cmd):
+    fan_device = create_circulator_fan_pro_for_testing()
+    await fan_device.set_vertical_oscillation(oscillating)
+    fan_device._send_command.assert_awaited_once_with(expected_cmd)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("mode", "expected_cmd"),
+    [
+        ("normal", "570f4111290101"),
+        ("natural", "570f4111290102"),
+        ("sleep", "570f4111290103"),
+        ("hurricane", "570f4111290104"),
+    ],
+)
+async def test_circulator_fan_pro_set_preset_mode(mode, expected_cmd):
+    fan_device = create_circulator_fan_pro_for_testing({"mode": mode})
+    await fan_device.set_preset_mode(mode)
+    fan_device._send_command.assert_awaited_once_with(expected_cmd)
+
+
+@pytest.mark.asyncio
+async def test_circulator_fan_pro_turn_on_sends_extended_frame():
+    fan_device = create_circulator_fan_pro_for_testing()
+    await fan_device.turn_on()
+    fan_device._send_command.assert_awaited_once_with("570f41112901")
+
+
+@pytest.mark.asyncio
+async def test_circulator_fan_pro_turn_off_sends_extended_frame():
+    fan_device = create_circulator_fan_pro_for_testing()
+    await fan_device.turn_off()
+    fan_device._send_command.assert_awaited_once_with("570f41112900")
+
+
+@pytest.mark.asyncio
+async def test_circulator_fan_pro_turn_on_light():
+    fan_device = create_circulator_fan_pro_for_testing()
+    await fan_device.turn_on_light()
+    fan_device._send_command.assert_awaited_once_with("570f960a0201")
+
+
+@pytest.mark.asyncio
+async def test_circulator_fan_pro_turn_on_light_low():
+    fan_device = create_circulator_fan_pro_for_testing()
+    await fan_device.turn_on_light(low=True)
+    fan_device._send_command.assert_awaited_once_with("570f960a0203")
+
+
+@pytest.mark.asyncio
+async def test_circulator_fan_pro_turn_off_light():
+    fan_device = create_circulator_fan_pro_for_testing()
+    await fan_device.turn_off_light()
+    fan_device._send_command.assert_awaited_once_with("570f960a0200")
+
+
+@pytest.mark.parametrize(
+    ("key", "method", "expected"),
+    [
+        ("night_light_is_on", "is_night_light_on", True),
+        ("night_light_level", "get_night_light_level", 2),
+    ],
+)
+def test_circulator_fan_pro_light_state_getters(key, method, expected):
+    fan_device = create_circulator_fan_pro_for_testing({key: expected})
+    assert getattr(fan_device, method)() == expected
+
+
+def test_circulator_fan_pro_fan_modes():
+    fan_device = create_circulator_fan_pro_for_testing()
+    assert fan_device.fan_modes == ["normal", "natural", "sleep", "hurricane"]
+
+
+@pytest.mark.asyncio
+async def test_circulator_fan_pro_get_basic_info():
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    fan_device = SwitchbotCirculatorFanPro(
+        ble_device,
+        "ff",
+        "ffffffffffffffffffffffffffffffff",
+        model=SwitchbotModel.CIRCULATOR_FAN_PRO,
+    )
+    fan_device._send_command = AsyncMock(return_value=b"\x01\x02\x37\x04")
+    info = await fan_device.get_basic_info()
+    assert info == {"firmware": 5.5}
+    fan_device._send_command.assert_called_once()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "response",
+    [b"\x00", b"\x07", b"\x01\x02"],
+)
+async def test_circulator_fan_pro_get_basic_info_returns_none(response):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    fan_device = SwitchbotCirculatorFanPro(
+        ble_device,
+        "ff",
+        "ffffffffffffffffffffffffffffffff",
+        model=SwitchbotModel.CIRCULATOR_FAN_PRO,
+    )
+    fan_device._send_command = AsyncMock(return_value=response)
+    assert await fan_device.get_basic_info() is None
+
+
 @pytest.mark.asyncio
 @pytest.mark.parametrize(
     ("basic_info", "firmware_info", "result"),