Repository: sblibs/pySwitchbot
Pull request #315: Add roller shade and hubmini matter support
Author: login: zerzhang
Created: 2025-04-03T04:14:47Z | Merged: 2025-04-03T07:30:41Z

This adds support for roller shade and hubmini matter in preparation to start working on HA integration.


Diff:
diff --git a/switchbot/__init__.py b/switchbot/__init__.py
index 4d538dce..16a91f56 100644
--- a/switchbot/__init__.py
+++ b/switchbot/__init__.py
@@ -29,6 +29,7 @@
 from .devices.lock import SwitchbotLock
 from .devices.plug import SwitchbotPlugMini
 from .devices.relay_switch import SwitchbotRelaySwitch
+from .devices.roller_shade import SwitchbotRollerShade
 from .discovery import GetSwitchbotDevices
 from .models import SwitchBotAdvertisement
 
@@ -58,6 +59,7 @@
     "SwitchbotPlugMini",
     "SwitchbotPlugMini",
     "SwitchbotRelaySwitch",
+    "SwitchbotRollerShade",
     "SwitchbotSupportedType",
     "SwitchbotSupportedType",
     "close_stale_connections",
diff --git a/switchbot/adv_parser.py b/switchbot/adv_parser.py
index 59c20481..38c07108 100644
--- a/switchbot/adv_parser.py
+++ b/switchbot/adv_parser.py
@@ -17,6 +17,7 @@
 from .adv_parsers.contact import process_wocontact
 from .adv_parsers.curtain import process_wocurtain
 from .adv_parsers.hub2 import process_wohub2
+from .adv_parsers.hubmini_matter import process_hubmini_matter
 from .adv_parsers.humidifier import process_evaporative_humidifier, process_wohumidifier
 from .adv_parsers.keypad import process_wokeypad
 from .adv_parsers.leak import process_leak
@@ -30,6 +31,7 @@
     process_worelay_switch_1pm,
 )
 from .adv_parsers.remote import process_woremote
+from .adv_parsers.roller_shade import process_worollershade
 from .const import SwitchbotModel
 from .models import SwitchBotAdvertisement
 
@@ -216,6 +218,18 @@ class SwitchbotSupportedType(TypedDict):
         "func": process_woremote,
         "manufacturer_id": 89,
     },
