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