Browse Source

Add rgbicww light support for switchbot (#389)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Retha Runolfsson 1 month ago
parent
commit
6f01f5174c

+ 6 - 1
switchbot/__init__.py

@@ -40,7 +40,11 @@ from .devices.device import (
 from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
 from .devices.fan import SwitchbotFan
 from .devices.humidifier import SwitchbotHumidifier
-from .devices.light_strip import SwitchbotLightStrip, SwitchbotStripLight3
+from .devices.light_strip import (
+    SwitchbotLightStrip,
+    SwitchbotRgbicLight,
+    SwitchbotStripLight3,
+)
 from .devices.lock import SwitchbotLock
 from .devices.plug import SwitchbotPlugMini
 from .devices.relay_switch import (
@@ -92,6 +96,7 @@ __all__ = [
     "SwitchbotPlugMini",
     "SwitchbotRelaySwitch",
     "SwitchbotRelaySwitch2PM",
+    "SwitchbotRgbicLight",
     "SwitchbotRollerShade",
     "SwitchbotStripLight3",
     "SwitchbotSupportedType",

+ 13 - 1
switchbot/adv_parser.py

@@ -24,7 +24,7 @@ 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
-from .adv_parsers.light_strip import process_light, process_wostrip
+from .adv_parsers.light_strip import process_light, process_rgbic_light, process_wostrip
 from .adv_parsers.lock import (
     process_lock2,
     process_locklite,
@@ -349,6 +349,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_relay_switch_1pm,
         "manufacturer_id": 2409,
     },
+    b"\x00\x10\xd0\xb3": {
+        "modelName": SwitchbotModel.RGBICWW_STRIP_LIGHT,
+        "modelFriendlyName": "RGBICWW Strip Light",
+        "func": process_rgbic_light,
+        "manufacturer_id": 2409,
+    },
+    b"\x00\x10\xd0\xb4": {
+        "modelName": SwitchbotModel.RGBICWW_FLOOR_LAMP,
+        "modelFriendlyName": "RGBICWW Floor Lamp",
+        "func": process_rgbic_light,
+        "manufacturer_id": 2409,
+    },
 }
 
 _SWITCHBOT_MODEL_TO_CHAR = {

+ 12 - 3
switchbot/adv_parsers/light_strip.py

@@ -2,7 +2,7 @@
 
 from __future__ import annotations
 
-import struct
+from ..helpers import _UNPACK_UINT16_BE
 
 
 def process_wostrip(
@@ -21,12 +21,21 @@ def process_wostrip(
     }
 
 
-def process_light(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]:
+def process_light(
+    data: bytes | None, mfr_data: bytes | None, cw_offset: int = 16
+) -> dict[str, bool | int]:
     """Support for strip light 3 and floor lamp."""
     common_data = process_wostrip(data, mfr_data)
     if not common_data:
         return {}
 
-    light_data = {"cw": struct.unpack(">H", mfr_data[16:18])[0]}
+    light_data = {"cw": _UNPACK_UINT16_BE(mfr_data, cw_offset)[0]}
 
     return common_data | light_data
+
+
+def process_rgbic_light(
+    data: bytes | None, mfr_data: bytes | None
+) -> dict[str, bool | int]:
+    """Support for RGBIC lights."""
+    return process_light(data, mfr_data, cw_offset=10)

+ 2 - 0
switchbot/const/__init__.py

@@ -94,6 +94,8 @@ class SwitchbotModel(StrEnum):
     STRIP_LIGHT_3 = "Strip Light 3"
     FLOOR_LAMP = "Floor Lamp"
     PLUG_MINI_EU = "Plug Mini (EU)"
+    RGBICWW_STRIP_LIGHT = "RGBICWW Strip Light"
+    RGBICWW_FLOOR_LAMP = "RGBICWW Floor Lamp"
 
 
 __all__ = [

+ 11 - 0
switchbot/const/light.py

@@ -31,4 +31,15 @@ class CeilingLightColorMode(Enum):
     UNKNOWN = 10
 
 
+class RGBICStripLightColorMode(Enum):
+    SEGMENTED = 1
+    RGB = 2
+    SCENE = 3
+    MUSIC = 4
+    CONTROLLER = 5
+    COLOR_TEMP = 6
+    EFFECT = 7
+    UNKNOWN = 10
+
+
 DEFAULT_COLOR_TEMP = 4001

+ 167 - 1
switchbot/devices/light_strip.py

@@ -5,7 +5,7 @@ from typing import Any
 from bleak.backends.device import BLEDevice
 
 from ..const import SwitchbotModel
-from ..const.light import ColorMode, StripLightColorMode
+from ..const.light import ColorMode, RGBICStripLightColorMode, StripLightColorMode
 from .base_light import SwitchbotSequenceBaseLight
 from .device import SwitchbotEncryptedDevice
 
@@ -18,6 +18,15 @@ _STRIP_LIGHT_COLOR_MODE_MAP = {
     StripLightColorMode.COLOR_TEMP: ColorMode.COLOR_TEMP,
     StripLightColorMode.UNKNOWN: ColorMode.OFF,
 }
+_RGBICWW_STRIP_LIGHT_COLOR_MODE_MAP = {
+    RGBICStripLightColorMode.SEGMENTED: ColorMode.EFFECT,
+    RGBICStripLightColorMode.RGB: ColorMode.RGB,
+    RGBICStripLightColorMode.SCENE: ColorMode.EFFECT,
+    RGBICStripLightColorMode.MUSIC: ColorMode.EFFECT,
+    RGBICStripLightColorMode.CONTROLLER: ColorMode.EFFECT,
+    RGBICStripLightColorMode.COLOR_TEMP: ColorMode.COLOR_TEMP,
+    RGBICStripLightColorMode.UNKNOWN: ColorMode.OFF,
+}
 LIGHT_STRIP_CONTROL_HEADER = "570F4901"
 COMMON_EFFECTS = {
     "christmas": [
@@ -97,6 +106,122 @@ COMMON_EFFECTS = {
         "570F490701000503600C2B35040C",
     ],
 }
+RGBIC_EFFECTS = {
+    "romance": [
+        "570F490D01350100FF10EE",
+        "570F490D0363",
+    ],
+    "energy": [
+        "570F490D01000300ED070F34FF14FFE114",
+        "570F490D03FA",
+    ],
+    "heartbeat": [
+        "570F490D01020400FFDEADFE90FDFF9E3D",
+        "570F490D01020403FCBAFD",
+        "570F490D03FA",
+    ],
+    "party": [
+        "570F490D01030400FF8A47FF524DFF4DEE",
+        "570F490D010304034DFF8C",
+        "570F490D03FA",
+    ],
+    "dynamic": [
+        "570F490D010403004DFFFB4DFF4FFFBF4D",
+        "570F490D03FA",
+    ],
+    "mystery": [
+        "570F490D01050300F660F6F6D460C6F660",
+        "570F490D03FA",
+    ],
+    "lightning": [
+        "570F490D01340100FFD700",
+        "570F490D03FA",
+    ],
+    "rock": [
+        "570F490D01090300B0F6606864FCFFBC3D",
+        "570F490D03FA",
+    ],
+    "starlight": [
+        "570F490D010A0100FF8C00",
+        "570F490D0363",
+    ],
+    "valentine_day": [
+        "570F490D010C0300FDE0FFFFCC8AD7FF8A",
+        "570F490D03FA",
+    ],
+    "dream": [
+        "570F490D010E0300A3E5FF73F019FFA8E5",
+        "570F490D03FA",
+    ],
+    "alarm": [
+        "570F490D013E0100FF0000",
+        "570F490D03FA",
+    ],
+    "fireworks": [
+        "570F490D01110300FFAA33FFE233FF5CDF",
+        "570F490D03FA",
+    ],
+    "waves": [
+        "570F490D013D01001E90FF",
+        "570F490D03FA",
+    ],
+    "christmas": [
+        "570F490D01380400DC143C228B22DAA520",
+        "570F490D0363",
+        "570F490D0138040332CD32",
+        "570F490D0363",
+    ],
+    "rainbow": [
+        "570F490D01160600FF0000FF7F00FFFF00",
+        "570F490D03FA",
+        "570F490D0116060300FF000000FF9400D3",
+        "570F490D03FA",
+    ],
+    "game": [
+        "570F490D011A0400D05CFF668FFFFFEFD5",
+        "570F490D0363",
+        "570F490D011A0403FFC55C",
+        "570F490D0363",
+    ],
+    "halloween": [
+        "570F490D01320300FF8C009370DB32CD32",
+        "570F490D0364",
+    ],
+    "meditation": [
+        "570F490D013502001E90FF9370DB",
+        "570F490D0364",
+    ],
+    "starlit_sky": [
+        "570F490D010D010099C8FF",
+        "570F490D0364",
+    ],
+    "sleep": [
+        "570F490D01370300FF8C002E4E3E3E3E5E",
+        "570F490D0364",
+    ],
+    "movie": [
+        "570F490D013602001919704B0082",
+        "570F490D0364",
+    ],
+    "sunrise": [
+        "570F490D013F0200FFD700FF4500",
+        "570F490D03FA",
+        "570F490D03FA",
+    ],
+    "sunset": [
+        "570F490D01390300FF4500FFA500483D8B",
+        "570F490D0363",
+        "570F490D0363",
+    ],
+    "new_year": [
+        "570F490D013F0300FF0000FFD700228B22",
+        "570F490D0364",
+    ],
+    "cherry_blossom": [
+        "570F490D01400200FFB3C1FF69B4",
+        "570F490D0364",
+    ],
+}
 
 
 class SwitchbotLightStrip(SwitchbotSequenceBaseLight):
@@ -177,3 +302,44 @@ class SwitchbotStripLight3(SwitchbotEncryptedDevice, SwitchbotLightStrip):
     def color_modes(self) -> set[ColorMode]:
         """Return the supported color modes."""
         return {ColorMode.RGB, ColorMode.COLOR_TEMP}
+
+
+class SwitchbotRgbicLight(SwitchbotEncryptedDevice, SwitchbotLightStrip):
+    """Support for Switchbot RGBIC lights."""
+
+    _effect_dict = RGBIC_EFFECTS
+
+    def __init__(
+        self,
+        device: BLEDevice,
+        key_id: str,
+        encryption_key: str,
+        interface: int = 0,
+        model: SwitchbotModel = SwitchbotModel.RGBICWW_STRIP_LIGHT,
+        **kwargs: Any,
+    ) -> None:
+        super().__init__(device, key_id, encryption_key, model, interface, **kwargs)
+
+    @classmethod
+    async def verify_encryption_key(
+        cls,
+        device: BLEDevice,
+        key_id: str,
+        encryption_key: str,
+        model: SwitchbotModel = SwitchbotModel.RGBICWW_STRIP_LIGHT,
+        **kwargs: Any,
+    ) -> bool:
+        return await super().verify_encryption_key(
+            device, key_id, encryption_key, model, **kwargs
+        )
+
+    @property
+    def color_modes(self) -> set[ColorMode]:
+        """Return the supported color modes."""
+        return {ColorMode.RGB, ColorMode.COLOR_TEMP}
+
+    @property
+    def color_mode(self) -> ColorMode:
+        """Return the current color mode."""
+        device_mode = RGBICStripLightColorMode(self._get_adv_value("color_mode") or 10)
+        return _RGBICWW_STRIP_LIGHT_COLOR_MODE_MAP.get(device_mode, ColorMode.OFF)

+ 69 - 0
tests/__init__.py

@@ -11,3 +11,72 @@ class AdvTestCase:
     model: str | bytes
     modelFriendlyName: str
     modelName: SwitchbotModel
+
+
+STRIP_LIGHT_3_INFO = AdvTestCase(
+    b'\xc0N0\xe0U\x9a\x85\x9e"\xd0\x00\x00\x00\x00\x00\x00\x12\x91\x00',
+    b"\x00\x00\x00\x00\x10\xd0\xb1",
+    {
+        "sequence_number": 133,
+        "isOn": True,
+        "brightness": 30,
+        "delay": False,
+        "network_state": 2,
+        "color_mode": 2,
+        "cw": 4753,
+    },
+    b"\x00\x10\xd0\xb1",
+    "Strip Light 3",
+    SwitchbotModel.STRIP_LIGHT_3,
+)
+
+FLOOR_LAMP_INFO = AdvTestCase(
+    b'\xa0\x85\xe3e,\x06P\xaa"\xd4\x00\x00\x00\x00\x00\x00\r\x93\x00',
+    b"\x00\x00\x00\x00\x10\xd0\xb0",
+    {
+        "sequence_number": 80,
+        "isOn": True,
+        "brightness": 42,
+        "delay": False,
+        "network_state": 2,
+        "color_mode": 2,
+        "cw": 3475,
+    },
+    b"\x00\x10\xd0\xb0",
+    "Floor Lamp",
+    SwitchbotModel.FLOOR_LAMP,
+)
+
+RGBICWW_STRIP_LIGHT_INFO = AdvTestCase(
+    b'(7/L\x94\xb2\x0c\x9e"\x00\x11:\x00',
+    b"\x00\x00\x00\x00\x10\xd0\xb3",
+    {
+        "sequence_number": 12,
+        "isOn": True,
+        "brightness": 30,
+        "delay": False,
+        "network_state": 2,
+        "color_mode": 2,
+        "cw": 4410,
+    },
+    b"\x00\x10\xd0\xb3",
+    "Rgbic Strip Light",
+    SwitchbotModel.RGBICWW_STRIP_LIGHT,
+)
+
+RGBICWW_FLOOR_LAMP_INFO = AdvTestCase(
+    b'\xdc\x06u\xa6\xfb\xb2y\x9e"\x00\x11\xb8\x00',
+    b"\x00\x00\x00\x00\x10\xd0\xb4",
+    {
+        "sequence_number": 121,
+        "isOn": True,
+        "brightness": 30,
+        "delay": False,
+        "network_state": 2,
+        "color_mode": 2,
+        "cw": 4536,
+    },
+    b"\x00\x10\xd0\xb4",
+    "Rgbic Floor Lamp",
+    SwitchbotModel.RGBICWW_FLOOR_LAMP,
+)

+ 80 - 0
tests/test_adv_parser.py

@@ -3379,6 +3379,38 @@ def test_humidifer_with_empty_data() -> None:
             "Plug Mini (EU)",
             SwitchbotModel.PLUG_MINI_EU,
         ),
+        AdvTestCase(
+            b'\xdc\x06u\xa6\xfb\xb2y\x9e"\x00\x11\xb8\x00',
+            b"\x00\x00\x00\x00\x10\xd0\xb4",
+            {
+                "sequence_number": 121,
+                "isOn": True,
+                "brightness": 30,
+                "delay": False,
+                "network_state": 2,
+                "color_mode": 2,
+                "cw": 4536,
+            },
+            b"\x00\x10\xd0\xb4",
+            "RGBICWW Floor Lamp",
+            SwitchbotModel.RGBICWW_FLOOR_LAMP,
+        ),
+        AdvTestCase(
+            b'(7/L\x94\xb2\x0c\x9e"\x00\x11:\x00',
+            b"\x00\x00\x00\x00\x10\xd0\xb3",
+            {
+                "sequence_number": 12,
+                "isOn": True,
+                "brightness": 30,
+                "delay": False,
+                "network_state": 2,
+                "color_mode": 2,
+                "cw": 4410,
+            },
+            b"\x00\x10\xd0\xb3",
+            "RGBICWW Strip Light",
+            SwitchbotModel.RGBICWW_STRIP_LIGHT,
+        ),
     ],
 )
 def test_adv_active(test_case: AdvTestCase) -> None:
@@ -3546,6 +3578,38 @@ def test_adv_active(test_case: AdvTestCase) -> None:
             "Plug Mini (EU)",
             SwitchbotModel.PLUG_MINI_EU,
         ),
+        AdvTestCase(
+            b'\xdc\x06u\xa6\xfb\xb2y\x9e"\x00\x11\xb8\x00',
+            None,
+            {
+                "sequence_number": 121,
+                "isOn": True,
+                "brightness": 30,
+                "delay": False,
+                "network_state": 2,
+                "color_mode": 2,
+                "cw": 4536,
+            },
+            b"\x00\x10\xd0\xb4",
+            "RGBICWW Floor Lamp",
+            SwitchbotModel.RGBICWW_FLOOR_LAMP,
+        ),
+        AdvTestCase(
+            b'(7/L\x94\xb2\x0c\x9e"\x00\x11:\x00',
+            None,
+            {
+                "sequence_number": 12,
+                "isOn": True,
+                "brightness": 30,
+                "delay": False,
+                "network_state": 2,
+                "color_mode": 2,
+                "cw": 4410,
+            },
+            b"\x00\x10\xd0\xb3",
+            "RGBICWW Strip Light",
+            SwitchbotModel.RGBICWW_STRIP_LIGHT,
+        ),
     ],
 )
 def test_adv_passive(test_case: AdvTestCase) -> None:
@@ -3687,6 +3751,22 @@ def test_adv_passive(test_case: AdvTestCase) -> None:
             "Plug Mini (EU)",
             SwitchbotModel.PLUG_MINI_EU,
         ),