+    ",": {
+        "modelName": SwitchbotModel.ROLLER_SHADE,
+        "modelFriendlyName": "Roller Shade",
+        "func": process_worollershade,
+        "manufacturer_id": 2409,
+    },
+    "%": {
+        "modelName": SwitchbotModel.HUBMINI_MATTER,
+        "modelFriendlyName": "HubMini Matter",
+        "func": process_hubmini_matter,
+        "manufacturer_id": 2409,
+    },
 }
 
 _SWITCHBOT_MODEL_TO_CHAR = {
diff --git a/switchbot/adv_parsers/hubmini_matter.py b/switchbot/adv_parsers/hubmini_matter.py
new file mode 100644
index 00000000..805b27b4
--- /dev/null
+++ b/switchbot/adv_parsers/hubmini_matter.py
@@ -0,0 +1,37 @@
+"""Hubmini matter parser."""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+def process_hubmini_matter(
+    data: bytes | None, mfr_data: bytes | None
+) -> dict[str, Any]:
+    """Process Hubmini matter sensor manufacturer data."""
+    temp_data = None
+
+    if mfr_data:
+        temp_data = mfr_data[13:16]
+
+    if not temp_data:
+        return {}
+
+    _temp_sign = 1 if temp_data[1] & 0b10000000 else -1
+    _temp_c = _temp_sign * (
+        (temp_data[1] & 0b01111111) + ((temp_data[0] & 0b00001111) / 10)
+    )
+    _temp_f = (_temp_c * 9 / 5) + 32
+    _temp_f = (_temp_f * 10) / 10
+    humidity = temp_data[2] & 0b01111111
+
+    if _temp_c == 0 and humidity == 0:
+        return {}
+
+    paraser_data = {
+        "temp": {"c": _temp_c, "f": _temp_f},
+        "temperature": _temp_c,
+        "fahrenheit": bool(temp_data[2] & 0b10000000),
+        "humidity": humidity,
+    }
+    return paraser_data
diff --git a/switchbot/adv_parsers/roller_shade.py b/switchbot/adv_parsers/roller_shade.py
new file mode 100644
index 00000000..2f80a6a1
--- /dev/null
+++ b/switchbot/adv_parsers/roller_shade.py
@@ -0,0 +1,29 @@
+"""Library to handle connection with Switchbot."""
+
+from __future__ import annotations
+
+
+def process_worollershade(
+    data: bytes | None, mfr_data: bytes | None, reverse: bool = True
+) -> dict[str, bool | int]:
+    """Process woRollerShade services data."""
+    if mfr_data is None:
+        return {}
+
+    device_data = mfr_data[6:]
+
+    _position = max(min(device_data[2] & 0b01111111, 100), 0)
+    _calibrated = bool(device_data[2] & 0b10000000)
+    _in_motion = bool(device_data[1] & 0b00000110)
+    _light_level = (device_data[3] >> 4) & 0b00001111
+    _device_chain = device_data[3] & 0b00001111
+
+    return {
+        "calibration": _calibrated,
+        "battery": data[2] & 0b01111111 if data else None,
+        "inMotion": _in_motion,
+        "position": (100 - _position) if reverse else _position,
+        "lightLevel": _light_level,
+        "deviceChain": _device_chain,
+        "sequence_number": device_data[0],
+    }
diff --git a/switchbot/const/__init__.py b/switchbot/const/__init__.py
index fa5cbdb7..f41557c4 100644
--- a/switchbot/const/__init__.py
+++ b/switchbot/const/__init__.py
@@ -63,3 +63,5 @@ class SwitchbotModel(StrEnum):
     RELAY_SWITCH_1 = "Relay Switch 1"
     REMOTE = "WoRemote"
     EVAPORATIVE_HUMIDIFIER = "Evaporative Humidifier"
+    ROLLER_SHADE = "Roller Shade"
+    HUBMINI_MATTER = "HubMini Matter"
diff --git a/switchbot/devices/base_cover.py b/switchbot/devices/base_cover.py
index fdd4bc55..6098b1a4 100644
--- a/switchbot/devices/base_cover.py
+++ b/switchbot/devices/base_cover.py
@@ -10,6 +10,8 @@
 
 # Cover keys
 COVER_COMMAND = "4501"
+ROLLERSHADE_COMMAND = "4701"
+CONTROL_SOURCE = "00"
 
 # For second element of open and close arrs we should add two bytes i.e. ff00
 # First byte [ff] stands for speed (00 or ff - normal, 01 - slow) *
diff --git a/switchbot/devices/roller_shade.py b/switchbot/devices/roller_shade.py
new file mode 100644
index 00000000..879213cf
--- /dev/null
+++ b/switchbot/devices/roller_shade.py
@@ -0,0 +1,129 @@
+"""Library to handle connection with Switchbot."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from ..models import SwitchBotAdvertisement
+from .base_cover import CONTROL_SOURCE, ROLLERSHADE_COMMAND, SwitchbotBaseCover
+from .device import REQ_HEADER, SwitchbotSequenceDevice, update_after_operation
+
+_LOGGER = logging.getLogger(__name__)
+
+
+OPEN_KEYS = [
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}01{CONTROL_SOURCE}0100",
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}05{CONTROL_SOURCE}0000",
+]
+CLOSE_KEYS = [
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}01{CONTROL_SOURCE}0164",
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}05{CONTROL_SOURCE}0064",
+]
+POSITION_KEYS = [
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}01{CONTROL_SOURCE}01",
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}05{CONTROL_SOURCE}",
+]  # +actual_position
+STOP_KEYS = [f"{REQ_HEADER}{ROLLERSHADE_COMMAND}00{CONTROL_SOURCE}01"]
+
+
+class SwitchbotRollerShade(SwitchbotBaseCover, SwitchbotSequenceDevice):
+    """Representation of a Switchbot Roller Shade."""
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        """Switchbot roller shade constructor."""
+        # The position of the roller shade is saved returned with 0 = open and 100 = closed.
+        # the definition of position is the same as in Home Assistant.
+
+        self._reverse: bool = kwargs.pop("reverse_mode", True)
+        super().__init__(self._reverse, *args, **kwargs)
+
+    def _set_parsed_data(
+        self, advertisement: SwitchBotAdvertisement, data: dict[str, Any]
+    ) -> None:
+        """Set data."""
+        in_motion = data["inMotion"]
+        previous_position = self._get_adv_value("position")
+        new_position = data["position"]
+        self._update_motion_direction(in_motion, previous_position, new_position)
+        super()._set_parsed_data(advertisement, data)
+
+    @update_after_operation
+    async def open(self, mode: int = 0) -> bool:
+        """Send open command. 0 - performance mode, 1 - unfelt mode."""
+        self._is_opening = True
+        self._is_closing = False
+        return await self._send_multiple_commands(OPEN_KEYS)
+
+    @update_after_operation
+    async def close(self, speed: int = 0) -> bool:
+        """Send close command. 0 - performance mode, 1 - unfelt mode."""
+        self._is_closing = True
+        self._is_opening = False
+        return await self._send_multiple_commands(CLOSE_KEYS)
+
+    @update_after_operation
+    async def stop(self) -> bool:
+        """Send stop command to device."""
+        self._is_opening = self._is_closing = False
+        return await self._send_multiple_commands(STOP_KEYS)
+
+    @update_after_operation
+    async def set_position(self, position: int, mode: int = 0) -> bool:
+        """Send position command (0-100) to device. 0 - performance mode, 1 - unfelt mode."""
+        position = (100 - position) if self._reverse else position
+        self._update_motion_direction(True, self._get_adv_value("position"), position)
+        return await self._send_multiple_commands(
+            [
+                f"{POSITION_KEYS[0]}{position:02X}",
+                f"{POSITION_KEYS[1]}{mode:02X}{position:02X}",
+            ]
+        )
+
+    def get_position(self) -> Any:
+        """Return cached position (0-100) of Curtain."""
+        # To get actual position call update() first.
+        return self._get_adv_value("position")
+
+    async def get_basic_info(self) -> dict[str, Any] | None:
+        """Get device basic settings."""
+        if not (_data := await self._get_basic_info()):
+            return None
+
+        _position = max(min(_data[5], 100), 0)
+        _direction_adjusted_position = (100 - _position) if self._reverse else _position
+        _previous_position = self._get_adv_value("position")
+        _in_motion = bool(_data[4] & 0b00000011)
+        self._update_motion_direction(
+            _in_motion, _previous_position, _direction_adjusted_position
+        )
+
+        return {
+            "battery": _data[1],
+            "firmware": _data[2] / 10.0,
+            "chainLength": _data[3],
+            "openDirection": (
+                "clockwise" if _data[4] & 0b10000000 == 128 else "anticlockwise"
+            ),
+            "fault": bool(_data[4] & 0b00010000),
+            "solarPanel": bool(_data[4] & 0b00001000),
+            "calibration": bool(_data[4] & 0b00000100),
+            "calibrated": bool(_data[4] & 0b00000100),
+            "inMotion": _in_motion,
+            "position": _direction_adjusted_position,
+            "timers": _data[6],
+        }
+
+    def _update_motion_direction(
+        self, in_motion: bool, previous_position: int | None, new_position: int
+    ) -> None:
+        """Update opening/closing status based on movement."""
+        if previous_position is None:
+            return
+        if in_motion is False:
+            self._is_closing = self._is_opening = False
+            return
+
+        if new_position != previous_position:
+            self._is_opening = new_position > previous_position
+            self._is_closing = new_position < previous_position
diff --git a/switchbot/discovery.py b/switchbot/discovery.py
index 5149d321..c3f770a7 100644
--- a/switchbot/discovery.py
+++ b/switchbot/discovery.py
@@ -111,7 +111,14 @@ async def get_tempsensors(self) -> dict[str, SwitchBotAdvertisement]:
         plus_meters = await self._get_devices_by_model("i")
         io_meters = await self._get_devices_by_model("w")
         hub2_meters = await self._get_devices_by_model("v")
-        return {**base_meters, **plus_meters, **io_meters, **hub2_meters}
+        hubmini_matter_meters = await self._get_devices_by_model("%")
+        return {
+            **base_meters,
+            **plus_meters,
+            **io_meters,
+            **hub2_meters,
+            **hubmini_matter_meters,
+        }
 
     async def get_contactsensors(self) -> dict[str, SwitchBotAdvertisement]:
         """Return all WoContact/Contact sensor devices with services data."""
diff --git a/tests/test_adv_parser.py b/tests/test_adv_parser.py
index 459d834e..cbd56174 100644
--- a/tests/test_adv_parser.py
+++ b/tests/test_adv_parser.py
@@ -1971,3 +1971,135 @@ def test_remote_passive() -> None:
         rssi=-97,
         active=False,
     )
+
+
+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")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={
+            2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00"
+        },
+        service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"%\x00"},
+        rssi=-67,
+    )
+    result = parse_advertisement_data(
+        ble_device, adv_data, SwitchbotModel.HUBMINI_MATTER
+    )
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "data": {
+                "fahrenheit": False,
+                "humidity": 53,
+                "temp": {"c": 24.1, "f": 75.38},
+                "temperature": 24.1,
+            },
+            "isEncrypted": False,
+            "model": "%",
+            "modelFriendlyName": "HubMini Matter",
+            "modelName": SwitchbotModel.HUBMINI_MATTER,
+            "rawAdvData": b"%\x00",
+        },
+        device=ble_device,
+        rssi=-67,
+        active=True,
+    )
+
+
+def test_parse_advertisement_data_roller_shade():
+    """Test parse_advertisement_data for roller shade."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={2409: b"\xb0\xe9\xfeT\x90\x1b,\x08\x9f\x11\x04'\x00"},
+        service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b",\x00'\x9f\x11\x04"},
+        rssi=-80,
+    )
+    result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.ROLLER_SHADE)
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": b",\x00'\x9f\x11\x04",
+            "data": {
+                "battery": 39,
+                "calibration": True,
+                "deviceChain": 1,
+                "inMotion": False,
+                "lightLevel": 1,
+                "position": 69,
+                "sequence_number": 44,
+            },
+            "isEncrypted": False,
+            "model": ",",
+            "modelFriendlyName": "Roller Shade",
+            "modelName": SwitchbotModel.ROLLER_SHADE,
+        },
+        device=ble_device,
+        rssi=-80,
+        active=True,
+    )
+
+
+def test_hubmini_matter_passive() -> None:
+    """Test parsing hubmini matter with passive data."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={
+            2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00"
+        },
+        rssi=-97,
+    )
+    result = parse_advertisement_data(
+        ble_device, adv_data, SwitchbotModel.HUBMINI_MATTER
+    )
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "data": {
+                "fahrenheit": False,
+                "humidity": 53,
+                "temp": {"c": 24.1, "f": 75.38},
+                "temperature": 24.1,
+            },
+            "isEncrypted": False,
+            "model": "%",
+            "modelFriendlyName": "HubMini Matter",
+            "modelName": SwitchbotModel.HUBMINI_MATTER,
+            "rawAdvData": None,
+        },
+        device=ble_device,
+        rssi=-97,
+        active=False,
+    )
+
+
+def test_roller_shade_passive() -> None:
+    """Test parsing roller_shade with passive data."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    adv_data = generate_advertisement_data(
+        manufacturer_data={2409: b"\xb0\xe9\xfeT\x90\x1b,\x08\x9f\x11\x04'\x00"},
+        rssi=-97,
+    )
+    result = parse_advertisement_data(ble_device, adv_data, SwitchbotModel.ROLLER_SHADE)
+    assert result == SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": None,
+            "data": {
+                "battery": None,
+                "calibration": True,
+                "deviceChain": 1,
+                "inMotion": False,
+                "lightLevel": 1,
+                "position": 69,
+                "sequence_number": 44,
+            },
+            "isEncrypted": False,
+            "model": ",",
+            "modelFriendlyName": "Roller Shade",
+            "modelName": SwitchbotModel.ROLLER_SHADE,
+        },
+        device=ble_device,
+        rssi=-97,
+        active=False,
+    )
diff --git a/tests/test_roller_shade.py b/tests/test_roller_shade.py
new file mode 100644
index 00000000..2a76fd19
--- /dev/null
+++ b/tests/test_roller_shade.py
@@ -0,0 +1,223 @@
+from unittest.mock import AsyncMock
+
+import pytest
+from bleak.backends.device import BLEDevice
+
+from switchbot import SwitchBotAdvertisement, SwitchbotModel
+from switchbot.devices import roller_shade
+
+from .test_adv_parser import generate_ble_device
+
+
+def create_device_for_command_testing(
+    position=50, calibration=True, reverse_mode=False
+):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    roller_shade_device = roller_shade.SwitchbotRollerShade(
+        ble_device, reverse_mode=reverse_mode
+    )
+    roller_shade_device.update_from_advertisement(
+        make_advertisement_data(ble_device, True, position, calibration)
+    )
+    roller_shade_device._send_multiple_commands = AsyncMock()
+    roller_shade_device.update = AsyncMock()
+    return roller_shade_device
+
+
+def make_advertisement_data(
+    ble_device: BLEDevice, in_motion: bool, position: int, calibration: bool = True
+):
+    """Set advertisement data with defaults."""
+    return SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": b",\x00'\x9f\x11\x04",
+            "data": {
+                "battery": 39,
+                "calibration": calibration,
+                "deviceChain": 1,
+                "inMotion": in_motion,
+                "lightLevel": 1,
+                "position": position,
+            },
+            "isEncrypted": False,
+            "model": ",",
+            "modelFriendlyName": "Roller Shade",
+            "modelName": SwitchbotModel.ROLLER_SHADE,
+        },
+        device=ble_device,
+        rssi=-80,
+        active=True,
+    )
+
+
+@pytest.mark.asyncio
+async def test_open():
+    roller_shade_device = create_device_for_command_testing()
+    await roller_shade_device.open()
+    assert roller_shade_device.is_opening() is True
+    assert roller_shade_device.is_closing() is False
+    roller_shade_device._send_multiple_commands.assert_awaited_once_with(
+        roller_shade.OPEN_KEYS
+    )
+
+
+@pytest.mark.asyncio
+async def test_close():
+    roller_shade_device = create_device_for_command_testing()
+    await roller_shade_device.close()
+    assert roller_shade_device.is_opening() is False
+    assert roller_shade_device.is_closing() is True
+    roller_shade_device._send_multiple_commands.assert_awaited_once_with(
+        roller_shade.CLOSE_KEYS
+    )
+
+
+@pytest.mark.asyncio
+async def test_get_basic_info_returns_none_when_no_data():
+    roller_shade_device = create_device_for_command_testing()
+    roller_shade_device._get_basic_info = AsyncMock(return_value=None)
+
+    assert await roller_shade_device.get_basic_info() is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "reverse_mode,data,result",
+    [
+        (
+            True,
+            bytes([0, 1, 10, 2, 0, 50, 4]),
+            [1, 1, 2, "anticlockwise", False, False, False, False, False, 50, 4],
+        ),
+        (
+            True,
+            bytes([0, 1, 10, 2, 214, 50, 4]),
+            [1, 1, 2, "clockwise", True, False, True, True, True, 50, 4],
+        ),
+    ],
+)
+async def test_get_basic_info(reverse_mode, data, result):
+    blind_device = create_device_for_command_testing(reverse_mode=reverse_mode)
+    blind_device._get_basic_info = AsyncMock(return_value=data)
+
+    info = await blind_device.get_basic_info()
+    assert info["battery"] == result[0]
+    assert info["firmware"] == result[1]
+    assert info["chainLength"] == result[2]
+    assert info["openDirection"] == result[3]
+    assert info["fault"] == result[4]
+    assert info["solarPanel"] == result[5]
+    assert info["calibration"] == result[6]
+    assert info["calibrated"] == result[7]
+    assert info["inMotion"] == result[8]
+    assert info["position"] == result[9]
+    assert info["timers"] == result[10]
+
+
+@pytest.mark.parametrize("reverse_mode", [(True), (False)])
+def test_device_passive_closing(reverse_mode):
+    """Test passive closing advertisement."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    curtain_device = roller_shade.SwitchbotRollerShade(
+        ble_device, reverse_mode=reverse_mode
+    )
+    curtain_device.update_from_advertisement(
+        make_advertisement_data(ble_device, True, 100)
+    )
+    curtain_device.update_from_advertisement(
+        make_advertisement_data(ble_device, True, 90)
+    )
+
+    assert curtain_device.is_opening() is False
+    assert curtain_device.is_closing() is True
+
+
+@pytest.mark.parametrize("reverse_mode", [(True), (False)])
+def test_device_passive_opening_then_stop(reverse_mode):
+    """Test passive stopped after opening advertisement."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    curtain_device = roller_shade.SwitchbotRollerShade(
+        ble_device, reverse_mode=reverse_mode
+    )
+    curtain_device.update_from_advertisement(
+        make_advertisement_data(ble_device, True, 0)
+    )
+    curtain_device.update_from_advertisement(
+        make_advertisement_data(ble_device, True, 10)
+    )
+    curtain_device.update_from_advertisement(
+        make_advertisement_data(ble_device, False, 10)
+    )
+
+    assert curtain_device.is_opening() is False
+    assert curtain_device.is_closing() is False
+
+
+@pytest.mark.parametrize("reverse_mode", [(True), (False)])
+def test_device_passive_closing_then_stop(reverse_mode):
+    """Test passive stopped after closing advertisement."""
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    curtain_device = roller_shade.SwitchbotRollerShade(
+        ble_device, reverse_mode=reverse_mode
+    )
+    curtain_device.update_from_advertisement(
+        make_advertisement_data(ble_device, True, 100)
+    )
+    curtain_device.update_from_advertisement(
+        make_advertisement_data(ble_device, True, 90)
+    )
+    curtain_device.update_from_advertisement(
+        make_advertisement_data(ble_device, False, 90)
+    )
+
+    assert curtain_device.is_opening() is False
+    assert curtain_device.is_closing() is False
+
+
+@pytest.mark.asyncio
+async def test_stop():
+    curtain_device = create_device_for_command_testing()
+    await curtain_device.stop()
+    assert curtain_device.is_opening() is False
+    assert curtain_device.is_closing() is False
+    curtain_device._send_multiple_commands.assert_awaited_once_with(
+        roller_shade.STOP_KEYS
+    )
+
+
+@pytest.mark.asyncio
+async def test_set_position_opening():
+    curtain_device = create_device_for_command_testing(reverse_mode=True)
+    await curtain_device.set_position(0)
+    assert curtain_device.is_opening() is True
+    assert curtain_device.is_closing() is False
+    curtain_device._send_multiple_commands.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_set_position_closing():
+    curtain_device = create_device_for_command_testing(reverse_mode=True)
+    await curtain_device.set_position(100)
+    assert curtain_device.is_opening() is False
+    assert curtain_device.is_closing() is True
+    curtain_device._send_multiple_commands.assert_awaited_once()
+
+
+def test_get_position():
+    curtain_device = create_device_for_command_testing()
+    assert curtain_device.get_position() == 50
+
+
+def test_update_motion_direction_with_no_previous_position():
+    curtain_device = create_device_for_command_testing(reverse_mode=True)
+    curtain_device._update_motion_direction(True, None, 100)
+    assert curtain_device.is_opening() is False
+    assert curtain_device.is_closing() is False
+
+
+def test_update_motion_direction_with_previous_position():
+    curtain_device = create_device_for_command_testing(reverse_mode=True)
+    curtain_device._update_motion_direction(True, 50, 100)
+    assert curtain_device.is_opening() is True
+    assert curtain_device.is_closing() is False


