Browse Source

Initial Blind Tilt support (#181)

Co-authored-by: Ben Morton <ben.morton91@gmail.com>
Co-authored-by: Ben Morton <ben.morton@pebble.tv>
Co-authored-by: J. Nick Koston <nick@koston.org>
closes https://github.com/Danielhiversen/pySwitchbot/issues/172
Jesse Hills 1 year ago
parent
commit
fbd0cfcc88

+ 2 - 0
switchbot/__init__.py

@@ -11,6 +11,7 @@ from .const import (
     SwitchbotModel,
 )
 from .devices.base_light import SwitchbotBaseLight
+from .devices.blind_tilt import SwitchbotBlindTilt
 from .devices.bot import Switchbot
 from .devices.bulb import SwitchbotBulb
 from .devices.ceiling_light import SwitchbotCeilingLight
@@ -45,4 +46,5 @@ __all__ = [
     "SwitchbotSupportedType",
     "SwitchbotModel",
     "SwitchbotLock",
+    "SwitchbotBlindTilt",
 ]

+ 8 - 0
switchbot/adv_parser.py

@@ -9,6 +9,7 @@ from typing import Any, TypedDict
 from bleak.backends.device import BLEDevice
 from bleak.backends.scanner import AdvertisementData
 
+from .adv_parsers.blind_tilt import process_woblindtilt
 from .adv_parsers.bot import process_wohand
 from .adv_parsers.bulb import process_color_bulb
 from .adv_parsers.ceiling_light import process_woceiling
@@ -126,6 +127,13 @@ SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = {
         "func": process_wolock,
         "manufacturer_id": 2409,
     },
+    "x": {
+        "modelName": SwitchbotModel.BLIND_TILT,
+        "modelFriendlyName": "Blind Tilt",
+        "func": process_woblindtilt,
+        "manufacturer_id": 2409,
+        "manufacturer_data_length": 10,
+    },
 }
 
 _SWITCHBOT_MODEL_TO_CHAR = {

+ 26 - 0
switchbot/adv_parsers/blind_tilt.py

@@ -0,0 +1,26 @@
+"""Library to handle connection with Switchbot."""
+from __future__ import annotations
+
+
+def process_woblindtilt(
+    data: bytes | None, mfr_data: bytes | None, reverse: bool = False
+) -> dict[str, bool | int]:
+    """Process woBlindTilt services data."""
+
+    if mfr_data is None:
+        return {}
+
+    device_data = mfr_data[6:]
+
+    _tilt = max(min(device_data[2] & 0b01111111, 100), 0)
+    _in_motion = bool(device_data[2] & 0b10000000)
+    _light_level = (device_data[1] >> 4) & 0b00001111
+    _calibrated = bool(device_data[1] & 0b00000001)
+
+    return {
+        "calibration": _calibrated,
+        "battery": data[2] & 0b01111111 if data else None,
+        "inMotion": _in_motion,
+        "tilt": (100 - _tilt) if reverse else _tilt,
+        "lightLevel": _light_level,
+    }

+ 1 - 0
switchbot/const.py

@@ -39,6 +39,7 @@ class SwitchbotModel(StrEnum):
     COLOR_BULB = "WoBulb"
     CEILING_LIGHT = "WoCeiling"
     LOCK = "WoLock"
+    BLIND_TILT = "WoBlindTilt"
 
 
 class LockStatus(Enum):

+ 116 - 0
switchbot/devices/blind_tilt.py

@@ -0,0 +1,116 @@
+"""Library to handle connection with Switchbot."""
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from switchbot.devices.device import REQ_HEADER, update_after_operation
+
+from .curtain import CURTAIN_EXT_SUM_KEY, SwitchbotCurtain
+
+_LOGGER = logging.getLogger(__name__)
+
+
+BLIND_COMMAND = "4501"
+OPEN_KEYS = [
+    f"{REQ_HEADER}{BLIND_COMMAND}010132",
+    f"{REQ_HEADER}{BLIND_COMMAND}05ff32",
+]
+CLOSE_DOWN_KEYS = [
+    f"{REQ_HEADER}{BLIND_COMMAND}010100",
+    f"{REQ_HEADER}{BLIND_COMMAND}05ff00",
+]
+CLOSE_UP_KEYS = [
+    f"{REQ_HEADER}{BLIND_COMMAND}010164",
+    f"{REQ_HEADER}{BLIND_COMMAND}05ff64",
+]
+
+
+class SwitchbotBlindTilt(SwitchbotCurtain):
+    """Representation of a Switchbot Blind Tilt."""
+
+    # The position of the blind is saved returned with 0 = closed down, 50 = open and 100 = closed up.
+    # This is independent of the calibration of the blind.
+    # The parameter 'reverse_mode' reverse these values,
+    # if 'reverse_mode' = True, position = 0 equals closed up
+    # and position = 100 equals closed down. The parameter is default set to False so that
+    # the definition of position is the same as in Home Assistant.
+    # This is opposite to the base class so needs to be overwritten.
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        """Switchbot Blind Tilt/woBlindTilt constructor."""
+        super().__init__(*args, **kwargs)
+
+        self._reverse: bool = kwargs.pop("reverse_mode", False)
+
+    @update_after_operation
+    async def open(self) -> bool:
+        """Send open command."""
+        return await self._send_multiple_commands(OPEN_KEYS)
+
+    @update_after_operation
+    async def close_up(self) -> bool:
+        """Send close up command."""
+        return await self._send_multiple_commands(CLOSE_UP_KEYS)
+
+    @update_after_operation
+    async def close_down(self) -> bool:
+        """Send close down command."""
+        return await self._send_multiple_commands(CLOSE_DOWN_KEYS)
+
+    # The aim of this is to close to the nearest endpoint.
+    # If we're open upwards we close up, if we're open downwards we close down.
+    # If we're in the middle we default to close down as that seems to be the app's preference.
+    @update_after_operation
+    async def close(self) -> bool:
+        """Send close command."""
+        if self.get_position() > 50:
+            return await self.close_up()
+        else:
+            return await self.close_down()
+
+    def get_position(self) -> Any:
+        """Return cached tilt (0-100) of Blind Tilt."""
+        # To get actual tilt call update() first.
+        return self._get_adv_value("tilt")
+
+    async def get_basic_info(self) -> dict[str, Any] | None:
+        """Get device basic settings."""
+        if not (_data := await self._get_basic_info()):
+            return None
+
+        _tilt = max(min(_data[6], 100), 0)
+        return {
+            "battery": _data[1],
+            "firmware": _data[2] / 10.0,
+            "light": bool(_data[4] & 0b00100000),
+            "fault": bool(_data[4] & 0b00001000),
+            "solarPanel": bool(_data[5] & 0b00001000),
+            "calibration": bool(_data[5] & 0b00000100),
+            "calibrated": bool(_data[5] & 0b00000100),
+            "inMotion": bool(_data[5] & 0b00000011),
+            "motionDirection": {
+                "up": bool(_data[5] & (0b00000010 if self._reverse else 0b00000001)),
+                "down": bool(_data[5] & (0b00000001 if self._reverse else 0b00000010)),
+            },
+            "tilt": (100 - _tilt) if self._reverse else _tilt,
+            "timers": _data[7],
+        }
+
+    async def get_extended_info_summary(self) -> dict[str, Any] | None:
+        """Get basic info for all devices in chain."""
+        _data = await self._send_command(key=CURTAIN_EXT_SUM_KEY)
+
+        if not _data:
+            _LOGGER.error("%s: Unsuccessful, no result from device", self.name)
+            return None
+
+        if _data in (b"\x07", b"\x00"):
+            _LOGGER.error("%s: Unsuccessful, please try again", self.name)
+            return None
+
+        self.ext_info_sum["device0"] = {
+            "light": bool(_data[1] & 0b00100000),
+        }
+
+        return self.ext_info_sum

+ 9 - 1
switchbot/discovery.py

@@ -83,9 +83,17 @@ class GetSwitchbotDevices:
             if adv.data.get("model") == model
         }
 
+    async def get_blind_tilts(self) -> dict[str, SwitchBotAdvertisement]:
+        """Return all WoBlindTilt/BlindTilts devices with services data."""
+        regular_blinds = await self._get_devices_by_model("x")
+        pairing_blinds = await self._get_devices_by_model("X")
+        return {**regular_blinds, **pairing_blinds}
+
     async def get_curtains(self) -> dict[str, SwitchBotAdvertisement]:
         """Return all WoCurtain/Curtains devices with services data."""
-        return await self._get_devices_by_model("c")
+        regular_curtains = await self._get_devices_by_model("c")
+        pairing_curtains = await self._get_devices_by_model("C")
+        return {**regular_curtains, **pairing_curtains}
 
     async def get_bots(self) -> dict[str, SwitchBotAdvertisement]:
         """Return all WoHand/Bot devices with services data."""