+        AdvTestCase(
+            None,
+            b"\x00\x00\x00\x00\x10\xd0\xb4",
+            {},
+            b"\x00\x10\xd0\xb4",
+            "RGBICWW Floor Lamp",
+            SwitchbotModel.RGBICWW_FLOOR_LAMP,
+        ),
+        AdvTestCase(
+            None,
+            b"\x00\x00\x00\x00\x10\xd0\xb3",
+            {},
+            b"\x00\x10\xd0\xb3",
+            "RGBICWW Strip Light",
+            SwitchbotModel.RGBICWW_STRIP_LIGHT,
+        ),
     ],
 )
 def test_adv_with_empty_data(test_case: AdvTestCase) -> None:

+ 104 - 57
tests/test_strip_light.py

@@ -9,24 +9,60 @@ from switchbot.devices import light_strip
 from switchbot.devices.base_light import SwitchbotBaseLight
 from switchbot.devices.device import SwitchbotEncryptedDevice, SwitchbotOperationError
 
-from .test_adv_parser import generate_ble_device
+from . import (
+    FLOOR_LAMP_INFO,
+    RGBICWW_FLOOR_LAMP_INFO,
+    RGBICWW_STRIP_LIGHT_INFO,
+    STRIP_LIGHT_3_INFO,
+)
+from .test_adv_parser import AdvTestCase, generate_ble_device
+
+
+@pytest.fixture(
+    params=[
+        (STRIP_LIGHT_3_INFO, light_strip.SwitchbotStripLight3),
+        (FLOOR_LAMP_INFO, light_strip.SwitchbotStripLight3),
+        (RGBICWW_STRIP_LIGHT_INFO, light_strip.SwitchbotRgbicLight),
+        (RGBICWW_FLOOR_LAMP_INFO, light_strip.SwitchbotRgbicLight),
+    ]
+)
+def device_case(request):
+    return request.param
+
+
+@pytest.fixture
+def expected_effects(device_case):
+    adv_info, dev_cls = device_case
+    EXPECTED = {
+        SwitchbotModel.STRIP_LIGHT_3: ("christmas", "halloween", "sunset"),
+        SwitchbotModel.FLOOR_LAMP: ("christmas", "halloween", "sunset"),
+        SwitchbotModel.RGBICWW_STRIP_LIGHT: ("romance", "energy", "heartbeat"),
+        SwitchbotModel.RGBICWW_FLOOR_LAMP: ("romance", "energy", "heartbeat"),
+    }
+    return EXPECTED[adv_info.modelName]
 
 
 def create_device_for_command_testing(
-    init_data: dict | None = None, model: SwitchbotModel = SwitchbotModel.STRIP_LIGHT_3
+    adv_info: AdvTestCase,
+    dev_cls: type[SwitchbotBaseLight],
+    init_data: dict | None = None,
 ):
     ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