Reviews:
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T04:21:43Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T04:22:06Z
user: login: bdraco
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-03T04:25:25Z
user: login: bdraco
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-03T07:30:22Z

Review comments:
diff_hunk: @@ -1971,3 +1971,70 @@ def test_remote_passive() -> None:
         rssi=-97,
         active=False,
     )
+
+
path: tests/test_adv_parser.py
user: login: bdraco
body: Please add tests for when the Bluetooth adapter is in scanning in passive mode
created_at: 2025-04-03T04:21:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,128 @@
+"""Library to handle connection with Switchbot."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from ..models import SwitchBotAdvertisement
+from .base_cover import CONTROL_SOURCE, ROLLERSHADE_COMMAND, SwitchbotBaseCover
+from .device import REQ_HEADER, SwitchbotSequenceDevice, update_after_operation
+
+_LOGGER = logging.getLogger(__name__)
+
+
+OPEN_KEYS = [
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}01{CONTROL_SOURCE}0100",
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}05{CONTROL_SOURCE}0000",
+]
+CLOSE_KEYS = [
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}01{CONTROL_SOURCE}0164",
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}05{CONTROL_SOURCE}0064",
+]
+POSITION_KEYS = [
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}01{CONTROL_SOURCE}01",
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}05{CONTROL_SOURCE}",
+]  # +actual_position
+STOP_KEYS = [f"{REQ_HEADER}{ROLLERSHADE_COMMAND}00{CONTROL_SOURCE}01"]
+
+
+class SwitchbotRollerShade(SwitchbotBaseCover, SwitchbotSequenceDevice):
+    """Representation of a Switchbot Roller Shade."""
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        """Switchbot roller shade constructor."""
+        # The position of the roller shade is saved returned with 0 = open and 100 = closed.
+        # the definition of position is the same as in Home Assistant.
+
+        self._reverse: bool = kwargs.pop("reverse_mode", True)
+        super().__init__(self._reverse, *args, **kwargs)
+
+    def _set_parsed_data(
+        self, advertisement: SwitchBotAdvertisement, data: dict[str, Any]
+    ) -> None:
+        """Set data."""
+        in_motion = data["inMotion"]
+        previous_position = self._get_adv_value("position")
+        new_position = data["position"]
+        self._update_motion_direction(in_motion, previous_position, new_position)
+        super()._set_parsed_data(advertisement, data)
+
+    @update_after_operation
+    async def open(self, mode: int = 0) -> bool:
+        """Send open command. 0 - performance mode, 1 - unfelt mode."""
+        self._is_opening = True
+        self._is_closing = False
+        return await self._send_multiple_commands(OPEN_KEYS)
+
+    @update_after_operation
+    async def close(self, speed: int = 0) -> bool:
+        """Send close command. 0 - performance mode, 1 - unfelt mode."""
+        self._is_closing = True
+        self._is_opening = False
+        return await self._send_multiple_commands(CLOSE_KEYS)
+
+    @update_after_operation
+    async def stop(self) -> bool:
+        """Send stop command to device."""
+        self._is_opening = self._is_closing = False
+        return await self._send_multiple_commands(STOP_KEYS)
+
+    @update_after_operation
+    async def set_position(self, position: int, mode: int = 0) -> bool:
+        """Send position command (0-100) to device. 0 - performance mode, 1 - unfelt mode."""
+        position = (100 - position) if self._reverse else position
+        return await self._send_multiple_commands(
+            [
+                f"{POSITION_KEYS[0]}{position:02X}",
+                f"{POSITION_KEYS[1]}{mode:02X}{position:02X}",
+            ]
+        )
+
+    def get_position(self) -> Any:
+        """Return cached position (0-100) of Curtain."""
+        # To get actual position call update() first.
+        return self._get_adv_value("position")
+
+    async def get_basic_info(self) -> dict[str, Any] | None:
+        """Get device basic settings."""
+        if not (_data := await self._get_basic_info()):
+            return None
+
+        _position = max(min(_data[5], 100), 0)
+        _direction_adjusted_position = (100 - _position) if self._reverse else _position
+        _previous_position = self._get_adv_value("position")
+        _in_motion = bool(_data[4] & 0b00000011)
+        self._update_motion_direction(
+            _in_motion, _previous_position, _direction_adjusted_position
+        )
+
+        return {
+            "battery": _data[1],
+            "firmware": _data[2] / 10.0,
+            "chainLength": _data[3],
+            "openDirection": (
+                "clockwise" if _data[4] & 0b10000000 == 128 else "anticlockwise"
+            ),
+            "fault": bool(_data[4] & 0b00010000),
+            "solarPanel": bool(_data[4] & 0b00001000),
+            "calibration": bool(_data[4] & 0b00000100),
+            "calibrated": bool(_data[4] & 0b00000100),
+            "inMotion": _in_motion,
+            "position": _direction_adjusted_position,
+            "timers": _data[6],
+        }
+
+    def _update_motion_direction(
+        self, in_motion: bool, previous_position: int | None, new_position: int
+    ) -> None:
+        """Update opening/closing status based on movement."""
+        if previous_position is None:
+            return
path: switchbot/devices/roller_shade.py
user: login: bdraco
body: It would be nice to cover this case
created_at: 2025-04-03T04:22:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,128 @@
+"""Library to handle connection with Switchbot."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from ..models import SwitchBotAdvertisement
+from .base_cover import CONTROL_SOURCE, ROLLERSHADE_COMMAND, SwitchbotBaseCover
+from .device import REQ_HEADER, SwitchbotSequenceDevice, update_after_operation
+
+_LOGGER = logging.getLogger(__name__)
+
+
+OPEN_KEYS = [
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}01{CONTROL_SOURCE}0100",
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}05{CONTROL_SOURCE}0000",
+]
+CLOSE_KEYS = [
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}01{CONTROL_SOURCE}0164",
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}05{CONTROL_SOURCE}0064",
+]
+POSITION_KEYS = [
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}01{CONTROL_SOURCE}01",
+    f"{REQ_HEADER}{ROLLERSHADE_COMMAND}05{CONTROL_SOURCE}",
+]  # +actual_position
+STOP_KEYS = [f"{REQ_HEADER}{ROLLERSHADE_COMMAND}00{CONTROL_SOURCE}01"]
+
+
+class SwitchbotRollerShade(SwitchbotBaseCover, SwitchbotSequenceDevice):
+    """Representation of a Switchbot Roller Shade."""
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        """Switchbot roller shade constructor."""
+        # The position of the roller shade is saved returned with 0 = open and 100 = closed.
+        # the definition of position is the same as in Home Assistant.
+
+        self._reverse: bool = kwargs.pop("reverse_mode", True)
+        super().__init__(self._reverse, *args, **kwargs)
+
+    def _set_parsed_data(
+        self, advertisement: SwitchBotAdvertisement, data: dict[str, Any]
+    ) -> None:
+        """Set data."""
+        in_motion = data["inMotion"]
+        previous_position = self._get_adv_value("position")
+        new_position = data["position"]
+        self._update_motion_direction(in_motion, previous_position, new_position)
+        super()._set_parsed_data(advertisement, data)
+
+    @update_after_operation
+    async def open(self, mode: int = 0) -> bool:
+        """Send open command. 0 - performance mode, 1 - unfelt mode."""
+        self._is_opening = True
+        self._is_closing = False
+        return await self._send_multiple_commands(OPEN_KEYS)
+
+    @update_after_operation
+    async def close(self, speed: int = 0) -> bool:
+        """Send close command. 0 - performance mode, 1 - unfelt mode."""
+        self._is_closing = True
+        self._is_opening = False
+        return await self._send_multiple_commands(CLOSE_KEYS)
+
+    @update_after_operation
+    async def stop(self) -> bool:
path: switchbot/devices/roller_shade.py
user: login: bdraco
body: stop and set_position seem rather important to add coverage for as well
created_at: 2025-04-03T04:25:25Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-03T04:15:45Z
body: ## [Codecov](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs) Report
Attention: Patch coverage is `94.84536%` with `5 lines` in your changes missing coverage. Please review.

