From e844b3a2a1c955b100637b7d3fcc3d8b2b3de5df Mon Sep 17 00:00:00 2001 From: testdev Date: Tue, 2 Jun 2026 16:28:59 +0800 Subject: [PATCH 1/3] feat: add Circulator Fan Pro (W1160) support Add the SwitchBot Circulator Fan Pro (W1160) as an encrypted BLE device: fan power/speed/preset-mode (direct/natural/sleep/hurricane), horizontal and vertical oscillation, and a two-level night light. State is parsed from the W1071-style advertisement (battery/fan-state swapped vs the legacy fan). Co-Authored-By: Claude Opus 4.8 --- switchbot/__init__.py | 9 ++- switchbot/adv_parser.py | 18 ++++- switchbot/adv_parsers/fan.py | 51 ++++++++++++++- switchbot/const/__init__.py | 3 + switchbot/const/fan.py | 17 +++++ switchbot/devices/device.py | 1 + switchbot/devices/fan.py | 113 ++++++++++++++++++++++++++++++++ tests/test_adv_parser.py | 83 +++++++++++++++++++++++ tests/test_fan.py | 123 ++++++++++++++++++++++++++++++++++- 9 files changed, 413 insertions(+), 5 deletions(-) diff --git a/switchbot/__init__.py b/switchbot/__init__.py index bc87409e..823f9fa9 100644 --- a/switchbot/__init__.py +++ b/switchbot/__init__.py @@ -14,6 +14,7 @@ AirQualityLevel, BulbColorMode, CeilingLightColorMode, + CirculatorFanProMode, ClimateAction, ClimateMode, ColorMode, @@ -48,7 +49,11 @@ 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 ( @@ -78,6 +83,7 @@ "AirQualityLevel", "BulbColorMode", "CeilingLightColorMode", + "CirculatorFanProMode", "ClimateAction", "ClimateMode", "ColorMode", @@ -105,6 +111,7 @@ "SwitchbotBulb", "SwitchbotCandleWarmerLamp", "SwitchbotCeilingLight", + "SwitchbotCirculatorFanPro", "SwitchbotCurtain", "SwitchbotDevice", "SwitchbotEncryptedDevice", diff --git a/switchbot/adv_parser.py b/switchbot/adv_parser.py index 74b13310..42a2b9b1 100644 --- a/switchbot/adv_parser.py +++ b/switchbot/adv_parser.py @@ -20,7 +20,11 @@ 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 @@ -677,6 +681,18 @@ class SwitchbotSupportedType(TypedDict): "func": process_rgbic_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", diff --git a/switchbot/adv_parsers/fan.py b/switchbot/adv_parsers/fan.py index 3178f6e4..53cab7d6 100644 --- a/switchbot/adv_parsers/fan.py +++ b/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( @@ -53,3 +56,49 @@ 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) + + +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, + } + diff --git a/switchbot/const/__init__.py b/switchbot/const/__init__.py index d2579093..ba7cf5bb 100644 --- a/switchbot/const/__init__.py +++ b/switchbot/const/__init__.py @@ -11,6 +11,7 @@ HumidifierWaterLevel, ) from .fan import ( + CirculatorFanProMode, FanMode, HorizontalOscillationAngle, NightLightState, @@ -86,6 +87,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" @@ -132,6 +134,7 @@ class SwitchbotModel(StrEnum): "AirQualityLevel", "BulbColorMode", "CeilingLightColorMode", + "CirculatorFanProMode", "ClimateAction", "ClimateMode", "ColorMode", diff --git a/switchbot/const/fan.py b/switchbot/const/fan.py index 27c65b33..a7b11c42 100644 --- a/switchbot/const/fan.py +++ b/switchbot/const/fan.py @@ -26,6 +26,23 @@ def get_modes(cls) -> list[str]: 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.""" diff --git a/switchbot/devices/device.py b/switchbot/devices/device.py index b60368b6..ccc802f3 100644 --- a/switchbot/devices/device.py +++ b/switchbot/devices/device.py @@ -102,6 +102,7 @@ def _extract_region(userinfo: dict[str, Any]) -> str: "W1102001": SwitchbotModel.STRIP_LIGHT_3, "W1102003": SwitchbotModel.RGBICWW_STRIP_LIGHT, "W1102004": SwitchbotModel.RGBICWW_FLOOR_LAMP, + "W1160000": SwitchbotModel.CIRCULATOR_FAN_PRO, "W1104000": SwitchbotModel.PLUG_MINI_EU, "W1128000": SwitchbotModel.SMART_THERMOSTAT_RADIATOR, "W1111000": SwitchbotModel.CLIMATE_PANEL, diff --git a/switchbot/devices/fan.py b/switchbot/devices/fan.py index 49071869..a25b7edf 100644 --- a/switchbot/devices/fan.py +++ b/switchbot/devices/fan.py @@ -6,7 +6,11 @@ from enum import Enum from typing import Any, ClassVar +from bleak.backends.device import BLEDevice + +from ..const import SwitchbotModel from ..const.fan import ( + CirculatorFanProMode, FanMode, HorizontalOscillationAngle, NightLightState, @@ -15,6 +19,7 @@ ) from .device import ( DEVICE_GET_BASIC_SETTINGS_KEY, + SwitchbotEncryptedDevice, SwitchbotSequenceDevice, update_after_operation, ) @@ -177,6 +182,11 @@ def get_current_mode(self) -> Any: """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).""" @@ -224,3 +234,106 @@ async def set_night_light(self, state: NightLightState | int) -> bool: def get_night_light_state(self) -> int | None: """Return cached night light state.""" return self._get_adv_value("nightLight") + + +class SwitchbotCirculatorFanPro(SwitchbotEncryptedDevice, SwitchbotFan): + """ + Representation of a Switchbot Circulator Fan Pro (W1160). + + The Pro uses extended commands (``57 0F …``) 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. The night light command + is on/off only (the device exposes two read-only brightness levels in its + advertisement, but they are not separately settable here). + """ + + _model = SwitchbotModel.CIRCULATOR_FAN_PRO + + # Fan power: ext 0x0F, subcmd 0x41, 0x11 = 开关机, 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{}" + + def __init__( + self, + device: BLEDevice, + key_id: str, + encryption_key: str, + interface: int = 0, + model: SwitchbotModel = SwitchbotModel.CIRCULATOR_FAN_PRO, + **kwargs: Any, + ) -> None: + """Initialize the Circulator Fan Pro.""" + super().__init__(device, key_id, encryption_key, interface, model, **kwargs) + + 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, and its GATT basic-info responses differ from the + legacy Circulator Fan, so parse only the firmware here and do so + defensively. + """ + if not (_data := await self._get_basic_info(COMMAND_GET_BASIC_INFO)): + return None + if not (_data1 := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)): + return None + + _LOGGER.debug( + "Circulator Fan Pro basic info: data=%s data1=%s", + _data.hex(), + _data1.hex(), + ) + info: dict[str, Any] = {} + if len(_data1) > 2: + info["firmware"] = _data1[2] / 10.0 + return info + + @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". + """ + result = await self._send_command(f"570f4111290101{percentage:02X}") + 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") + + diff --git a/tests/test_adv_parser.py b/tests/test_adv_parser.py index fbfb6251..ad2ecc73 100644 --- a/tests/test_adv_parser.py +++ b/tests/test_adv_parser.py @@ -13,6 +13,7 @@ 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 @@ -1909,6 +1910,88 @@ 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 + + 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") diff --git a/tests/test_fan.py b/tests/test_fan.py index 4d475adf..b6eb1f8e 100644 --- a/tests/test_fan.py +++ b/tests/test_fan.py @@ -12,8 +12,8 @@ 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 @@ -384,6 +384,125 @@ 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) + ) + 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( + ("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 + + @pytest.mark.asyncio @pytest.mark.parametrize( ("basic_info", "firmware_info", "result"), From 09f24e53a2aa6ddea7a919ecb626b08a01e6728d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:34:16 +0000 Subject: [PATCH 2/3] chore(pre-commit.ci): auto fixes --- switchbot/adv_parsers/fan.py | 1 - switchbot/devices/fan.py | 2 -- tests/test_adv_parser.py | 8 ++++++-- tests/test_fan.py | 4 +--- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/switchbot/adv_parsers/fan.py b/switchbot/adv_parsers/fan.py index 53cab7d6..5c25e7c6 100644 --- a/switchbot/adv_parsers/fan.py +++ b/switchbot/adv_parsers/fan.py @@ -101,4 +101,3 @@ def process_circulator_fan_pro( "charging": _charging, "speed": _speed, } - diff --git a/switchbot/devices/fan.py b/switchbot/devices/fan.py index a25b7edf..5982a8ab 100644 --- a/switchbot/devices/fan.py +++ b/switchbot/devices/fan.py @@ -335,5 +335,3 @@ def is_night_light_on(self) -> bool | None: 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") - - diff --git a/tests/test_adv_parser.py b/tests/test_adv_parser.py index ad2ecc73..ecc5604a 100644 --- a/tests/test_adv_parser.py +++ b/tests/test_adv_parser.py @@ -1921,7 +1921,9 @@ def test_circulator_fan_pro_active() -> None: """ 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"}, + 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@" }, @@ -1962,7 +1964,9 @@ 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"}, + 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@" }, diff --git a/tests/test_fan.py b/tests/test_fan.py index b6eb1f8e..36513dbb 100644 --- a/tests/test_fan.py +++ b/tests/test_fan.py @@ -393,9 +393,7 @@ def create_circulator_fan_pro_for_testing(init_data: dict | None = None): "ffffffffffffffffffffffffffffffff", model=SwitchbotModel.CIRCULATOR_FAN_PRO, ) - fan_device.update_from_advertisement( - make_advertisement_data(ble_device, init_data) - ) + fan_device.update_from_advertisement(make_advertisement_data(ble_device, init_data)) fan_device._send_command = AsyncMock() fan_device._check_command_result = MagicMock() fan_device.update = AsyncMock() From e8b4508930497286d5bccb9dcba61e8142f94216 Mon Sep 17 00:00:00 2001 From: testdev Date: Tue, 2 Jun 2026 17:09:57 +0800 Subject: [PATCH 3/3] test: cover Circulator Fan Pro get_basic_info, fan_modes, short adv Co-Authored-By: Claude Opus 4.8 --- tests/test_adv_parser.py | 6 ++++++ tests/test_fan.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/tests/test_adv_parser.py b/tests/test_adv_parser.py index ecc5604a..be2702f1 100644 --- a/tests/test_adv_parser.py +++ b/tests/test_adv_parser.py @@ -1996,6 +1996,12 @@ def test_circulator_fan_pro_night_light( 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") diff --git a/tests/test_fan.py b/tests/test_fan.py index 36513dbb..26a6e99f 100644 --- a/tests/test_fan.py +++ b/tests/test_fan.py @@ -501,6 +501,46 @@ def test_circulator_fan_pro_light_state_getters(key, method, 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, + ) + # Both basic-info commands return the same stub; byte 2 (0x37) -> firmware 5.5. + fan_device._send_command = AsyncMock(return_value=b"\x01\x02\x37\x04") + info = await fan_device.get_basic_info() + assert info == {"firmware": 5.5} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "side_effect", + [ + [b"\x00"], # first basic-info command fails + [b"\x01\x02\x37\x04", b"\x00"], # second command fails + ], +) +async def test_circulator_fan_pro_get_basic_info_returns_none(side_effect): + 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(side_effect=side_effect) + assert await fan_device.get_basic_info() is None + + @pytest.mark.asyncio @pytest.mark.parametrize( ("basic_info", "firmware_info", "result"),