Преглед изворни кода

feat(bot): add inverse direction controls (#543)

* feat(bot): add inverse direction controls

* fix(bot): preserve existing inverse mode behavior
bingxyz пре 3 дана
родитељ
комит
ad370345d3
2 измењених фајлова са 167 додато и 0 уклоњено
  1. 17 0
      switchbot/devices/bot.py
  2. 150 0
      tests/test_bot.py

+ 17 - 0
switchbot/devices/bot.py

@@ -9,6 +9,7 @@ from .device import (
     DEVICE_SET_EXTENDED_KEY,
     DEVICE_SET_MODE_KEY,
     SwitchbotDeviceOverrideStateDuringConnection,
+    SwitchbotOperationError,
     update_after_operation,
 )
 
@@ -90,6 +91,18 @@ class Switchbot(SwitchbotDeviceOverrideStateDuringConnection):
         result = await self._send_command(DEVICE_SET_MODE_KEY + strength_key + mode_key)
         return self._check_command_result(result, 0, {1})
 
+    async def set_inverse_direction(self, inverse: bool) -> bool:
+        """Set the Bot's inverse direction while preserving its other mode settings."""
+        if not (settings := await self.get_basic_info()):
+            raise SwitchbotOperationError(
+                f"{self.name}: Unable to get current Bot mode settings"
+            )
+        return await self.set_switch_mode(
+            switch_mode=settings["switchMode"],
+            strength=settings["strength"],
+            inverse=inverse,
+        )
+
     @update_after_operation
     async def set_long_press(self, duration: int = 0) -> bool:
         """Set bot long press duration."""
@@ -121,3 +134,7 @@ class Switchbot(SwitchbotDeviceOverrideStateDuringConnection):
         if self._inverse:
             return not value
         return value
+
+    def inverse_direction(self) -> bool | None:
+        """Return the cached inverse direction setting."""
+        return self._get_adv_value("inverseDirection")

+ 150 - 0
tests/test_bot.py

@@ -0,0 +1,150 @@
+from unittest.mock import AsyncMock, Mock
+
+import pytest
+from bleak.backends.device import BLEDevice
+
+from switchbot import (
+    SwitchBotAdvertisement,
+    SwitchbotModel,
+    SwitchbotOperationError,
+)
+from switchbot.devices import bot
+
+from .test_adv_parser import generate_ble_device
+
+
+def make_advertisement_data(
+    ble_device: BLEDevice,
+) -> SwitchBotAdvertisement:
+    """Create Bot advertisement data for tests."""
+    data: dict[str, bool | int | None] = {
+        "battery": 89,
+        "isOn": True,
+        "switchMode": True,
+    }
+    return SwitchBotAdvertisement(
+        address=ble_device.address,
+        data={
+            "rawAdvData": b"H\x90\xd9",
+            "data": data,
+            "isEncrypted": False,
+            "model": "H",
+            "modelFriendlyName": "Bot",
+            "modelName": SwitchbotModel.BOT,
+        },
+        device=ble_device,
+        rssi=-80,
+        active=True,
+    )
+
+
+def create_device_for_command_testing(
+    *,
+    inverse_direction: bool | None = None,
+) -> bot.Switchbot:
+    """Create a Bot device with mocked Bluetooth commands."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "WoHand")
+    device = bot.Switchbot(ble_device)
+    device.update_from_advertisement(make_advertisement_data(ble_device))
+    if inverse_direction is not None:
+        device._update_parsed_data({"inverseDirection": inverse_direction})
+    device._send_command = AsyncMock(return_value=b"\x01")
+    device.update = AsyncMock()
+    return device
+
+
+@pytest.mark.asyncio
+async def test_update_reads_inverse_direction() -> None:
+    """Test updating reads inverse direction."""
+    device = create_device_for_command_testing()
+    device.update = bot.Switchbot.update.__get__(device)
+    device._get_basic_info = AsyncMock(
+        return_value=bytes([1, 89, 31, 75, 0, 0, 0, 0, 2, 17, 0])
+    )
+
+    await device.update()
+
+    assert device.inverse_direction() is True
+    assert device.parsed_data["strength"] == 75
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("inverse_direction", "expected_command"),
+    [(False, "57034b10"), (True, "57034b11")],
+)
+async def test_set_inverse_direction_preserves_mode_and_strength(
+    inverse_direction: bool,
+    expected_command: str,
+) -> None:
+    """Test changing inverse direction preserves the current Bot settings."""
+    device = create_device_for_command_testing()
+    device.get_basic_info = AsyncMock(
+        return_value={
+            "battery": 89,
+            "firmware": 3.1,
+            "strength": 75,
+            "timers": 2,
+            "switchMode": True,
+            "inverseDirection": False,
+            "holdSeconds": 0,
+        }
+    )
+    assert await device.set_inverse_direction(inverse_direction) is True
+
+    device._send_command.assert_awaited_once_with(expected_command)
+
+
+@pytest.mark.asyncio
+async def test_set_inverse_direction_raises_when_settings_unavailable() -> None:
+    """Test changing inverse direction fails if current settings cannot be read."""
+    device = create_device_for_command_testing()
+    device.get_basic_info = AsyncMock(return_value=None)
+
+    with pytest.raises(
+        SwitchbotOperationError, match="Unable to get current Bot mode settings"
+    ):
+        await device.set_inverse_direction(True)
+
+
+@pytest.mark.asyncio
+async def test_set_inverse_direction_returns_false_for_rejected_command() -> None:
+    """Test a rejected inverse direction command does not update the cache."""
+    device = create_device_for_command_testing(inverse_direction=False)
+    device.get_basic_info = AsyncMock(
+        return_value={
+            "switchMode": True,
+            "strength": 75,
+        }
+    )
+    device._send_command = AsyncMock(return_value=b"\x00")
+
+    assert await device.set_inverse_direction(True) is False
+    assert device.inverse_direction() is False
+
+
+@pytest.mark.asyncio
+async def test_set_switch_mode_uses_confirmed_settings() -> None:
+    """Test setting switch mode caches the settings read back from the Bot."""
+    device = create_device_for_command_testing(inverse_direction=False)
+    device.update = bot.Switchbot.update.__get__(device)
+    device._send_command = AsyncMock(
+        side_effect=[
+            b"\x01",
+            bytes([1, 89, 31, 75, 0, 0, 0, 0, 2, 17, 0]),
+        ]
+    )
+    callback = Mock()
+    device.subscribe(callback)
+
+    assert await device.set_switch_mode(
+        switch_mode=True,
+        strength=75,
+        inverse=True,
+    )
+
+    assert device.switch_mode() is True
+    assert device.inverse_direction() is True
+    assert device.parsed_data["strength"] == 75
+    assert device._send_command.await_args_list[0].args == ("57034b11",)
+    callback.assert_called_once_with()