-    device = light_strip.SwitchbotStripLight3(
-        ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=model
+    device = dev_cls(
+        ble_device, "ff", "ffffffffffffffffffffffffffffffff", model=adv_info.modelName
+    )
+    device.update_from_advertisement(
+        make_advertisement_data(ble_device, adv_info, init_data)
     )
-    device.update_from_advertisement(make_advertisement_data(ble_device, init_data))
     device._send_command = AsyncMock()
     device._check_command_result = MagicMock()
     device.update = AsyncMock()
     return device
 
 
-def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None):
+def make_advertisement_data(
+    ble_device: BLEDevice, adv_info: AdvTestCase, init_data: dict | None = None
+):
     """Set advertisement data with defaults."""
     if init_data is None:
         init_data = {}
@@ -34,21 +70,12 @@ def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None
     return SwitchBotAdvertisement(
         address="aa:bb:cc:dd:ee:ff",
         data={
-            "rawAdvData": b"\x00\x00\x00\x00\x10\xd0\xb1",
-            "data": {
-                "sequence_number": 133,
-                "isOn": True,
-                "brightness": 30,
-                "delay": False,
-                "network_state": 2,
-                "color_mode": 2,
-                "cw": 4753,
-            }
-            | init_data,
+            "rawAdvData": adv_info.service_data,
+            "data": adv_info.data | init_data,
             "isEncrypted": False,
-            "model": b"\x00\x10\xd0\xb1",
-            "modelFriendlyName": "Strip Light 3",
-            "modelName": SwitchbotModel.STRIP_LIGHT_3,
+            "model": adv_info.model,
+            "modelFriendlyName": adv_info.modelFriendlyName,
+            "modelName": adv_info.modelName,
         },
         device=ble_device,
         rssi=-80,
@@ -57,9 +84,10 @@ def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None
 
 
 @pytest.mark.asyncio
-async def test_default_info():
+async def test_default_info(device_case, expected_effects):
     """Test default initialization of the strip light."""
-    device = create_device_for_command_testing()
+    adv_info, dev_cls = device_case
+    device = create_device_for_command_testing(adv_info, dev_cls)
 
     assert device.rgb is None
 
@@ -74,7 +102,7 @@ async def test_default_info():
     }
     assert device.rgb == (30, 0, 0)
     assert device.color_temp == 3200
