From bab19f979768a020b99671cd3d9877a28c50dab7 Mon Sep 17 00:00:00 2001 From: Fan Kai Date: Fri, 6 Mar 2026 17:29:43 +0800 Subject: [PATCH 1/5] feat: add SwitchBot Standing Fan (FAN2) support - Add SwitchbotStandingFan device class with 5 preset modes - Support independent horizontal/vertical oscillation control with angle selection - Add night light control (off/level_1/level_2) - Register Standing Fan BLE advertisement IDs - Add unit tests for fan instantiation and on/off Co-Authored-By: Claude Opus 4.6 --- switchbot/__init__.py | 5 +- switchbot/adv_parser.py | 12 ++++ switchbot/const/__init__.py | 4 +- switchbot/const/fan.py | 12 ++++ switchbot/devices/fan.py | 108 ++++++++++++++++++++++++++++++++++-- tests/test_fan.py | 35 ++++++++++++ 6 files changed, 170 insertions(+), 6 deletions(-) diff --git a/switchbot/__init__.py b/switchbot/__init__.py index f5981fb6..12748e9f 100644 --- a/switchbot/__init__.py +++ b/switchbot/__init__.py @@ -22,6 +22,7 @@ HumidifierWaterLevel, LockStatus, SmartThermostatRadiatorMode, + StandingFanMode, StripLightColorMode, SwitchbotAccountConnectionError, SwitchbotApiError, @@ -43,7 +44,7 @@ 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 ( @@ -79,6 +80,7 @@ "HumidifierWaterLevel", "LockStatus", "SmartThermostatRadiatorMode", + "StandingFanMode", "StripLightColorMode", "SwitchBotAdvertisement", "Switchbot", @@ -97,6 +99,7 @@ "SwitchbotEncryptedDevice", "SwitchbotEvaporativeHumidifier", "SwitchbotFan", + "SwitchbotStandingFan", "SwitchbotGarageDoorOpener", "SwitchbotHumidifier", "SwitchbotKeypadVision", diff --git a/switchbot/adv_parser.py b/switchbot/adv_parser.py index 7c121029..010a0d87 100644 --- a/switchbot/adv_parser.py +++ b/switchbot/adv_parser.py @@ -791,6 +791,18 @@ class SwitchbotSupportedType(TypedDict): "func": process_wolock_pro, "manufacturer_id": 2409, }, + b"\x00\x11\x07\x60": { + "modelName": SwitchbotModel.STANDING_FAN, + "modelFriendlyName": "Standing Fan", + "func": process_fan, + "manufacturer_id": 2409, + }, + b"\x01\x11\x07\x60": { + "modelName": SwitchbotModel.STANDING_FAN, + "modelFriendlyName": "Standing Fan", + "func": process_fan, + "manufacturer_id": 2409, + }, } _SWITCHBOT_MODEL_TO_CHAR: defaultdict[SwitchbotModel, list[str | bytes]] = defaultdict( diff --git a/switchbot/const/__init__.py b/switchbot/const/__init__.py index 4e6e2512..5d810481 100644 --- a/switchbot/const/__init__.py +++ b/switchbot/const/__init__.py @@ -10,7 +10,7 @@ HumidifierMode, HumidifierWaterLevel, ) -from .fan import FanMode +from .fan import FanMode, StandingFanMode from .light import ( BulbColorMode, CeilingLightColorMode, @@ -80,6 +80,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" @@ -121,6 +122,7 @@ class SwitchbotModel(StrEnum): "ClimateMode", "ColorMode", "FanMode", + "StandingFanMode", "HumidifierAction", "HumidifierMode", "HumidifierWaterLevel", diff --git a/switchbot/const/fan.py b/switchbot/const/fan.py index 3cca3577..088ec039 100644 --- a/switchbot/const/fan.py +++ b/switchbot/const/fan.py @@ -12,3 +12,15 @@ 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] diff --git a/switchbot/devices/fan.py b/switchbot/devices/fan.py index 5ad45e93..8f01efad 100644 --- a/switchbot/devices/fan.py +++ b/switchbot/devices/fan.py @@ -5,7 +5,7 @@ import logging from typing import Any -from ..const.fan import FanMode +from ..const.fan import FanMode, StandingFanMode from .device import ( DEVICE_GET_BASIC_SETTINGS_KEY, SwitchbotSequenceDevice, @@ -16,8 +16,12 @@ COMMAND_HEAD = "570f41" -COMMAND_START_OSCILLATION = f"{COMMAND_HEAD}020101ff" -COMMAND_STOP_OSCILLATION = f"{COMMAND_HEAD}020102ff" +COMMAND_START_OSCILLATION = f"{COMMAND_HEAD}02010101" # 左右+上下同时启动 +COMMAND_STOP_OSCILLATION = f"{COMMAND_HEAD}02010202" # 左右+上下同时停止 +COMMAND_START_HORIZONTAL_OSCILLATION = f"{COMMAND_HEAD}020101ff" # 仅启动左右,上下保持 +COMMAND_STOP_HORIZONTAL_OSCILLATION = f"{COMMAND_HEAD}020102ff" # 仅停止左右,上下保持 +COMMAND_START_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff01" # 左右保持,仅启动上下 +COMMAND_STOP_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff02" # 左右保持,仅停止上下 COMMAND_SET_MODE = { FanMode.NORMAL.name.lower(): f"{COMMAND_HEAD}030101ff", FanMode.NATURAL.name.lower(): f"{COMMAND_HEAD}030102ff", @@ -44,7 +48,9 @@ async def get_basic_info(self) -> dict[str, Any] | None: _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 speed = _data[9] @@ -54,6 +60,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: "battery": battery, "isOn": isOn, "oscillating": oscillating, + "oscillating_horizontal": oscillating_horizontal, + "oscillating_vertical": oscillating_vertical, "mode": mode, "speed": speed, "firmware": firmware, @@ -86,6 +94,20 @@ async def set_oscillation(self, oscillating: bool) -> bool: return await self._send_command(COMMAND_START_OSCILLATION) return await self._send_command(COMMAND_STOP_OSCILLATION) + @update_after_operation + async def set_horizontal_oscillation(self, oscillating: bool) -> bool: + """Send command to set fan horizontal (left-right) oscillation only.""" + if oscillating: + return await self._send_command(COMMAND_START_HORIZONTAL_OSCILLATION) + return await self._send_command(COMMAND_STOP_HORIZONTAL_OSCILLATION) + + @update_after_operation + async def set_vertical_oscillation(self, oscillating: bool) -> bool: + """Send command to set fan vertical (up-down) oscillation only.""" + if oscillating: + return await self._send_command(COMMAND_START_VERTICAL_OSCILLATION) + return await self._send_command(COMMAND_STOP_VERTICAL_OSCILLATION) + def get_current_percentage(self) -> Any: """Return cached percentage.""" return self._get_adv_value("speed") @@ -98,6 +120,84 @@ def get_oscillating_state(self) -> Any: """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.""" + + COMMAND_SET_MODE = { + StandingFanMode.NORMAL.name.lower(): f"{COMMAND_HEAD}030101ff", + StandingFanMode.NATURAL.name.lower(): f"{COMMAND_HEAD}030102ff", + StandingFanMode.SLEEP.name.lower(): f"{COMMAND_HEAD}030103", + StandingFanMode.BABY.name.lower(): f"{COMMAND_HEAD}030104", + StandingFanMode.CUSTOM_NATURAL.name.lower(): f"{COMMAND_HEAD}030105", + } + COMMAND_SET_OSCILLATION_PARAMS = f"{COMMAND_HEAD}0202" + COMMAND_SET_NIGHT_LIGHT = f"{COMMAND_HEAD}0502" + + @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(self.COMMAND_SET_MODE[preset_mode]) + + async def get_basic_info(self) -> dict[str, Any] | None: + """Get device basic settings.""" + 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("data: %s", _data) + battery = _data[2] & 0b01111111 + isOn = bool(_data[3] & 0b10000000) + oscillating_horizontal = bool(_data[3] & 0b01000000) + oscillating_vertical = bool(_data[3] & 0b00100000) + oscillating = oscillating_horizontal or oscillating_vertical + _mode = _data[8] & 0b00000111 + mode = StandingFanMode(_mode).name.lower() if 1 <= _mode <= 5 else None + speed = _data[9] + firmware = _data1[2] / 10.0 + + return { + "battery": battery, + "isOn": isOn, + "oscillating": oscillating, + "oscillating_horizontal": oscillating_horizontal, + "oscillating_vertical": oscillating_vertical, + "mode": mode, + "speed": speed, + "firmware": firmware, + } + + @update_after_operation + async def set_horizontal_oscillation_angle(self, angle: int) -> bool: + """Set horizontal oscillation angle (30/60/90).""" + cmd = f"{self.COMMAND_SET_OSCILLATION_PARAMS}{angle:02X}FFFFFF" + return await self._send_command(cmd) + + @update_after_operation + async def set_vertical_oscillation_angle(self, angle: int) -> bool: + """Set vertical oscillation angle (30/60/90).""" + cmd = f"{self.COMMAND_SET_OSCILLATION_PARAMS}FFFF{angle:02X}FF" + return await self._send_command(cmd) + + @update_after_operation + async def set_night_light(self, state: int) -> bool: + """Set night light state. 1=level1, 2=level2, 3=off.""" + cmd = f"{self.COMMAND_SET_NIGHT_LIGHT}{state:02X}FFFF" + return await self._send_command(cmd) + + def get_night_light_state(self) -> int | None: + """Return cached night light state.""" + return self._get_adv_value("nightLight") diff --git a/tests/test_fan.py b/tests/test_fan.py index 777a1937..7db86f45 100644 --- a/tests/test_fan.py +++ b/tests/test_fan.py @@ -6,6 +6,7 @@ from switchbot import SwitchBotAdvertisement, SwitchbotModel from switchbot.const.fan import FanMode from switchbot.devices import fan +from switchbot.devices.fan import SwitchbotStandingFan from .test_adv_parser import generate_ble_device @@ -175,3 +176,37 @@ async def test_turn_off(): def test_get_modes(): assert FanMode.get_modes() == ["normal", "natural", "sleep", "baby"] + + +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 + + +@pytest.mark.asyncio +async def test_standing_fan_turn_on(): + 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, {"isOn": True})) + standing_fan._send_command = AsyncMock() + standing_fan._check_command_result = MagicMock() + standing_fan.update = AsyncMock() + await standing_fan.turn_on() + assert standing_fan.is_on() is True + + +@pytest.mark.asyncio +async def test_standing_fan_turn_off(): + 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, {"isOn": False})) + standing_fan._send_command = AsyncMock() + standing_fan._check_command_result = MagicMock() + standing_fan.update = AsyncMock() + await standing_fan.turn_off() + assert standing_fan.is_on() is False From 9b8171cd301f82732013fe091530ae2decdce555 Mon Sep 17 00:00:00 2001 From: Fan Kai Date: Fri, 6 Mar 2026 17:37:56 +0800 Subject: [PATCH 2/5] test: add comprehensive unit tests for SwitchBot Standing Fan - Test all 5 preset modes including custom_natural - Test get_basic_info parsing with mode=5 (custom_natural) and oscillation bits - Test horizontal/vertical oscillation angle setting (30/60/90) - Test night light control (off/level_1/level_2) - Test independent horizontal/vertical oscillation commands Co-Authored-By: Claude Opus 4.6 --- tests/test_fan.py | 198 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 185 insertions(+), 13 deletions(-) diff --git a/tests/test_fan.py b/tests/test_fan.py index 7db86f45..f07b46b0 100644 --- a/tests/test_fan.py +++ b/tests/test_fan.py @@ -4,7 +4,7 @@ from bleak.backends.device import BLEDevice from switchbot import SwitchBotAdvertisement, SwitchbotModel -from switchbot.const.fan import FanMode +from switchbot.const.fan import FanMode, StandingFanMode from switchbot.devices import fan from switchbot.devices.fan import SwitchbotStandingFan @@ -178,6 +178,21 @@ 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) @@ -188,25 +203,182 @@ def test_standing_fan_instantiation(): 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(): - 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, {"isOn": True})) - standing_fan._send_command = AsyncMock() - standing_fan._check_command_result = MagicMock() - standing_fan.update = AsyncMock() + 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(): - 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, {"isOn": False})) - standing_fan._send_command = AsyncMock() - standing_fan._check_command_result = MagicMock() - standing_fan.update = AsyncMock() + 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): + 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) + + info = await standing_fan.get_basic_info() + assert info == result + + +@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", [30, 60, 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"570f410202{angle:02X}FFFFFF" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("angle", [30, 60, 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"570f410202FFFF{angle:02X}FF" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("state", "label"), + [(1, "level_1"), (2, "level_2"), (3, "off")], +) +async def test_standing_fan_set_night_light(state, label): + 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"570f410502{state:02X}FFFF" + + +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 +async def test_standing_fan_set_horizontal_oscillation(): + standing_fan = create_standing_fan_for_testing({"oscillating": True}) + await standing_fan.set_horizontal_oscillation(True) + standing_fan._send_command.assert_called_once() + cmd = standing_fan._send_command.call_args[0][0] + assert cmd == fan.COMMAND_START_HORIZONTAL_OSCILLATION + + +@pytest.mark.asyncio +async def test_standing_fan_set_vertical_oscillation(): + standing_fan = create_standing_fan_for_testing({"oscillating": True}) + await standing_fan.set_vertical_oscillation(True) + standing_fan._send_command.assert_called_once() + cmd = standing_fan._send_command.call_args[0][0] + assert cmd == fan.COMMAND_START_VERTICAL_OSCILLATION From bae09858f0ea5e0b7bed0687ecc28e824d86397c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:47:44 +0000 Subject: [PATCH 3/5] chore(pre-commit.ci): auto fixes --- switchbot/__init__.py | 2 +- switchbot/const/__init__.py | 2 +- switchbot/devices/fan.py | 10 +++++----- tests/test_fan.py | 16 ++++------------ 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/switchbot/__init__.py b/switchbot/__init__.py index 12748e9f..7120f39e 100644 --- a/switchbot/__init__.py +++ b/switchbot/__init__.py @@ -99,7 +99,6 @@ "SwitchbotEncryptedDevice", "SwitchbotEvaporativeHumidifier", "SwitchbotFan", - "SwitchbotStandingFan", "SwitchbotGarageDoorOpener", "SwitchbotHumidifier", "SwitchbotKeypadVision", @@ -116,6 +115,7 @@ "SwitchbotRgbicLight", "SwitchbotRollerShade", "SwitchbotSmartThermostatRadiator", + "SwitchbotStandingFan", "SwitchbotStripLight3", "SwitchbotSupportedType", "SwitchbotSupportedType", diff --git a/switchbot/const/__init__.py b/switchbot/const/__init__.py index 5d810481..cb2ad97b 100644 --- a/switchbot/const/__init__.py +++ b/switchbot/const/__init__.py @@ -122,12 +122,12 @@ class SwitchbotModel(StrEnum): "ClimateMode", "ColorMode", "FanMode", - "StandingFanMode", "HumidifierAction", "HumidifierMode", "HumidifierWaterLevel", "LockStatus", "SmartThermostatRadiatorMode", + "StandingFanMode", "StripLightColorMode", "SwitchbotAccountConnectionError", "SwitchbotApiError", diff --git a/switchbot/devices/fan.py b/switchbot/devices/fan.py index 8f01efad..a59081bc 100644 --- a/switchbot/devices/fan.py +++ b/switchbot/devices/fan.py @@ -16,12 +16,12 @@ COMMAND_HEAD = "570f41" -COMMAND_START_OSCILLATION = f"{COMMAND_HEAD}02010101" # 左右+上下同时启动 -COMMAND_STOP_OSCILLATION = f"{COMMAND_HEAD}02010202" # 左右+上下同时停止 +COMMAND_START_OSCILLATION = f"{COMMAND_HEAD}02010101" # 左右+上下同时启动 +COMMAND_STOP_OSCILLATION = f"{COMMAND_HEAD}02010202" # 左右+上下同时停止 COMMAND_START_HORIZONTAL_OSCILLATION = f"{COMMAND_HEAD}020101ff" # 仅启动左右,上下保持 -COMMAND_STOP_HORIZONTAL_OSCILLATION = f"{COMMAND_HEAD}020102ff" # 仅停止左右,上下保持 -COMMAND_START_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff01" # 左右保持,仅启动上下 -COMMAND_STOP_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff02" # 左右保持,仅停止上下 +COMMAND_STOP_HORIZONTAL_OSCILLATION = f"{COMMAND_HEAD}020102ff" # 仅停止左右,上下保持 +COMMAND_START_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff01" # 左右保持,仅启动上下 +COMMAND_STOP_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff02" # 左右保持,仅停止上下 COMMAND_SET_MODE = { FanMode.NORMAL.name.lower(): f"{COMMAND_HEAD}030101ff", FanMode.NATURAL.name.lower(): f"{COMMAND_HEAD}030102ff", diff --git a/tests/test_fan.py b/tests/test_fan.py index f07b46b0..1db16c0c 100644 --- a/tests/test_fan.py +++ b/tests/test_fan.py @@ -181,9 +181,7 @@ def test_get_modes(): 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 = SwitchbotStandingFan(ble_device, model=SwitchbotModel.STANDING_FAN) standing_fan.update_from_advertisement( make_advertisement_data(ble_device, init_data) ) @@ -243,9 +241,7 @@ async def test_standing_fan_set_preset_mode(mode): ("basic_info", "firmware_info", "result"), [ ( - bytearray( - b"\x01\x02W\x82g\xf5\xde4\x01=dPP\x03\x14P\x00\x00\x00\x00" - ), + bytearray(b"\x01\x02W\x82g\xf5\xde4\x01=dPP\x03\x14P\x00\x00\x00\x00"), bytearray(b"\x01W\x0b\x17\x01"), { "battery": 87, @@ -259,9 +255,7 @@ async def test_standing_fan_set_preset_mode(mode): }, ), ( - bytearray( - b"\x01\x02U\xc2g\xf5\xde4\x04+dPP\x03\x14P\x00\x00\x00\x00" - ), + bytearray(b"\x01\x02U\xc2g\xf5\xde4\x04+dPP\x03\x14P\x00\x00\x00\x00"), bytearray(b"\x01U\x0b\x17\x01"), { "battery": 85, @@ -275,9 +269,7 @@ async def test_standing_fan_set_preset_mode(mode): }, ), ( - bytearray( - b"\x01\x02U\xe2g\xf5\xde4\x05+dPP\x03\x14P\x00\x00\x00\x00" - ), + bytearray(b"\x01\x02U\xe2g\xf5\xde4\x05+dPP\x03\x14P\x00\x00\x00\x00"), bytearray(b"\x01U\x0b\x17\x01"), { "battery": 85, From bb198d081b6c0331eb18362d0dfe8e4db49825cb Mon Sep 17 00:00:00 2001 From: Fan Kai Date: Fri, 6 Mar 2026 17:54:06 +0800 Subject: [PATCH 4/5] fix: replace fullwidth commas in comments to pass ruff RUF003 Co-Authored-By: Claude Opus 4.6 --- switchbot/devices/fan.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/switchbot/devices/fan.py b/switchbot/devices/fan.py index a59081bc..709f5747 100644 --- a/switchbot/devices/fan.py +++ b/switchbot/devices/fan.py @@ -16,12 +16,12 @@ COMMAND_HEAD = "570f41" -COMMAND_START_OSCILLATION = f"{COMMAND_HEAD}02010101" # 左右+上下同时启动 -COMMAND_STOP_OSCILLATION = f"{COMMAND_HEAD}02010202" # 左右+上下同时停止 -COMMAND_START_HORIZONTAL_OSCILLATION = f"{COMMAND_HEAD}020101ff" # 仅启动左右,上下保持 -COMMAND_STOP_HORIZONTAL_OSCILLATION = f"{COMMAND_HEAD}020102ff" # 仅停止左右,上下保持 -COMMAND_START_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff01" # 左右保持,仅启动上下 -COMMAND_STOP_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff02" # 左右保持,仅停止上下 +COMMAND_START_OSCILLATION = f"{COMMAND_HEAD}02010101" # H+V start +COMMAND_STOP_OSCILLATION = f"{COMMAND_HEAD}02010202" # H+V stop +COMMAND_START_HORIZONTAL_OSCILLATION = f"{COMMAND_HEAD}020101ff" # H start, V keep +COMMAND_STOP_HORIZONTAL_OSCILLATION = f"{COMMAND_HEAD}020102ff" # H stop, V keep +COMMAND_START_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff01" # H keep, V start +COMMAND_STOP_VERTICAL_OSCILLATION = f"{COMMAND_HEAD}0201ff02" # H keep, V stop COMMAND_SET_MODE = { FanMode.NORMAL.name.lower(): f"{COMMAND_HEAD}030101ff", FanMode.NATURAL.name.lower(): f"{COMMAND_HEAD}030102ff", From 9051057d0b1fa2d55f53612ed98322a3e332661a Mon Sep 17 00:00:00 2001 From: Fan Kai Date: Tue, 10 Mar 2026 15:46:33 +0800 Subject: [PATCH 5/5] Add tests for horizontal/vertical oscillation stop and state getters Cover the False branches of set_horizontal_oscillation and set_vertical_oscillation, and add tests for the state getter methods. --- tests/test_fan.py | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/tests/test_fan.py b/tests/test_fan.py index 1db16c0c..7b1d5ddc 100644 --- a/tests/test_fan.py +++ b/tests/test_fan.py @@ -359,18 +359,42 @@ def test_standing_fan_get_night_light_state(): @pytest.mark.asyncio -async def test_standing_fan_set_horizontal_oscillation(): - standing_fan = create_standing_fan_for_testing({"oscillating": True}) - await standing_fan.set_horizontal_oscillation(True) +@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 == fan.COMMAND_START_HORIZONTAL_OSCILLATION + assert cmd == expected_cmd @pytest.mark.asyncio -async def test_standing_fan_set_vertical_oscillation(): - standing_fan = create_standing_fan_for_testing({"oscillating": True}) - await standing_fan.set_vertical_oscillation(True) +@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 == fan.COMMAND_START_VERTICAL_OSCILLATION + 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