ソースを参照

feat: add Universal Remote Control support (#520)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Fan Kai <fankai@onero.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Onero-testdev 3 日 前
コミット
2736169027

+ 2 - 0
switchbot/__init__.py

@@ -70,6 +70,7 @@ from .devices.relay_switch import (
 )
 from .devices.roller_shade import SwitchbotRollerShade
 from .devices.smart_thermostat_radiator import SwitchbotSmartThermostatRadiator
+from .devices.universal_remote import SwitchbotUniversalRemote
 from .devices.vacuum import SwitchbotVacuum
 from .discovery import GetSwitchbotDevices
 from .models import SwitchBotAdvertisement
@@ -131,6 +132,7 @@ __all__ = [
     "SwitchbotStandingFan",
     "SwitchbotStripLight3",
     "SwitchbotSupportedType",
+    "SwitchbotUniversalRemote",
     "SwitchbotVacuum",
     "VerticalOscillationAngle",
     "close_stale_connections",

+ 13 - 1
switchbot/adv_parser.py

@@ -51,7 +51,7 @@ from .adv_parsers.relay_switch import (
     process_relay_switch_2pm,
     process_relay_switch_common_data,
 )
-from .adv_parsers.remote import process_woremote
+from .adv_parsers.remote import process_woremote, process_wouniversal_remote
 from .adv_parsers.roller_shade import process_worollershade
 from .adv_parsers.smart_thermostat_radiator import process_smart_thermostat_radiator
 from .adv_parsers.vacuum import process_vacuum, process_vacuum_k
@@ -402,6 +402,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_woremote,
         "manufacturer_id": 89,
     },
+    "\x07": {
+        "modelName": SwitchbotModel.UNIVERSAL_REMOTE,
+        "modelFriendlyName": "Universal Remote",
+        "func": process_wouniversal_remote,
+        "manufacturer_id": 2409,
+    },
+    "'": {
+        "modelName": SwitchbotModel.UNIVERSAL_REMOTE,
+        "modelFriendlyName": "Universal Remote",
+        "func": process_wouniversal_remote,
+        "manufacturer_id": 2409,
+    },
     ",": {
         "modelName": SwitchbotModel.ROLLER_SHADE,
         "modelFriendlyName": "Roller Shade",

+ 19 - 0
switchbot/adv_parsers/remote.py

@@ -21,3 +21,22 @@ def process_woremote(
     return {
         "battery": data[2] & 0b01111111,
     }
+
+
+def process_wouniversal_remote(
+    data: bytes | None, mfr_data: bytes | None
+) -> dict[str, bool | int | None]:
+    """Process Universal Remote adv data."""
+    if mfr_data is None or len(mfr_data) < 8:
+        return {
+            "battery": None,
+            "charging": None,
+        }
+
+    _LOGGER.debug("mfr_data: %s", mfr_data.hex())
+
+    # mfr_data[7]: bit 7 is charging; bits 6-0 are battery percentage.
+    return {
+        "battery": mfr_data[7] & 0b01111111,
+        "charging": bool((mfr_data[7] >> 7) & 1),
+    }

+ 1 - 0
switchbot/const/__init__.py

@@ -82,6 +82,7 @@ class SwitchbotModel(StrEnum):
     RELAY_SWITCH_1PM = "Relay Switch 1PM"
     RELAY_SWITCH_1 = "Relay Switch 1"
     REMOTE = "WoRemote"
+    UNIVERSAL_REMOTE = "WoUniversalRemote"
     EVAPORATIVE_HUMIDIFIER = "Evaporative Humidifier"
     ROLLER_SHADE = "Roller Shade"
     HUBMINI_MATTER = "HubMini Matter"

+ 1 - 0
switchbot/devices/device.py

@@ -74,6 +74,7 @@ API_MODEL_TO_ENUM: dict[str, SwitchbotModel] = {
     "WoBlindTilt": SwitchbotModel.BLIND_TILT,
     "WoIOSensor": SwitchbotModel.IO_METER,  # Outdoor Meter
     "WoButton": SwitchbotModel.REMOTE,  # Remote button
+    "WoUniversalRemote": SwitchbotModel.UNIVERSAL_REMOTE,  # Universal Remote
     "WoLinkMini": SwitchbotModel.HUBMINI_MATTER,  # Hub Mini
     "WoFan2": SwitchbotModel.CIRCULATOR_FAN,
     "WoHub2": SwitchbotModel.HUB2,

+ 23 - 0
switchbot/devices/universal_remote.py

@@ -0,0 +1,23 @@
+"""Library to handle connection with Switchbot Universal Remote."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from .device import SwitchbotDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class SwitchbotUniversalRemote(SwitchbotDevice):
+    """Representation of a Switchbot Universal Remote."""
+
+    async def get_basic_info(self) -> dict[str, Any] | None:
+        """Get device basic settings."""
+        if not (_data := await self._get_basic_info()):
+            return None
+        return {
+            "battery": _data[1],
+            "charging": bool(_data[12]),
+        }

+ 47 - 0
tests/test_adv_parser.py

@@ -1711,6 +1711,53 @@ def test_remote_passive() -> None:
     )
 
 
+def test_universal_remote_active() -> None:
+    """Test Universal Remote active scan parsing."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={
+            2409: b"\xaa\xbb\xcc\xdd\xee\xff\x00\x50\x00\x00\x00\x00\x00\x00\x00\x00"
+        },
+        service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"'\x00"},
+        service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
+        rssi=-66,
+    )
+    result = parse_advertisement_data(ble_device, adv_data)
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "data": {
+                "battery": 80,
+                "charging": False,
+            },
+            "isEncrypted": False,
+            "model": "'",
+            "modelFriendlyName": "Universal Remote",
+            "modelName": SwitchbotModel.UNIVERSAL_REMOTE,
+            "rawAdvData": b"'\x00",
+        },
+        device=ble_device,
+        rssi=-66,
+        active=True,
+    )
+
+
+def test_universal_remote_charging() -> None:
+    """Test Universal Remote reports the charging bit from byte 14."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={
+            2409: b"\xaa\xbb\xcc\xdd\xee\xff\x00\xb7\x00\x00\x00\x00\x00\x00\x00\x00"
+        },
+        service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"'\x00"},
+        service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
+        rssi=-66,
+    )
+    result = parse_advertisement_data(ble_device, adv_data)
+    assert result is not None
+    assert result.data["data"] == {"battery": 55, "charging": True}
+
+
 def test_parse_advertisement_data_hubmini_matter():
     """Test parse_advertisement_data for the HubMini Matter."""
     ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")

+ 10 - 1
tests/test_short_payload_guards.py

@@ -49,7 +49,7 @@ from switchbot.adv_parsers.lock import (
 from switchbot.adv_parsers.meter import process_wosensorth
 from switchbot.adv_parsers.motion import process_wopresence
 from switchbot.adv_parsers.plug import process_woplugmini
-from switchbot.adv_parsers.remote import process_woremote
+from switchbot.adv_parsers.remote import process_woremote, process_wouniversal_remote
 from switchbot.adv_parsers.roller_shade import process_worollershade
 from switchbot.adv_parsers.smart_thermostat_radiator import (
     process_smart_thermostat_radiator,
@@ -127,6 +127,15 @@ def test_process_woremote_short_data(data):
     assert out == {"battery": None}
 
 
+@pytest.mark.parametrize(
+    "mfr_data",
+    [None, EMPTY, b"\x00", b"\x00" * 7],
+)
+def test_process_wouniversal_remote_short_mfr(mfr_data):
+    out = process_wouniversal_remote(None, mfr_data)
+    assert out == {"battery": None, "charging": None}
+
+
 @pytest.mark.parametrize(
     ("data", "mfr_data"),
     [

+ 49 - 0
tests/test_universal_remote.py

@@ -0,0 +1,49 @@
+from unittest.mock import AsyncMock
+
+import pytest
+from bleak.backends.device import BLEDevice
+
+from switchbot.devices.universal_remote import SwitchbotUniversalRemote
+
+
+def create_device() -> SwitchbotUniversalRemote:
+    """Create a Universal Remote device for command testing."""
+    ble_device = BLEDevice(
+        address="aa:bb:cc:dd:ee:ff", name="any", details={"rssi": -80}
+    )
+    device = SwitchbotUniversalRemote(ble_device)
+    device._send_command = AsyncMock()
+    return device
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("response", "expected"),
+    [
+        (
+            b"\x01\x50\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00",
+            {"battery": 80, "charging": False},
+        ),
+        (
+            b"\x01\x37\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01",
+            {"battery": 55, "charging": True},
+        ),
+    ],
+)
+async def test_get_basic_info(response: bytes, expected: dict[str, int | bool]) -> None:
+    """Test get_basic_info parses battery and charging state."""
+    device = create_device()
+    device._get_basic_info = AsyncMock(return_value=response)
+
+    info = await device.get_basic_info()
+
+    assert info == expected
+
+
+@pytest.mark.asyncio
+async def test_get_basic_info_returns_none_on_empty_response() -> None:
+    """get_basic_info returns None when the device gives no data."""
+    device = create_device()
+    device._get_basic_info = AsyncMock(return_value=None)
+
+    assert await device.get_basic_info() is None