-    assert device.brightness == 30
+    assert device.brightness == adv_info.data["brightness"]
     assert device.min_temp == 2700
     assert device.max_temp == 6500
     # Check that effect list contains expected lowercase effect names
@@ -82,17 +110,18 @@ async def test_default_info():
     assert effect_list is not None
     assert all(effect.islower() for effect in effect_list)
     # Verify some known effects are present
-    assert "christmas" in effect_list
-    assert "halloween" in effect_list
-    assert "sunset" in effect_list
+    for effect in expected_effects:
+        assert effect in effect_list
 
 
 @pytest.mark.asyncio
 @pytest.mark.parametrize(
     ("basic_info", "version_info"), [(True, False), (False, True), (False, False)]
 )
-async def test_get_basic_info_returns_none(basic_info, version_info):
-    device = create_device_for_command_testing()
+async def test_get_basic_info_returns_none(basic_info, version_info, device_case):
+    """Test that get_basic_info returns None if no data is available."""
+    adv_info, dev_cls = device_case
+    device = create_device_for_command_testing(adv_info, dev_cls)
 
     async def mock_get_basic_info(arg):
         if arg == device._get_basic_info_command[1]:
@@ -133,9 +162,10 @@ async def test_get_basic_info_returns_none(basic_info, version_info):
         ),
     ],
 )
