diff --git a/switchbot/devices/air_purifier.py b/switchbot/devices/air_purifier.py index 73365946..c05a7643 100644 --- a/switchbot/devices/air_purifier.py +++ b/switchbot/devices/air_purifier.py @@ -97,6 +97,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: return None _data, led_settings, led_status = res[0], res[1], res[2] + if len(_data) < 16 or len(led_settings) < 6 or len(led_status) < 2: + return None _LOGGER.debug( "%s %s basic info %s", self._model, self._device.address, _data.hex() diff --git a/switchbot/devices/art_frame.py b/switchbot/devices/art_frame.py index 9b763787..7ce0ba83 100644 --- a/switchbot/devices/art_frame.py +++ b/switchbot/devices/art_frame.py @@ -25,6 +25,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic settings.""" if not (_data := await self._get_basic_info()): return None + if len(_data) < 7: + return None _LOGGER.debug("basic info data: %s", _data.hex()) battery_charging = bool(_data[1] & 0x80) @@ -36,6 +38,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: last_network_status = (_data[4] >> 2) & 0x01 current_image_index = _data[5] total_num_of_images = _data[6] + if len(_data) < 7 + total_num_of_images: + return None all_images_index = [_data[x] for x in range(7, 7 + total_num_of_images)] basic_info = { diff --git a/switchbot/devices/base_cover.py b/switchbot/devices/base_cover.py index a721c8a4..f56f7b7c 100644 --- a/switchbot/devices/base_cover.py +++ b/switchbot/devices/base_cover.py @@ -78,7 +78,7 @@ async def get_extended_info_adv(self) -> dict[str, Any] | None: _LOGGER.error("%s: Unsuccessful, no result from device", self.name) return None - if _data in (b"\x07", b"\x00"): + if len(_data) < 4: _LOGGER.error("%s: Unsuccessful, please try again", self.name) return None @@ -94,15 +94,21 @@ async def get_extended_info_adv(self) -> dict[str, Any] | None: self.ext_info_adv["device0"] = { "battery": _data[1], "firmware": _data[2] / 10.0, - "stateOfCharge": _state_of_charge[_data[3]], + "stateOfCharge": ( + _state_of_charge[_data[3]] if _data[3] < len(_state_of_charge) else None + ), } # If grouped curtain device present. - if _data[4]: + if len(_data) >= 7 and _data[4]: self.ext_info_adv["device1"] = { "battery": _data[4], "firmware": _data[5] / 10.0, - "stateOfCharge": _state_of_charge[_data[6]], + "stateOfCharge": ( + _state_of_charge[_data[6]] + if _data[6] < len(_state_of_charge) + else None + ), } return self.ext_info_adv diff --git a/switchbot/devices/blind_tilt.py b/switchbot/devices/blind_tilt.py index 3fbfea86..52e0981b 100644 --- a/switchbot/devices/blind_tilt.py +++ b/switchbot/devices/blind_tilt.py @@ -111,6 +111,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic settings.""" if not (_data := await self._get_basic_info()): return None + if len(_data) < 8: + return None _tilt = max(min(_data[6], 100), 0) _moving = bool(_data[5] & 0b00000011) @@ -150,7 +152,7 @@ async def get_extended_info_summary(self) -> dict[str, Any] | None: _LOGGER.error("%s: Unsuccessful, no result from device", self.name) return None - if _data in (b"\x07", b"\x00"): + if len(_data) < 2: _LOGGER.error("%s: Unsuccessful, please try again", self.name) return None diff --git a/switchbot/devices/bot.py b/switchbot/devices/bot.py index 0099a6db..49e954f9 100644 --- a/switchbot/devices/bot.py +++ b/switchbot/devices/bot.py @@ -101,6 +101,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic settings.""" if not (_data := await self._get_basic_info()): return None + if len(_data) < 11: + return None return { "battery": _data[1], "firmware": _data[2] / 10.0, diff --git a/switchbot/devices/bulb.py b/switchbot/devices/bulb.py index 346ef604..b8dc18e0 100644 --- a/switchbot/devices/bulb.py +++ b/switchbot/devices/bulb.py @@ -48,6 +48,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: ): return None _version_info, _data = res + if len(_data) < 11 or len(_version_info) < 3: + return None self._state["r"] = _data[3] self._state["g"] = _data[4] diff --git a/switchbot/devices/ceiling_light.py b/switchbot/devices/ceiling_light.py index b487c414..7af76852 100644 --- a/switchbot/devices/ceiling_light.py +++ b/switchbot/devices/ceiling_light.py @@ -59,6 +59,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: ): return None _version_info, _data = res + if len(_data) < 5 or len(_version_info) < 3: + return None self._state["cw"] = int.from_bytes(_data[3:5], "big") diff --git a/switchbot/devices/curtain.py b/switchbot/devices/curtain.py index 877aa994..48381802 100644 --- a/switchbot/devices/curtain.py +++ b/switchbot/devices/curtain.py @@ -104,6 +104,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic settings.""" if not (_data := await self._get_basic_info()): return None + if len(_data) < 8: + return None _position = max(min(_data[6], 100), 0) _direction_adjusted_position = (100 - _position) if self._reverse else _position @@ -153,7 +155,7 @@ async def get_extended_info_summary(self) -> dict[str, Any] | None: _LOGGER.error("%s: Unsuccessful, no result from device", self.name) return None - if _data in (b"\x07", b"\x00"): + if len(_data) < 3: _LOGGER.error("%s: Unsuccessful, please try again", self.name) return None diff --git a/switchbot/devices/evaporative_humidifier.py b/switchbot/devices/evaporative_humidifier.py index 9d370764..e8793ebf 100644 --- a/switchbot/devices/evaporative_humidifier.py +++ b/switchbot/devices/evaporative_humidifier.py @@ -54,6 +54,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic settings.""" if not (_data := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)): return None + if len(_data) < 11: + return None _LOGGER.debug("basic info data: %s", _data.hex()) isOn = bool(_data[1] & 0b10000000) diff --git a/switchbot/devices/fan.py b/switchbot/devices/fan.py index f41e4b30..140f4e08 100644 --- a/switchbot/devices/fan.py +++ b/switchbot/devices/fan.py @@ -72,6 +72,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: return None if not (_data1 := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)): return None + if len(_data) < 10 or len(_data1) < 3: + return None _LOGGER.debug("data: %s", _data) return self._parse_basic_info(_data, _data1) @@ -111,7 +113,7 @@ async def _get_basic_info(self, cmd: str) -> bytes | None: """Return basic info of device.""" _data = await self._send_command(key=cmd, retry=self._retry_count) - if _data in (b"\x07", b"\x00"): + if _data is None or len(_data) <= 1: _LOGGER.error("Unsuccessful, please try again") return None diff --git a/switchbot/devices/keypad_vision.py b/switchbot/devices/keypad_vision.py index ef824ebd..38a0ab6c 100644 --- a/switchbot/devices/keypad_vision.py +++ b/switchbot/devices/keypad_vision.py @@ -46,6 +46,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic settings.""" if not (_data := await self._get_basic_info()): return None + if len(_data) < 15: + return None _LOGGER.debug("Raw model %s basic info data: %s", self._model, _data.hex()) battery = _data[1] & 0x7F @@ -137,6 +139,9 @@ async def get_password_count(self) -> dict[str, int] | None: """Get the number of passwords stored in the Keypad Vision (Pro).""" if not (_data := await self._send_command(COMMAND_GET_PASSWORD_COUNT)): return None + min_len = 8 if self._model == SwitchbotModel.KEYPAD_VISION_PRO else 6 + if len(_data) < min_len: + return None _LOGGER.debug("Raw model %s password count data: %s", self._model, _data.hex()) pin = _data[1] diff --git a/switchbot/devices/light_strip.py b/switchbot/devices/light_strip.py index c018aa4e..ba9bfd1f 100644 --- a/switchbot/devices/light_strip.py +++ b/switchbot/devices/light_strip.py @@ -268,6 +268,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: return None _version_info, _data = res + if len(_data) < 11 or len(_version_info) < 3: + return None self._state["r"] = _data[3] self._state["g"] = _data[4] self._state["b"] = _data[5] @@ -321,6 +323,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: ): return None _version_info, _data = res + if len(_data) < 3 or len(_version_info) < 3: + return None return { "isOn": bool(_data[1] & 0b10000000), "brightness": _data[2] & 0b01111111, diff --git a/switchbot/devices/roller_shade.py b/switchbot/devices/roller_shade.py index 0cbdc7ea..a519891b 100644 --- a/switchbot/devices/roller_shade.py +++ b/switchbot/devices/roller_shade.py @@ -127,6 +127,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic settings.""" if not (_data := await self._get_basic_info()): return None + if len(_data) < 7: + return None _position = max(min(_data[5], 100), 0) _direction_adjusted_position = (100 - _position) if self._reverse else _position diff --git a/switchbot/devices/smart_thermostat_radiator.py b/switchbot/devices/smart_thermostat_radiator.py index 0b53c3fd..7723109c 100644 --- a/switchbot/devices/smart_thermostat_radiator.py +++ b/switchbot/devices/smart_thermostat_radiator.py @@ -133,6 +133,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: """Get device basic settings.""" if not (_data := await self._get_basic_info()): return None + if len(_data) < 15: + return None _LOGGER.debug("data: %s", _data) battery = _data[1] diff --git a/switchbot/devices/vacuum.py b/switchbot/devices/vacuum.py index bd4fe490..31f39d45 100644 --- a/switchbot/devices/vacuum.py +++ b/switchbot/devices/vacuum.py @@ -33,6 +33,8 @@ async def get_basic_info(self) -> dict[str, Any] | None: """Only support get the ble version through the command.""" if not (_data := await self._get_basic_info()): return None + if len(_data) < 3: + return None return { "firmware": _data[2], } diff --git a/tests/test_art_frame.py b/tests/test_art_frame.py index ebbe428b..6489bee7 100644 --- a/tests/test_art_frame.py +++ b/tests/test_art_frame.py @@ -133,6 +133,7 @@ async def test_next_image( current_index: int, all_images_index: list[int], expected_cmd: str ) -> None: device = create_device_for_command_testing(ART_FRAME_INFO) + device._get_current_image_index = AsyncMock() with ( patch.object(device, "get_current_image_index", return_value=current_index), @@ -157,6 +158,7 @@ async def test_prev_image( current_index: int, all_images_index: list[int], expected_cmd: str ) -> None: device = create_device_for_command_testing(ART_FRAME_INFO) + device._get_current_image_index = AsyncMock() with ( patch.object(device, "get_current_image_index", return_value=current_index), @@ -171,6 +173,7 @@ async def test_prev_image( @pytest.mark.asyncio async def test_set_image_with_invalid_index() -> None: device = create_device_for_command_testing(ART_FRAME_INFO) + device._get_current_image_index = AsyncMock() with ( patch.object(device, "get_total_images", return_value=3), @@ -185,6 +188,7 @@ async def test_set_image_with_invalid_index() -> None: @pytest.mark.asyncio async def test_set_image_with_valid_index() -> None: device = create_device_for_command_testing(ART_FRAME_INFO) + device._get_current_image_index = AsyncMock() with ( patch.object(device, "get_total_images", return_value=3), diff --git a/tests/test_device_basic_info_guards.py b/tests/test_device_basic_info_guards.py new file mode 100644 index 00000000..f67d4f31 --- /dev/null +++ b/tests/test_device_basic_info_guards.py @@ -0,0 +1,353 @@ +r""" +Regression tests: device-level get_basic_info must not crash on short responses. + +Each device class's `get_basic_info()` accesses fixed byte offsets in the response +payload. A truncated reply (BLE proxy strips bytes, device firmware error, etc.) +must return None instead of raising IndexError. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from bleak.backends.device import BLEDevice + +from switchbot import SwitchbotModel +from switchbot.devices import ( + air_purifier, + art_frame, + blind_tilt, + bot, + bulb, + ceiling_light, + curtain, + evaporative_humidifier, + fan, + keypad_vision, + light_strip, + roller_shade, + smart_thermostat_radiator, + vacuum, +) + +from .test_adv_parser import generate_ble_device + + +def _ble() -> BLEDevice: + return generate_ble_device("aa:bb:cc:dd:ee:ff", "any") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("short", [b"\x01", b"\x01\x02", b"\x01\x02\x03"]) +async def test_bot_get_basic_info_short_returns_none(short: bytes) -> None: + """SwitchbotBot.get_basic_info accesses _data[10] — short reply must return None.""" + device = bot.Switchbot(_ble()) + device._send_command = AsyncMock(return_value=short) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "data_short", + [b"", b"\x01", b"\x01" * 10], +) +async def test_bulb_get_basic_info_short_returns_none(data_short: bytes) -> None: + """SwitchbotBulb.get_basic_info accesses _data[10] — short reply must return None.""" + device = bulb.SwitchbotBulb(_ble()) + # _get_multi_commands_results returns (version_info, data); fake both short. + device._get_multi_commands_results = AsyncMock( + return_value=(b"\x01\x02\x03", data_short) + ) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +async def test_bulb_get_basic_info_short_version_returns_none() -> None: + """SwitchbotBulb.get_basic_info needs version_info[2] — short reply must return None.""" + device = bulb.SwitchbotBulb(_ble()) + device._get_multi_commands_results = AsyncMock(return_value=(b"\x01", b"\x00" * 16)) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +async def test_ceiling_light_get_basic_info_short_data_returns_none() -> None: + """SwitchbotCeilingLight.get_basic_info needs _data >= 5.""" + device = ceiling_light.SwitchbotCeilingLight(_ble()) + device._get_multi_commands_results = AsyncMock( + return_value=(b"\x01\x02\x03", b"\x01\x02") + ) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +async def test_ceiling_light_get_basic_info_short_version_returns_none() -> None: + """SwitchbotCeilingLight.get_basic_info needs version_info >= 3.""" + device = ceiling_light.SwitchbotCeilingLight(_ble()) + device._get_multi_commands_results = AsyncMock(return_value=(b"\x01", b"\x00" * 8)) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("short", [b"\x01", b"\x01\x02", b"\x01" * 9]) +async def test_fan_get_basic_info_short_returns_none(short: bytes) -> None: + """SwitchbotFan.get_basic_info accesses _data[9] — short reply must return None.""" + ble = _ble() + # Use a concrete subclass with a _mode_enum defined. + device = fan.SwitchbotFan(ble) + device._send_command = AsyncMock(return_value=short) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +async def test_fan_get_basic_info_short_firmware_returns_none() -> None: + """SwitchbotFan.get_basic_info accesses _data1[2] — short firmware reply returns None.""" + device = fan.SwitchbotFan(_ble()) + # First call returns a sufficiently-long data buffer; second (firmware) is short. + device._send_command = AsyncMock(side_effect=[b"\x01" + b"\x80" * 10, b"\x01\x02"]) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("short", [b"\x01", b"\x01\x02", b"\x01" * 14]) +async def test_keypad_vision_get_basic_info_short_returns_none(short: bytes) -> None: + """SwitchbotKeypadVision.get_basic_info accesses _data[14] — short reply returns None.""" + device = keypad_vision.SwitchbotKeypadVision( + _ble(), + "ff", + "ffffffffffffffffffffffffffffffff", + model=SwitchbotModel.KEYPAD_VISION, + ) + device._get_basic_info = AsyncMock(return_value=short) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("model", "short"), + [ + (SwitchbotModel.KEYPAD_VISION, b"\x01\x02"), + (SwitchbotModel.KEYPAD_VISION, b"\x01" * 5), + (SwitchbotModel.KEYPAD_VISION_PRO, b"\x01" * 7), + ], +) +async def test_keypad_vision_get_password_count_short_returns_none( + model: SwitchbotModel, short: bytes +) -> None: + device = keypad_vision.SwitchbotKeypadVision( + _ble(), "ff", "ffffffffffffffffffffffffffffffff", model=model + ) + device._send_command = AsyncMock(return_value=short) + assert await device.get_password_count() is None + + +@pytest.mark.asyncio +async def test_air_purifier_get_basic_info_short_data_returns_none() -> None: + """SwitchbotAirPurifier.get_basic_info needs _data >= 16.""" + device = air_purifier.SwitchbotAirPurifier( + _ble(), + "ff", + "ffffffffffffffffffffffffffffffff", + model=SwitchbotModel.AIR_PURIFIER_TABLE_US, + ) + device._get_basic_info_by_multi_commands = AsyncMock( + return_value=[b"\x01" * 10, b"\x01" * 6, b"\x01" * 2] + ) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +async def test_air_purifier_get_basic_info_short_led_returns_none() -> None: + """SwitchbotAirPurifier.get_basic_info needs led_settings >= 6.""" + device = air_purifier.SwitchbotAirPurifier( + _ble(), + "ff", + "ffffffffffffffffffffffffffffffff", + model=SwitchbotModel.AIR_PURIFIER_TABLE_US, + ) + device._get_basic_info_by_multi_commands = AsyncMock( + return_value=[b"\x01" * 16, b"\x01" * 3, b"\x01" * 2] + ) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("short", [b"\x01", b"\x01\x02", b"\x01" * 7]) +async def test_blind_tilt_get_basic_info_short_returns_none(short: bytes) -> None: + """SwitchbotBlindTilt.get_basic_info accesses _data[7] — short reply returns None.""" + device = blind_tilt.SwitchbotBlindTilt(_ble()) + device._get_basic_info = AsyncMock(return_value=short) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +async def test_blind_tilt_get_extended_info_summary_short_returns_none() -> None: + """SwitchbotBlindTilt.get_extended_info_summary accesses _data[1] — short reply returns None.""" + device = blind_tilt.SwitchbotBlindTilt(_ble()) + device._send_command = AsyncMock(return_value=b"\x01") + assert await device.get_extended_info_summary() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("short", [b"\x01", b"\x01\x02", b"\x01" * 7]) +async def test_curtain_get_basic_info_short_returns_none(short: bytes) -> None: + """SwitchbotCurtain.get_basic_info accesses _data[7] — short reply returns None.""" + device = curtain.SwitchbotCurtain(_ble()) + device._get_basic_info = AsyncMock(return_value=short) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +async def test_curtain_get_extended_info_summary_short_returns_none() -> None: + """SwitchbotCurtain.get_extended_info_summary accesses _data[2] — short reply returns None.""" + device = curtain.SwitchbotCurtain(_ble()) + device._send_command = AsyncMock(return_value=b"\x01\x02") + assert await device.get_extended_info_summary() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("short", [b"\x01", b"\x01\x02", b"\x01\x02\x03"]) +async def test_curtain_get_extended_info_adv_short_returns_none(short: bytes) -> None: + """get_extended_info_adv accesses _data[3] on device0 — short reply returns None.""" + device = curtain.SwitchbotCurtain(_ble()) + device._send_command = AsyncMock(return_value=short) + assert await device.get_extended_info_adv() is None + + +@pytest.mark.asyncio +async def test_curtain_get_extended_info_adv_single_device_short_skips_device1() -> ( + None +): + """A 4-byte reply parses device0 only; the device1 block (needs _data[6]) is skipped.""" + device = curtain.SwitchbotCurtain(_ble()) + # _data[0]=hdr, [1]=battery, [2]=firmware*10, [3]=stateOfCharge index (0..5) + device._send_command = AsyncMock(return_value=b"\x00\x55\x32\x01") + result = await device.get_extended_info_adv() + assert result is not None + assert "device0" in result + assert result["device0"] == { + "battery": 0x55, + "firmware": 5.0, + "stateOfCharge": "charging_by_adapter", + } + assert "device1" not in result + + +@pytest.mark.asyncio +async def test_curtain_get_extended_info_adv_truncated_device1_does_not_crash() -> None: + """A reply with _data[4] set but <7 bytes total must not IndexError on _data[5]/_data[6].""" + device = curtain.SwitchbotCurtain(_ble()) + # _data[4]=0x55 would normally trigger the device1 branch; len=6 is one short of _data[6]. + device._send_command = AsyncMock(return_value=b"\x00\x55\x32\x01\x55\x32") + result = await device.get_extended_info_adv() + assert result is not None + assert "device1" not in result + + +@pytest.mark.asyncio +async def test_curtain_get_extended_info_adv_out_of_range_charge_returns_none() -> None: + """A full-length reply with charge byte > 5 must not IndexError; stateOfCharge is None.""" + device = curtain.SwitchbotCurtain(_ble()) + # _data[3]=0xFF (device0) and _data[6]=0xFF (device1) are out of range (0..5). + device._send_command = AsyncMock(return_value=b"\x00\x55\x32\xff\x55\x32\xff") + result = await device.get_extended_info_adv() + assert result is not None + assert result["device0"]["stateOfCharge"] is None + assert result["device1"]["stateOfCharge"] is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("short", [b"\x01", b"\x01" * 5, b"\x01" * 10]) +async def test_evaporative_humidifier_get_basic_info_short_returns_none( + short: bytes, +) -> None: + """SwitchbotEvaporativeHumidifier.get_basic_info accesses _data[10].""" + device = evaporative_humidifier.SwitchbotEvaporativeHumidifier( + _ble(), "ff", "ffffffffffffffffffffffffffffffff" + ) + device._send_command = AsyncMock(return_value=short) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +async def test_light_strip_get_basic_info_short_data_returns_none() -> None: + """SwitchbotLightStrip.get_basic_info needs _data >= 11.""" + device = light_strip.SwitchbotLightStrip(_ble()) + device._get_multi_commands_results = AsyncMock( + return_value=(b"\x01\x02\x03", b"\x01" * 5) + ) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +async def test_candle_warmer_lamp_get_basic_info_short_data_returns_none() -> None: + """SwitchbotCandleWarmerLamp.get_basic_info needs _data >= 3 and version_info >= 3.""" + device = light_strip.SwitchbotCandleWarmerLamp( + _ble(), "ff", "ffffffffffffffffffffffffffffffff" + ) + device._get_multi_commands_results = AsyncMock( + return_value=(b"\x01\x02\x03", b"\x01\x02") + ) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("short", [b"\x01", b"\x01" * 5, b"\x01" * 6]) +async def test_roller_shade_get_basic_info_short_returns_none(short: bytes) -> None: + """SwitchbotRollerShade.get_basic_info accesses _data[6] — short reply returns None.""" + device = roller_shade.SwitchbotRollerShade(_ble()) + device._get_basic_info = AsyncMock(return_value=short) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("short", [b"\x01", b"\x01" * 10, b"\x01" * 14]) +async def test_smart_thermostat_radiator_get_basic_info_short_returns_none( + short: bytes, +) -> None: + """SwitchbotSmartThermostatRadiator.get_basic_info accesses _data[14].""" + device = smart_thermostat_radiator.SwitchbotSmartThermostatRadiator( + _ble(), + "ff", + "ffffffffffffffffffffffffffffffff", + model=SwitchbotModel.SMART_THERMOSTAT_RADIATOR, + ) + device._send_command = AsyncMock(return_value=short) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("short", [b"\x01", b"\x01\x02"]) +async def test_vacuum_get_basic_info_short_returns_none(short: bytes) -> None: + """SwitchbotVacuum.get_basic_info accesses _data[2] — short reply returns None.""" + device = vacuum.SwitchbotVacuum(_ble()) + device._send_command = AsyncMock(return_value=short) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("short", [b"\x01", b"\x01" * 5, b"\x01" * 6]) +async def test_art_frame_get_basic_info_short_returns_none(short: bytes) -> None: + """SwitchbotArtFrame.get_basic_info accesses _data[6] (total_num_of_images).""" + device = art_frame.SwitchbotArtFrame( + _ble(), + "ff", + "ffffffffffffffffffffffffffffffff", + model=SwitchbotModel.ART_FRAME, + ) + device._get_basic_info = AsyncMock(return_value=short) + assert await device.get_basic_info() is None + + +@pytest.mark.asyncio +async def test_art_frame_get_basic_info_truncated_images_returns_none() -> None: + """ArtFrame: total_num_of_images = 5 but only 1 image-index byte present.""" + device = art_frame.SwitchbotArtFrame( + _ble(), + "ff", + "ffffffffffffffffffffffffffffffff", + model=SwitchbotModel.ART_FRAME, + ) + # _data[6] = 5 (claims 5 images) but buffer ends at byte 7 (only 1 image byte). + device._get_basic_info = AsyncMock(return_value=b"\x01\x02\x03\x04\x05\x06\x05\xaa") + assert await device.get_basic_info() is None