| [Files with missing lines](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs) | Patch % | Lines |
|---|---|---|
| [switchbot/adv\_parsers/hubmini\_matter.py](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?src=pr&el=tree&filepath=switchbot%2Fadv_parsers%2Fhubmini_matter.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs#diff-c3dpdGNoYm90L2Fkdl9wYXJzZXJzL2h1Ym1pbmlfbWF0dGVyLnB5) | 88.23% | [2 Missing :warning: ](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs) |
| [switchbot/discovery.py](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?src=pr&el=tree&filepath=switchbot%2Fdiscovery.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs#diff-c3dpdGNoYm90L2Rpc2NvdmVyeS5weQ==) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs) |
| [switchbot/adv\_parsers/roller\_shade.py](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?src=pr&el=tree&filepath=switchbot%2Fadv_parsers%2Froller_shade.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs#diff-c3dpdGNoYm90L2Fkdl9wYXJzZXJzL3JvbGxlcl9zaGFkZS5weQ==) | 90.90% | [1 Missing :warning: ](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs) |

| [Files with missing lines](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs) | Coverage Δ | |
|---|---|---|
| [switchbot/\_\_init\_\_.py](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?src=pr&el=tree&filepath=switchbot%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs#diff-c3dpdGNoYm90L19faW5pdF9fLnB5) | `100.00% <100.00%> (ø)` | |
| [switchbot/adv\_parser.py](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?src=pr&el=tree&filepath=switchbot%2Fadv_parser.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs#diff-c3dpdGNoYm90L2Fkdl9wYXJzZXIucHk=) | `93.18% <100.00%> (+0.15%)` | :arrow_up: |
| [switchbot/const/\_\_init\_\_.py](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?src=pr&el=tree&filepath=switchbot%2Fconst%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs#diff-c3dpdGNoYm90L2NvbnN0L19faW5pdF9fLnB5) | `100.00% <100.00%> (ø)` | |
| [switchbot/devices/base\_cover.py](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?src=pr&el=tree&filepath=switchbot%2Fdevices%2Fbase_cover.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs#diff-c3dpdGNoYm90L2RldmljZXMvYmFzZV9jb3Zlci5weQ==) | `100.00% <100.00%> (ø)` | |
| [switchbot/devices/roller\_shade.py](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?src=pr&el=tree&filepath=switchbot%2Fdevices%2Froller_shade.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs#diff-c3dpdGNoYm90L2RldmljZXMvcm9sbGVyX3NoYWRlLnB5) | `100.00% <100.00%> (ø)` | |
| [switchbot/adv\_parsers/roller\_shade.py](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?src=pr&el=tree&filepath=switchbot%2Fadv_parsers%2Froller_shade.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs#diff-c3dpdGNoYm90L2Fkdl9wYXJzZXJzL3JvbGxlcl9zaGFkZS5weQ==) | `90.90% <90.90%> (ø)` | |
| [switchbot/adv\_parsers/hubmini\_matter.py](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?src=pr&el=tree&filepath=switchbot%2Fadv_parsers%2Fhubmini_matter.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs#diff-c3dpdGNoYm90L2Fkdl9wYXJzZXJzL2h1Ym1pbmlfbWF0dGVyLnB5) | `88.23% <88.23%> (ø)` | |
| [switchbot/discovery.py](https://app.codecov.io/gh/sblibs/pySwitchbot/pull/315?src=pr&el=tree&filepath=switchbot%2Fdiscovery.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sblibs#diff-c3dpdGNoYm90L2Rpc2NvdmVyeS5weQ==) | `34.66% <0.00%> (-0.47%)` | :arrow_down: |

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: bdraco
created_at: 2025-04-03T04:26:09Z
body: Thanks for the PR 👍  Looks good.

- Passive scanning needs to be considered
- There are some places missing test coverage.

author_association: MEMBER
user: login: bdraco
created_at: 2025-04-03T07:30:38Z
body: Thanks @zerzhang 
author_association: MEMBER