-async def test_strip_light_get_basic_info(info_data, result):
+async def test_strip_light_get_basic_info(info_data, result, device_case):
     """Test getting basic info from the strip light."""
-    device = create_device_for_command_testing()
+    adv_info, dev_cls = device_case
+    device = create_device_for_command_testing(adv_info, dev_cls)
 
     async def mock_get_basic_info(args: str) -> list[int] | None:
         if args == device._get_basic_info_command[1]:
@@ -158,9 +188,10 @@ async def test_strip_light_get_basic_info(info_data, result):
 
 
 @pytest.mark.asyncio
-async def test_set_color_temp():
+async def test_set_color_temp(device_case):
     """Test setting color temperature."""
-    device = create_device_for_command_testing()
+    adv_info, dev_cls = device_case
+    device = create_device_for_command_testing(adv_info, dev_cls)
 
     await device.set_color_temp(50, 3000)
 
@@ -170,9 +201,11 @@ async def test_set_color_temp():
 
 
 @pytest.mark.asyncio
-async def test_turn_on():
+async def test_turn_on(device_case):
     """Test turning on the strip light."""
-    device = create_device_for_command_testing({"isOn": True})
+    init_data = {"isOn": True}
+    adv_info, dev_cls = device_case
+    device = create_device_for_command_testing(adv_info, dev_cls, init_data)
 
     await device.turn_on()
 
