From 4599bed1291e41b9f32126a12b269acafd63f4ee Mon Sep 17 00:00:00 2001 From: Bluetooth Devices Bot Date: Tue, 19 May 2026 18:08:00 +0000 Subject: [PATCH 1/2] fix: skip cached state override when bot ignores command (#213) When the bot replies with 0x03 0xff 0x00 ("command ignored", typically on back-to-back presses), `Switchbot.turn_on`/`turn_off` still called `_override_state` unconditionally, so Home Assistant's cached state flipped even though the device never actuated. Gate the override on the result of `_check_command_result` so a rejected command leaves the cached state alone. Adds tests covering accepted (`0x01`/`0x05`) and rejected return values for both turn_on and turn_off, plus an inverse-mode regression check. Co-Authored-By: Claude Opus 4.7 (1M context) --- switchbot/devices/bot.py | 6 ++- tests/test_bot.py | 107 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 tests/test_bot.py diff --git a/switchbot/devices/bot.py b/switchbot/devices/bot.py index 0099a6db..9736ced7 100644 --- a/switchbot/devices/bot.py +++ b/switchbot/devices/bot.py @@ -37,7 +37,8 @@ async def turn_on(self) -> bool: """Turn device on.""" result = await self._send_command(ON_KEY) ret = self._check_command_result(result, 0, {1, 5}) - self._override_state({"isOn": True}) + if ret: + self._override_state({"isOn": True}) _LOGGER.debug( "%s: Turn on result: %s -> %s", self.name, @@ -52,7 +53,8 @@ async def turn_off(self) -> bool: """Turn device off.""" result = await self._send_command(OFF_KEY) ret = self._check_command_result(result, 0, {1, 5}) - self._override_state({"isOn": False}) + if ret: + self._override_state({"isOn": False}) _LOGGER.debug( "%s: Turn off result: %s -> %s", self.name, diff --git a/tests/test_bot.py b/tests/test_bot.py new file mode 100644 index 00000000..9ac116b8 --- /dev/null +++ b/tests/test_bot.py @@ -0,0 +1,107 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from bleak.backends.device import BLEDevice + +from switchbot import SwitchBotAdvertisement, SwitchbotModel +from switchbot.devices import bot + +from .test_adv_parser import generate_ble_device + + +def create_bot_for_command_testing(init_data: dict | None = None) -> bot.Switchbot: + ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") + device = bot.Switchbot(ble_device, model=SwitchbotModel.BOT) + device.update_from_advertisement(make_advertisement_data(ble_device, init_data)) + device._send_command = AsyncMock() + device._check_command_result = MagicMock(return_value=True) + device.update = AsyncMock() + return device + + +def make_advertisement_data( + ble_device: BLEDevice, init_data: dict | None = None +) -> SwitchBotAdvertisement: + if init_data is None: + init_data = {} + return SwitchBotAdvertisement( + address="aa:bb:cc:dd:ee:ff", + data={ + "rawAdvData": b"H\x10\xe1", + "data": {"switchMode": False, "isOn": False, "battery": 97} | init_data, + "model": "H", + "isEncrypted": False, + "modelFriendlyName": "Bot", + "modelName": SwitchbotModel.BOT, + }, + device=ble_device, + rssi=-65, + active=True, + ) + + +@pytest.mark.asyncio +async def test_turn_on_accepted_overrides_state() -> None: + """Accepted command (e.g. 0x01 0x48 0x90) must update cached state to on.""" + device = create_bot_for_command_testing({"isOn": False}) + + assert await device.turn_on() is True + + device._send_command.assert_called_with(bot.ON_KEY) + assert device.is_on() is True + + +@pytest.mark.asyncio +async def test_turn_off_accepted_overrides_state() -> None: + """Accepted command must update cached state to off.""" + device = create_bot_for_command_testing({"isOn": True}) + + assert await device.turn_off() is True + + device._send_command.assert_called_with(bot.OFF_KEY) + assert device.is_on() is False + + +@pytest.mark.asyncio +async def test_turn_on_rejected_preserves_state() -> None: + """Rejected command (e.g. 0x03 0xff 0x00) must NOT override the cached state. + + Regression for sblibs/pySwitchbot#213: back-to-back presses where the bot + silently ignores the second one would still flip HA's state to ``on`` + because ``_override_state`` ran unconditionally. + """ + device = create_bot_for_command_testing({"isOn": False}) + device._check_command_result = MagicMock(return_value=False) + + assert await device.turn_on() is False + + device._send_command.assert_called_with(bot.ON_KEY) + assert device.is_on() is False + + +@pytest.mark.asyncio +async def test_turn_off_rejected_preserves_state() -> None: + """Rejected command must NOT override the cached state to off.""" + device = create_bot_for_command_testing({"isOn": True}) + device._check_command_result = MagicMock(return_value=False) + + assert await device.turn_off() is False + + device._send_command.assert_called_with(bot.OFF_KEY) + assert device.is_on() is True + + +@pytest.mark.asyncio +async def test_inverse_mode_is_on_reflects_override() -> None: + """is_on() must respect inverse_mode after a successful turn_on.""" + ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") + device = bot.Switchbot(ble_device, model=SwitchbotModel.BOT, inverse_mode=True) + device.update_from_advertisement(make_advertisement_data(ble_device, {"isOn": True})) + device._send_command = AsyncMock() + device._check_command_result = MagicMock(return_value=True) + device.update = AsyncMock() + + await device.turn_on() + + # inverse_mode flips the user-facing reading + assert device.is_on() is False From a02c6e0dd0a96d2fd40bec172dd9326fa49accca Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 18:08:34 +0000 Subject: [PATCH 2/2] chore(pre-commit.ci): auto fixes --- tests/test_bot.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_bot.py b/tests/test_bot.py index 9ac116b8..0a59ecd2 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -64,7 +64,8 @@ async def test_turn_off_accepted_overrides_state() -> None: @pytest.mark.asyncio async def test_turn_on_rejected_preserves_state() -> None: - """Rejected command (e.g. 0x03 0xff 0x00) must NOT override the cached state. + """ + Rejected command (e.g. 0x03 0xff 0x00) must NOT override the cached state. Regression for sblibs/pySwitchbot#213: back-to-back presses where the bot silently ignores the second one would still flip HA's state to ``on`` @@ -96,7 +97,9 @@ async def test_inverse_mode_is_on_reflects_override() -> None: """is_on() must respect inverse_mode after a successful turn_on.""" ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any") device = bot.Switchbot(ble_device, model=SwitchbotModel.BOT, inverse_mode=True) - device.update_from_advertisement(make_advertisement_data(ble_device, {"isOn": True})) + device.update_from_advertisement( + make_advertisement_data(ble_device, {"isOn": True}) + ) device._send_command = AsyncMock() device._check_command_result = MagicMock(return_value=True) device.update = AsyncMock()