diff --git a/switchbot/__init__.py b/switchbot/__init__.py index f5981fb6..7120f39e 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", @@ -113,6 +115,7 @@ "SwitchbotRgbicLight", "SwitchbotRollerShade", "SwitchbotSmartThermostatRadiator", + "SwitchbotStandingFan", "SwitchbotStripLight3", "SwitchbotSupportedType", "SwitchbotSupportedType", 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..cb2ad97b 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" @@ -126,6 +127,7 @@ class SwitchbotModel(StrEnum): "HumidifierWaterLevel", "LockStatus", "SmartThermostatRadiatorMode", + "StandingFanMode", "StripLightColorMode", "SwitchbotAccountConnectionError", "SwitchbotApiError", 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..709f5747 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" # 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", @@ -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..7b1d5ddc 100644 --- a/tests/test_fan.py +++ b/tests/test_fan.py @@ -4,8 +4,9 @@ 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 from .test_adv_parser import generate_ble_device @@ -175,3 +176,225 @@ 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): + 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 +@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