@@ -182,9 +215,11 @@ async def test_turn_on():
 
 
 @pytest.mark.asyncio
-async def test_turn_off():
+async def test_turn_off(device_case):
     """Test turning off the strip light."""
-    device = create_device_for_command_testing({"isOn": False})
+    init_data = {"isOn": False}
+    adv_info, dev_cls = device_case
+    device = create_device_for_command_testing(adv_info, dev_cls, init_data)
 
     await device.turn_off()
 
@@ -194,9 +229,10 @@ async def test_turn_off():
 
 
 @pytest.mark.asyncio
-async def test_set_brightness():
+async def test_set_brightness(device_case):
     """Test setting brightness."""
-    device = create_device_for_command_testing()
+    adv_info, dev_cls = device_case
+    device = create_device_for_command_testing(adv_info, dev_cls)
 
     await device.set_brightness(75)
 
@@ -204,9 +240,10 @@ async def test_set_brightness():
 
 
 @pytest.mark.asyncio
-async def test_set_rgb():
+async def test_set_rgb(device_case):
     """Test setting RGB values."""
-    device = create_device_for_command_testing()
+    adv_info, dev_cls = device_case
+    device = create_device_for_command_testing(adv_info, dev_cls)
 
     await device.set_rgb(100, 255, 128, 64)
 
