Browse Source

feat: add support for light strips (#88)

J. Nick Koston 1 year ago
parent
commit
bcb954af4a

+ 6 - 2
switchbot/__init__.py

@@ -3,10 +3,12 @@ from __future__ import annotations
 
 from .adv_parser import SwitchbotSupportedType, parse_advertisement_data
 from .const import SwitchbotModel
+from .devices.base_light import SwitchbotBaseLight
 from .devices.bot import Switchbot
-from .devices.bulb import ColorMode, SwitchbotBulb
+from .devices.bulb import SwitchbotBulb
 from .devices.curtain import SwitchbotCurtain
-from .devices.device import SwitchbotDevice
+from .devices.device import ColorMode, SwitchbotDevice
+from .devices.light_strip import SwitchbotLightStrip
 from .devices.plug import SwitchbotPlugMini
 from .discovery import GetSwitchbotDevices
 from .models import SwitchBotAdvertisement
@@ -16,9 +18,11 @@ __all__ = [
     "GetSwitchbotDevices",
     "SwitchBotAdvertisement",
     "ColorMode",
+    "SwitchbotBaseLight",
     "SwitchbotBulb",
     "SwitchbotDevice",
     "SwitchbotCurtain",
+    "SwitchbotLightStrip",
     "Switchbot",
     "SwitchbotPlugMini",
     "SwitchbotSupportedType",

+ 6 - 0
switchbot/adv_parser.py

@@ -11,6 +11,7 @@ from .adv_parsers.bot import process_wohand
 from .adv_parsers.bulb import process_color_bulb
 from .adv_parsers.contact import process_wocontact
 from .adv_parsers.curtain import process_wocurtain
+from .adv_parsers.light_strip import process_wostrip
 from .adv_parsers.meter import process_wosensorth
 from .adv_parsers.motion import process_wopresence
 from .adv_parsers.plug import process_woplugmini
@@ -42,6 +43,11 @@ SUPPORTED_TYPES: dict[str, SwitchbotSupportedType] = {
         "modelFriendlyName": "Motion Sensor",
         "func": process_wopresence,
     },
+    "r": {
+        "modelName": SwitchbotModel.LIGHT_STRIP,
+        "modelFriendlyName": "Light Strip",
+        "func": process_wostrip,
+    },
     "c": {
         "modelName": SwitchbotModel.CURTAIN,
         "modelFriendlyName": "Curtain",

+ 17 - 0
switchbot/adv_parsers/light_strip.py

@@ -0,0 +1,17 @@
+"""Light strip adv parser."""
+from __future__ import annotations
+
+
+def process_wostrip(data: bytes, mfr_data: bytes | None) -> dict[str, bool | int]:
+    """Process WoStrip services data."""
+    assert mfr_data is not None
+    return {
+        "sequence_number": mfr_data[6],
+        "isOn": bool(mfr_data[7] & 0b10000000),
+        "brightness": mfr_data[7] & 0b01111111,
+        "delay": bool(mfr_data[8] & 0b10000000),
+        "preset": bool(mfr_data[8] & 0b00001000),
+        "color_mode": mfr_data[8] & 0b00000111,
+        "speed": mfr_data[9] & 0b01111111,
+        "loop_index": mfr_data[10] & 0b11111110,
+    }

+ 1 - 0
switchbot/const.py

@@ -14,6 +14,7 @@ class SwitchbotModel(StrEnum):
     CURTAIN = "WoCurtain"
     PLUG_MINI = "WoPlug"
     CONTACT_SENSOR = "WoContact"
+    LIGHT_STRIP = "WoStrip"
     METER = "WoSensorTH"
     MOTION_SENSOR = "WoPresence"
     COLOR_BULB = "WoBulb"

+ 77 - 0
switchbot/devices/base_light.py

@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+import logging
+from abc import abstractmethod
+from typing import Any
+
+from .device import ColorMode, SwitchbotSequenceDevice
+
+
+class SwitchbotBaseLight(SwitchbotSequenceDevice):
+    """Representation of a Switchbot light."""
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        """Switchbot bulb constructor."""
+        super().__init__(*args, **kwargs)
+        self._state: dict[str, Any] = {}
+
+    @property
+    def on(self) -> bool | None:
+        """Return if bulb is on."""
+        return self.is_on()
+
+    @property
+    def rgb(self) -> tuple[int, int, int] | None:
+        """Return the current rgb value."""
+        if "r" not in self._state or "g" not in self._state or "b" not in self._state:
+            return None
+        return self._state["r"], self._state["g"], self._state["b"]
+
+    @property
+    def color_temp(self) -> int | None:
+        """Return the current color temp value."""
+        return self._state.get("cw") or self.min_temp
+
+    @property
+    def brightness(self) -> int | None:
+        """Return the current brightness value."""
+        return self._get_adv_value("brightness") or 0
+
+    @property
+    def color_mode(self) -> ColorMode:
+        """Return the current color mode."""
+        return ColorMode(self._get_adv_value("color_mode") or 0)
+
+    @property
+    def min_temp(self) -> int:
+        """Return minimum color temp."""
+        return 2700
+
+    @property
+    def max_temp(self) -> int:
+        """Return maximum color temp."""
+        return 6500
+
+    def is_on(self) -> bool | None:
+        """Return bulb state from cache."""
+        return self._get_adv_value("isOn")
+
+    @abstractmethod
+    async def turn_on(self) -> bool:
+        """Turn device on."""
+
+    @abstractmethod
+    async def turn_off(self) -> bool:
+        """Turn device off."""
+
+    @abstractmethod
+    async def set_brightness(self, brightness: int) -> bool:
+        """Set brightness."""
+
+    @abstractmethod
+    async def set_color_temp(self, brightness: int, color_temp: int) -> bool:
+        """Set color temp."""
+
+    @abstractmethod
+    async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool:
+        """Set rgb."""

+ 8 - 24
switchbot/devices/bulb.py

@@ -7,7 +7,7 @@ from typing import Any
 
 from switchbot.models import SwitchBotAdvertisement
 
-from .device import SwitchbotDevice
+from .device import SwitchbotDevice, SwitchbotSequenceDevice
 
 REQ_HEADER = "570f"
 BULB_COMMMAND_HEADER = "4701"
@@ -25,16 +25,10 @@ CW_KEY = f"{BULB_COMMAND}17"
 
 _LOGGER = logging.getLogger(__name__)
 
+from .device import ColorMode
 
-class ColorMode(Enum):
 
-    OFF = 0
-    COLOR_TEMP = 1
-    RGB = 2
-    EFFECT = 3
-
-
-class SwitchbotBulb(SwitchbotDevice):
+class SwitchbotBulb(SwitchbotSequenceDevice):
     """Representation of a Switchbot bulb."""
 
     def __init__(self, *args: Any, **kwargs: Any) -> None:
@@ -69,6 +63,11 @@ class SwitchbotBulb(SwitchbotDevice):
         """Return the current color mode."""
         return ColorMode(self._get_adv_value("color_mode") or 0)
 
+    @property
+    def color_modes(self) -> set[ColorMode]:
+        """Return the supported color modes."""
+        return {ColorMode.RGB, ColorMode.COLOR_TEMP}
+
     @property
     def min_temp(self) -> int:
         """Return minimum color temp."""
@@ -141,18 +140,3 @@ class SwitchbotBulb(SwitchbotDevice):
             "%s: Bulb update state: %s = %s", self.name, result.hex(), self._state
         )
         self._fire_callbacks()
-
-    def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None:
-        """Update device data from advertisement."""
-        current_state = self._get_adv_value("sequence_number")
-        super().update_from_advertisement(advertisement)
-        new_state = self._get_adv_value("sequence_number")
-        _LOGGER.debug(
-            "%s: Bulb update advertisement: %s (seq before: %s) (seq after: %s)",
-            self.name,
-            advertisement,
-            current_state,
-            new_state,
-        )
-        if current_state != new_state:
-            asyncio.ensure_future(self.update())

+ 26 - 0
switchbot/devices/device.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 import asyncio
 import binascii
 import logging
+from enum import Enum
 from typing import Any, Callable
 from uuid import UUID
 
@@ -41,6 +42,14 @@ BLEAK_EXCEPTIONS = (AttributeError, BleakError, asyncio.exceptions.TimeoutError)
 DISCONNECT_DELAY = 49
 
 
+class ColorMode(Enum):
+
+    OFF = 0
+    COLOR_TEMP = 1
+    RGB = 2
+    EFFECT = 3
+
+
 class CharacteristicMissingError(Exception):
     """Raised when a characteristic is missing."""
 
@@ -396,3 +405,20 @@ class SwitchbotDevice:
 
     async def update(self) -> None:
         """Update state of device."""
+
+
+class SwitchbotSequenceDevice(SwitchbotDevice):
+    def update_from_advertisement(self, advertisement: SwitchBotAdvertisement) -> None:
+        """Update device data from advertisement."""
+        current_state = self._get_adv_value("sequence_number")
+        super().update_from_advertisement(advertisement)
+        new_state = self._get_adv_value("sequence_number")
+        _LOGGER.debug(
+            "%s: Strip update advertisement: %s (seq before: %s) (seq after: %s)",
+            self.name,
+            advertisement,
+            current_state,
+            new_state,
+        )
+        if current_state != new_state:
+            asyncio.ensure_future(self.update())

+ 87 - 0
switchbot/devices/light_strip.py

@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+REQ_HEADER = "570f"
+STRIP_COMMMAND_HEADER = "4901"
+STRIP_REQUEST = f"{REQ_HEADER}4A01"
+
+STRIP_COMMAND = f"{REQ_HEADER}{STRIP_COMMMAND_HEADER}"
+# Strip keys
+STRIP_ON_KEY = f"{STRIP_COMMAND}01"
+STRIP_OFF_KEY = f"{STRIP_COMMAND}02"
+RGB_BRIGHTNESS_KEY = f"{STRIP_COMMAND}12"
+BRIGHTNESS_KEY = f"{STRIP_COMMAND}14"
+
+_LOGGER = logging.getLogger(__name__)
+
+
+from .base_light import SwitchbotBaseLight
+from .device import ColorMode
+
+
+class SwitchbotLightStrip(SwitchbotBaseLight):
+    """Representation of a Switchbot light strip."""
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        """Switchbot light strip constructor."""
+        super().__init__(*args, **kwargs)
+        self._state: dict[str, Any] = {}
+
+    @property
+    def color_modes(self) -> set[ColorMode]:
+        """Return the supported color modes."""
+        return {ColorMode.RGB}
+
+    async def update(self) -> None:
+        """Update state of device."""
+        result = await self._sendcommand(STRIP_REQUEST)
+        self._update_state(result)
+
+    async def turn_on(self) -> bool:
+        """Turn device on."""
+        result = await self._sendcommand(STRIP_ON_KEY)
+        self._update_state(result)
+        return result[1] == 0x80
+
+    async def turn_off(self) -> bool:
+        """Turn device off."""
+        result = await self._sendcommand(STRIP_OFF_KEY)
+        self._update_state(result)
+        return result[1] == 0x00
+
+    async def set_brightness(self, brightness: int) -> bool:
+        """Set brightness."""
+        assert 0 <= brightness <= 100, "Brightness must be between 0 and 100"
+        result = await self._sendcommand(f"{BRIGHTNESS_KEY}{brightness:02X}")
+        self._update_state(result)
+        return result[1] == 0x80
+
+    async def set_color_temp(self, brightness: int, color_temp: int) -> bool:
+        """Set color temp."""
+        # not supported on this device
+
+    async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool:
+        """Set rgb."""
+        assert 0 <= brightness <= 100, "Brightness must be between 0 and 100"
+        assert 0 <= r <= 255, "r must be between 0 and 255"
+        assert 0 <= g <= 255, "g must be between 0 and 255"
+        assert 0 <= b <= 255, "b must be between 0 and 255"
+        result = await self._sendcommand(
+            f"{RGB_BRIGHTNESS_KEY}{brightness:02X}{r:02X}{g:02X}{b:02X}"
+        )
+        self._update_state(result)
+        return result[1] == 0x80
+
+    def _update_state(self, result: bytes) -> None:
+        """Update device state."""
+        if len(result) < 10:
+            return
+        self._state["r"] = result[3]
+        self._state["g"] = result[4]
+        self._state["b"] = result[5]
+        _LOGGER.debug(
+            "%s: Bulb update state: %s = %s", self.name, result.hex(), self._state
+        )
+        self._fire_callbacks()