@@ -214,9 +251,10 @@ async def test_set_rgb():
 
 
 @pytest.mark.asyncio
-async def test_set_effect_with_invalid_effect():
+async def test_set_effect_with_invalid_effect(device_case):
     """Test setting an invalid effect."""
-    device = create_device_for_command_testing()
+    adv_info, dev_cls = device_case
+    device = create_device_for_command_testing(adv_info, dev_cls)
 
     with pytest.raises(
         SwitchbotOperationError, match="Effect invalid_effect not supported"
@@ -225,9 +263,10 @@ async def test_set_effect_with_invalid_effect():
 
 
 @pytest.mark.asyncio
-async def test_set_effect_with_valid_effect():
+async def test_set_effect_with_valid_effect(device_case):
     """Test setting a valid effect."""
-    device = create_device_for_command_testing()
+    adv_info, dev_cls = device_case
+    device = create_device_for_command_testing(adv_info, dev_cls)
     device._send_multiple_commands = AsyncMock()
 
     await device.set_effect("christmas")
@@ -237,10 +276,12 @@ async def test_set_effect_with_valid_effect():
     assert device.get_effect() == "christmas"
 
 
-def test_effect_list_contains_lowercase_names():
+@pytest.mark.asyncio
+async def test_effect_list_contains_lowercase_names(device_case, expected_effects):
     """Test that all effect names in get_effect_list are lowercase."""
-    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
-    device = light_strip.SwitchbotLightStrip(ble_device)
+    adv_info, dev_cls = device_case
+    device = create_device_for_command_testing(adv_info, dev_cls)
+
     effect_list = device.get_effect_list
 
     assert effect_list is not None, "Effect list should not be None"
@@ -248,15 +289,17 @@ def test_effect_list_contains_lowercase_names():
     for effect_name in effect_list:
         assert effect_name.islower(), f"Effect name '{effect_name}' is not lowercase"
     # Verify some known effects are present
-    assert "christmas" in effect_list
-    assert "halloween" in effect_list
-    assert "sunset" in effect_list
+    for expected_effect in expected_effects:
+        assert expected_effect in effect_list, (
+            f"Expected effect '{expected_effect}' not found"
+        )
 
 
 @pytest.mark.asyncio
-async def test_set_effect_normalizes_case():
+async def test_set_effect_normalizes_case(device_case):
     """Test that set_effect normalizes effect names to lowercase."""
-    device = create_device_for_command_testing()
+    adv_info, dev_cls = device_case
+    device = create_device_for_command_testing(adv_info, dev_cls)
     device._send_multiple_commands = AsyncMock()
 
     # Test various case combinations
@@ -271,24 +314,27 @@ async def test_set_effect_normalizes_case():
 
 @pytest.mark.asyncio
 @patch.object(SwitchbotEncryptedDevice, "verify_encryption_key", new_callable=AsyncMock)
-async def test_verify_encryption_key(mock_parent_verify):
+async def test_verify_encryption_key(mock_parent_verify, device_case):
     ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
     key_id = "ff"
     encryption_key = "ffffffffffffffffffffffffffffffff"
 
     mock_parent_verify.return_value = True
 
-    result = await light_strip.SwitchbotStripLight3.verify_encryption_key(
+    adv_info, dev_cls = device_case
+
+    result = await dev_cls.verify_encryption_key(
         device=ble_device,
         key_id=key_id,
         encryption_key=encryption_key,
+        model=adv_info.modelName,
     )
 
     mock_parent_verify.assert_awaited_once_with(
         ble_device,
         key_id,
         encryption_key,
-        SwitchbotModel.STRIP_LIGHT_3,
+        adv_info.modelName,
     )
 
     assert result is True
@@ -318,9 +364,10 @@ async def test_strip_light_supported_color_modes():
         (("command1", "command2"), [(b"\x01", True), (b"\x01", False)], True),
     ],
 )
-async def test_send_multiple_commands(commands, results, final_result):
+async def test_send_multiple_commands(commands, results, final_result, device_case):
     """Test sending multiple commands."""
-    device = create_device_for_command_testing()
+    adv_info, dev_cls = device_case
+    device = create_device_for_command_testing(adv_info, dev_cls)
 
     device._send_command = AsyncMock(side_effect=[r[0] for r in results])