1
0
Эх сурвалжийг харах

feat: add RGBICWW Ceiling Light support (#507)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Onero-testdev 1 өдөр өмнө
parent
commit
0a59efdd59

+ 2 - 0
switchbot/__init__.py

@@ -57,6 +57,7 @@ from .devices.light_strip import (
     SwitchbotPermanentOutdoorLight,
     SwitchbotRgbicLight,
     SwitchbotRgbicNeonLight,
+    SwitchbotRgbicwwCeilingLight,
     SwitchbotStripLight3,
 )
 from .devices.lock import SwitchbotLock
@@ -126,6 +127,7 @@ __all__ = [
     "SwitchbotRelaySwitch2PM",
     "SwitchbotRgbicLight",
     "SwitchbotRgbicNeonLight",
+    "SwitchbotRgbicwwCeilingLight",
     "SwitchbotRollerShade",
     "SwitchbotSmartThermostatRadiator",
     "SwitchbotStandingFan",

+ 13 - 0
switchbot/adv_parser.py

@@ -32,6 +32,7 @@ from .adv_parsers.light_strip import (
     process_candle_warmer_lamp,
     process_light,
     process_rgbic_light,
+    process_rgbicww_ceiling_light,
     process_wostrip,
 )
 from .adv_parsers.lock import (
@@ -677,6 +678,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_rgbic_light,
         "manufacturer_id": 2409,
     },
+    b"\x00\x11\xbb\x10": {
+        "modelName": SwitchbotModel.RGBICWW_CEILING_LIGHT,
+        "modelFriendlyName": "RGBICWW Ceiling Light",
+        "func": process_rgbicww_ceiling_light,
+        "manufacturer_id": 2409,
+    },
+    b"\x01\x11\xbb\x10": {
+        "modelName": SwitchbotModel.RGBICWW_CEILING_LIGHT,
+        "modelFriendlyName": "RGBICWW Ceiling Light",
+        "func": process_rgbicww_ceiling_light,
+        "manufacturer_id": 2409,
+    },
     b"\x00\x10\xd0\xb7": {
         "modelName": SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
         "modelFriendlyName": "Permanent Outdoor Light",

+ 13 - 0
switchbot/adv_parsers/light_strip.py

@@ -54,3 +54,16 @@ def process_rgbic_light(
 ) -> dict[str, bool | int]:
     """Support for RGBIC lights."""
     return process_light(data, mfr_data, cw_offset=10)
+
+
+def process_rgbicww_ceiling_light(
+    data: bytes | None, mfr_data: bytes | None
+) -> dict[str, bool | int]:
+    """Support for RGBICWW Ceiling Light (white + color sub-lights)."""
+    common_data = process_light(data, mfr_data, cw_offset=10)
+    if not common_data or len(mfr_data) < 14:
+        return {}
+    return common_data | {
+        "main_isOn": bool(mfr_data[13] & 0b10000000),
+        "main_brightness": mfr_data[13] & 0b01111111,
+    }

+ 1 - 0
switchbot/const/__init__.py

@@ -107,6 +107,7 @@ class SwitchbotModel(StrEnum):
     PLUG_MINI_EU = "Plug Mini (EU)"
     RGBICWW_STRIP_LIGHT = "RGBICWW Strip Light"
     RGBICWW_FLOOR_LAMP = "RGBICWW Floor Lamp"
+    RGBICWW_CEILING_LIGHT = "RGBICWW Ceiling Light"
     PERMANENT_OUTDOOR_LIGHT = "Permanent Outdoor Light"
     RGBIC_NEON_ROPE_LIGHT = "RGBIC Neon Rope Light"
     RGBIC_NEON_WIRE_ROPE_LIGHT = "RGBIC Neon Wire Rope Light"

+ 11 - 0
switchbot/const/light.py

@@ -43,4 +43,15 @@ class RGBICStripLightColorMode(Enum):
     UNKNOWN = 10
 
 
+class RGBICWWCeilingLightColorMode(Enum):
+    SEGMENTED = 1
+    COLOR = 2
+    SCENE = 3
+    MUSIC = 4
+    CONTROLLER = 5
+    WARMWHITE = 6
+    EFFECT = 7
+    UNKNOWN = 10
+
+
 DEFAULT_COLOR_TEMP = 4001

+ 1 - 0
switchbot/devices/device.py

@@ -102,6 +102,7 @@ API_MODEL_TO_ENUM: dict[str, SwitchbotModel] = {
     "W1102001": SwitchbotModel.STRIP_LIGHT_3,
     "W1102003": SwitchbotModel.RGBICWW_STRIP_LIGHT,
     "W1102004": SwitchbotModel.RGBICWW_FLOOR_LAMP,
+    "W1162000": SwitchbotModel.RGBICWW_CEILING_LIGHT,
     "W1104000": SwitchbotModel.PLUG_MINI_EU,
     "W1128000": SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
     "W1111000": SwitchbotModel.CLIMATE_PANEL,

+ 138 - 2
switchbot/devices/light_strip.py

@@ -3,9 +3,14 @@ from __future__ import annotations
 from typing import Any
 
 from ..const import SwitchbotModel
-from ..const.light import ColorMode, RGBICStripLightColorMode, StripLightColorMode
+from ..const.light import (
+    ColorMode,
+    RGBICStripLightColorMode,
+    RGBICWWCeilingLightColorMode,
+    StripLightColorMode,
+)
 from .base_light import SwitchbotSequenceBaseLight
-from .device import SwitchbotEncryptedDevice
+from .device import SwitchbotEncryptedDevice, update_after_operation
 
 # Private mapping from device-specific color modes to original ColorMode enum
 _STRIP_LIGHT_COLOR_MODE_MAP = {
@@ -26,6 +31,16 @@ _RGBICWW_STRIP_LIGHT_COLOR_MODE_MAP = {
     RGBICStripLightColorMode.EFFECT: ColorMode.EFFECT,
     RGBICStripLightColorMode.UNKNOWN: ColorMode.OFF,
 }
+_RGBICWW_CEILING_LIGHT_COLOR_MODE_MAP = {
+    RGBICWWCeilingLightColorMode.SEGMENTED: ColorMode.EFFECT,
+    RGBICWWCeilingLightColorMode.COLOR: ColorMode.RGB,
+    RGBICWWCeilingLightColorMode.SCENE: ColorMode.EFFECT,
+    RGBICWWCeilingLightColorMode.MUSIC: ColorMode.EFFECT,
+    RGBICWWCeilingLightColorMode.CONTROLLER: ColorMode.EFFECT,
+    RGBICWWCeilingLightColorMode.WARMWHITE: ColorMode.COLOR_TEMP,
+    RGBICWWCeilingLightColorMode.EFFECT: ColorMode.EFFECT,
+    RGBICWWCeilingLightColorMode.UNKNOWN: ColorMode.OFF,
+}
 LIGHT_STRIP_CONTROL_HEADER = "570F4901"
 COMMON_EFFECTS = {
     "christmas": [
@@ -352,3 +367,124 @@ class SwitchbotRgbicNeonLight(SwitchbotEncryptedDevice, SwitchbotLightStrip):
     def color_mode(self) -> ColorMode:
         """Return the current color mode."""
         return ColorMode.RGB
+
+
+class SwitchbotRgbicwwCeilingLight(SwitchbotEncryptedDevice, SwitchbotLightStrip):
+    """Support for Switchbot RGBICWW Ceiling Light (warm-white + color sub-lights)."""
+
+    _model = SwitchbotModel.RGBICWW_CEILING_LIGHT
+    _effect_dict = RGBIC_EFFECTS
+
+    # Color sub-light commands (sub_cmd 0x12 brightness+RGB, 0x14 brightness)
+    _set_brightness_command = f"{LIGHT_STRIP_CONTROL_HEADER}14{{}}"
+    _set_rgb_command = f"{LIGHT_STRIP_CONTROL_HEADER}12{{}}"
+
+    # Main (warm-white) sub-light commands (sub_cmd 0x09, 0x10, 0x11)
+    _set_main_brightness_command = f"{LIGHT_STRIP_CONTROL_HEADER}09{{}}"
+    _set_main_color_temp_command = f"{LIGHT_STRIP_CONTROL_HEADER}10{{}}"
+    _set_color_temp_command = f"{LIGHT_STRIP_CONTROL_HEADER}11{{}}"
+
+    # Sub-light power control: 0x49 0x01 <onoff> <selector>.
+    # onoff = 0x01 (on)/0x02 (off)/0x03 (toggle); here we always send 0x01 and
+    # let the selector drive the individual sub-lights.
+    # selector: bit7=1 (specify), bits[3:2]=color state, bits[1:0]=white state;
+    # state encoding 00=keep, 01=on, 02=off, 03=toggle.
+    _turn_on_main_command = f"{LIGHT_STRIP_CONTROL_HEADER}0181"
+    _turn_off_main_command = f"{LIGHT_STRIP_CONTROL_HEADER}0182"
+    _turn_on_color_command = f"{LIGHT_STRIP_CONTROL_HEADER}0184"
+    _turn_off_color_command = f"{LIGHT_STRIP_CONTROL_HEADER}0188"
+
+    @property
+    def color_modes(self) -> set[ColorMode]:
+        """Return the supported color modes (color sub-light)."""
+        return {ColorMode.RGB, ColorMode.COLOR_TEMP}
+
+    @property
+    def color_mode(self) -> ColorMode:
+        """Return the current color mode."""
+        device_mode = RGBICWWCeilingLightColorMode(
+            self._get_adv_value("color_mode") or 10
+        )
+        return _RGBICWW_CEILING_LIGHT_COLOR_MODE_MAP.get(device_mode, ColorMode.OFF)
+
+    @property
+    def is_main_on(self) -> bool | None:
+        """Return whether the main (warm-white) sub-light is on."""
+        return self._get_adv_value("main_isOn")
+
+    @property
+    def main_brightness(self) -> int:
+        """Return the main (warm-white) sub-light brightness 0-100."""
+        return self._get_adv_value("main_brightness") or 0
+
+    @update_after_operation
+    async def turn_on_main(self) -> bool:
+        """Turn the main (warm-white) sub-light on."""
+        result = await self._send_command(self._turn_on_main_command)
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def turn_off_main(self) -> bool:
+        """Turn the main (warm-white) sub-light off."""
+        result = await self._send_command(self._turn_off_main_command)
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def turn_on_color(self) -> bool:
+        """Turn the color sub-light on."""
+        result = await self._send_command(self._turn_on_color_command)
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def turn_off_color(self) -> bool:
+        """Turn the color sub-light off."""
+        result = await self._send_command(self._turn_off_color_command)
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def set_main_brightness(self, brightness: int) -> bool:
+        """Set the main (warm-white) sub-light brightness (sub_cmd 0x09)."""
+        self._validate_brightness(brightness)
+        hex_brightness = f"{brightness:02X}"
+        result = await self._send_command(
+            self._set_main_brightness_command.format(hex_brightness)
+        )
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def set_main_color_temp(self, color_temp: int) -> bool:
+        """Set the main (warm-white) sub-light color temperature (sub_cmd 0x10)."""
+        self._validate_color_temp(color_temp)
+        hex_data = f"{color_temp:04X}"
+        result = await self._send_command(
+            self._set_main_color_temp_command.format(hex_data)
+        )
+        return self._check_command_result(result, 0, {1})
+
+    async def get_basic_info(self) -> dict[str, Any] | None:
+        """
+        Read the RGB color (and color temp) over GATT.
+
+        Power, brightness and color mode are taken from the advertisement
+        (which tracks them reliably). The device's 0x4A01 status response does
+        NOT carry a usable color power state - byte 1 stays 0 even when the
+        color sub-light is on - so those fields are deliberately not returned
+        here, otherwise update() would clobber the correct advertised values.
+        """
+        if not (
+            res := await self._get_multi_commands_results(self._get_basic_info_command)
+        ):
+            return None
+
+        _version_info, _data = res
+        self._state["r"] = _data[3]
+        self._state["g"] = _data[4]
+        self._state["b"] = _data[5]
+        self._state["cw"] = int.from_bytes(_data[7:9], "big")
+
+        return {
+            "r": self._state["r"],
+            "g": self._state["g"],
+            "b": self._state["b"],
+            "firmware": _version_info[2] / 10.0,
+        }

+ 19 - 0
tests/__init__.py

@@ -96,6 +96,25 @@ RGBICWW_FLOOR_LAMP_INFO = AdvTestCase(
     SwitchbotModel.RGBICWW_FLOOR_LAMP,
 )
 
+RGBICWW_CEILING_LIGHT_INFO = AdvTestCase(
+    b'(7/L\x94\xb2\x0c\x9e"\x00\x11:\x00\xa0',
+    b"\x00\x00\x00\x00\x11\xbb\x10",
+    {
+        "sequence_number": 12,
+        "isOn": True,
+        "brightness": 30,
+        "delay": False,
+        "network_state": 2,
+        "color_mode": 2,
+        "cw": 4410,
+        "main_isOn": True,
+        "main_brightness": 32,
+    },
+    b"\x00\x11\xbb\x10",
+    "RGBICWW Ceiling Light",
+    SwitchbotModel.RGBICWW_CEILING_LIGHT,
+)
+
 RGBIC_NEON_LIGHT_INFO = AdvTestCase(
     b'\xdc\x06u\xa6\xfb\xb2y\x9e"\x00\x11\xb8\x00',
     b"\x00\x00\x00\x00\x10\xd0\xb6",

+ 44 - 0
tests/test_adv_parser.py

@@ -3708,6 +3708,24 @@ def test_humidifer_with_empty_data() -> None:
             "RGBIC Neon Wire Rope Light",
             SwitchbotModel.RGBIC_NEON_WIRE_ROPE_LIGHT,
         ),
+        AdvTestCase(
+            b'(7/L\x94\xb2\x0c\x9e"\x00\x11:\x00\xa0',
+            b"\x00\x00\x00\x00\x11\xbb\x10",
+            {
+                "sequence_number": 12,
+                "isOn": True,
+                "brightness": 30,
+                "delay": False,
+                "network_state": 2,
+                "color_mode": 2,
+                "cw": 4410,
+                "main_isOn": True,
+                "main_brightness": 32,
+            },
+            b"\x00\x11\xbb\x10",
+            "RGBICWW Ceiling Light",
+            SwitchbotModel.RGBICWW_CEILING_LIGHT,
+        ),
         AdvTestCase(
             b"\xb0\xe9\xfe\xe4\xbf\xd8\x0b\x01\x11f\x00\x16M\x15",
             b"\x00\x00M\x00\x10\xfb\xa8",
@@ -4142,6 +4160,24 @@ def test_adv_active(test_case: AdvTestCase) -> None:
             "RGBIC Neon Wire Rope Light",
             SwitchbotModel.RGBIC_NEON_WIRE_ROPE_LIGHT,
         ),
+        AdvTestCase(
+            b'(7/L\x94\xb2\x0c\x9e"\x00\x11:\x00\xa0',
+            None,
+            {
+                "sequence_number": 12,
+                "isOn": True,
+                "brightness": 30,
+                "delay": False,
+                "network_state": 2,
+                "color_mode": 2,
+                "cw": 4410,
+                "main_isOn": True,
+                "main_brightness": 32,
+            },
+            b"\x00\x11\xbb\x10",
+            "RGBICWW Ceiling Light",
+            SwitchbotModel.RGBICWW_CEILING_LIGHT,
+        ),
         AdvTestCase(
             b"\xb0\xe9\xfe\xe4\xbf\xd8\x0b\x01\x11f\x00\x16M\x15",
             None,
@@ -4441,6 +4477,14 @@ def test_adv_passive(test_case: AdvTestCase) -> None:
             "RGBICWW Floor Lamp",
             SwitchbotModel.RGBICWW_FLOOR_LAMP,
         ),
+        AdvTestCase(
+            None,
+            b"\x00\x00\x00\x00\x11\xbb\x10",
+            {},
+            b"\x00\x11\xbb\x10",
+            "RGBICWW Ceiling Light",
+            SwitchbotModel.RGBICWW_CEILING_LIGHT,
+        ),
         AdvTestCase(
             None,
             b"\x00\x00\x00\x00\x10\xd0\xb3",

+ 132 - 0
tests/test_strip_light.py

@@ -14,6 +14,7 @@ from . import (
     FLOOR_LAMP_INFO,
     PERMANENT_OUTDOOR_LIGHT_INFO,
     RGBIC_NEON_LIGHT_INFO,
+    RGBICWW_CEILING_LIGHT_INFO,
     RGBICWW_FLOOR_LAMP_INFO,
     RGBICWW_STRIP_LIGHT_INFO,
     STRIP_LIGHT_3_INFO,
@@ -28,6 +29,7 @@ ALL_LIGHT_CASES = [
     (RGBICWW_FLOOR_LAMP_INFO, light_strip.SwitchbotRgbicLight),
     (PERMANENT_OUTDOOR_LIGHT_INFO, light_strip.SwitchbotPermanentOutdoorLight),
     (RGBIC_NEON_LIGHT_INFO, light_strip.SwitchbotRgbicNeonLight),
+    (RGBICWW_CEILING_LIGHT_INFO, light_strip.SwitchbotRgbicwwCeilingLight),
 ]
 
 # RGB + color-temp devices. Excludes brightness-only lights (CWL) and
@@ -53,6 +55,7 @@ def expected_effects(device_case):
         SwitchbotModel.FLOOR_LAMP: ("christmas", "halloween", "sunset"),
         SwitchbotModel.RGBICWW_STRIP_LIGHT: ("romance", "energy", "heartbeat"),
         SwitchbotModel.RGBICWW_FLOOR_LAMP: ("romance", "energy", "heartbeat"),
+        SwitchbotModel.RGBICWW_CEILING_LIGHT: ("romance", "energy", "heartbeat"),
         SwitchbotModel.PERMANENT_OUTDOOR_LIGHT: ("romance", "energy", "heartbeat"),
     }
     return EXPECTED[adv_info.modelName]
@@ -192,6 +195,14 @@ async def test_get_basic_info_returns_none(
 
 
 @pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "device_case",
+    [
+        case
+        for case in RGB_LIGHT_CASES
+        if case[1] is not light_strip.SwitchbotRgbicwwCeilingLight
+    ],
+)
 @pytest.mark.parametrize(
     ("info_data", "result"),
     [
@@ -375,6 +386,10 @@ async def test_set_effect_normalizes_case(device_case):
         ),
         (light_strip.SwitchbotRgbicNeonLight, SwitchbotModel.RGBIC_NEON_ROPE_LIGHT),
         (light_strip.SwitchbotCandleWarmerLamp, SwitchbotModel.CANDLE_WARMER_LAMP),
+        (
+            light_strip.SwitchbotRgbicwwCeilingLight,
+            SwitchbotModel.RGBICWW_CEILING_LIGHT,
+        ),
     ],
 )
 def test_default_model_classvar(dev_cls, expected_model):
@@ -473,3 +488,120 @@ async def test_permanent_outdoor_light_color_mode(
         init_data={"color_mode": color_mode_value},
     )
     assert device.color_mode == expected_color_mode
+
+
+@pytest.mark.asyncio
+async def test_rgbicww_ceiling_light_main_sub_light_state() -> None:
+    """Test the main (warm-white) sub-light state surface."""
+    device = create_device_for_command_testing(
+        RGBICWW_CEILING_LIGHT_INFO, light_strip.SwitchbotRgbicwwCeilingLight
+    )
+    assert device.is_main_on is True
+    assert device.main_brightness == 32
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("method", "expected_command_attr"),
+    [
+        ("turn_on_main", "_turn_on_main_command"),
+        ("turn_off_main", "_turn_off_main_command"),
+        ("turn_on_color", "_turn_on_color_command"),
+        ("turn_off_color", "_turn_off_color_command"),
+    ],
+)
+async def test_rgbicww_ceiling_light_sub_light_power(
+    method: str, expected_command_attr: str
+) -> None:
+    """Test the per-sub-light power commands."""
+    device = create_device_for_command_testing(
+        RGBICWW_CEILING_LIGHT_INFO, light_strip.SwitchbotRgbicwwCeilingLight
+    )
+    await getattr(device, method)()
+    device._send_command.assert_called_with(getattr(device, expected_command_attr))
+
+
+@pytest.mark.asyncio
+async def test_rgbicww_ceiling_light_set_main_brightness() -> None:
+    """Test setting main (warm-white) sub-light brightness."""
+    device = create_device_for_command_testing(
+        RGBICWW_CEILING_LIGHT_INFO, light_strip.SwitchbotRgbicwwCeilingLight
+    )
+    await device.set_main_brightness(75)
+    device._send_command.assert_called_with(
+        device._set_main_brightness_command.format("4B")
+    )
+
+
+@pytest.mark.asyncio
+async def test_rgbicww_ceiling_light_set_main_color_temp() -> None:
+    """Test setting main (warm-white) sub-light color temperature."""
+    device = create_device_for_command_testing(
+        RGBICWW_CEILING_LIGHT_INFO, light_strip.SwitchbotRgbicwwCeilingLight
+    )
+    await device.set_main_color_temp(3000)
+    device._send_command.assert_called_with(
+        device._set_main_color_temp_command.format("0BB8")
+    )
+
+
+@pytest.mark.asyncio
+async def test_rgbicww_ceiling_light_color_modes() -> None:
+    """Test the supported color modes set."""
+    device = create_device_for_command_testing(
+        RGBICWW_CEILING_LIGHT_INFO, light_strip.SwitchbotRgbicwwCeilingLight
+    )
+    assert device.color_modes == {ColorMode.RGB, ColorMode.COLOR_TEMP}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("color_mode_value", "expected_color_mode"),
+    [
+        (1, ColorMode.EFFECT),  # SEGMENTED
+        (2, ColorMode.RGB),  # COLOR
+        (3, ColorMode.EFFECT),  # SCENE
+        (4, ColorMode.EFFECT),  # MUSIC
+        (5, ColorMode.EFFECT),  # CONTROLLER
+        (6, ColorMode.COLOR_TEMP),  # WARMWHITE
+        (7, ColorMode.EFFECT),  # EFFECT
+        (10, ColorMode.OFF),  # UNKNOWN
+    ],
+)
+async def test_rgbicww_ceiling_light_color_mode(
+    color_mode_value, expected_color_mode
+) -> None:
+    """Test color_mode mapping for the ceiling light."""
+    device = create_device_for_command_testing(
+        RGBICWW_CEILING_LIGHT_INFO,
+        light_strip.SwitchbotRgbicwwCeilingLight,
+        init_data={"color_mode": color_mode_value},
+    )
+    assert device.color_mode == expected_color_mode
+
+
+@pytest.mark.asyncio
+async def test_rgbicww_ceiling_light_get_basic_info_with_main_state() -> None:
+    """
+    Test that the ceiling light's get_basic_info reads RGB over GATT.
+
+    Power/brightness/mode/color-temp come from the advertisement, not GATT,
+    so they are not part of the get_basic_info result.
+    """
+    device = create_device_for_command_testing(
+        RGBICWW_CEILING_LIGHT_INFO, light_strip.SwitchbotRgbicwwCeilingLight
+    )
+    device._send_command = AsyncMock(
+        side_effect=[b"\x01\x01\n", b"\x01\x80NK\xff:\xa0\x19d\xff\x02"]
+    )
+    device._check_command_result = MagicMock(side_effect=[True, True])
+
+    info = await device.get_basic_info()
+
+    assert info["r"] == 75
+    assert info["g"] == 255
+    assert info["b"] == 58
+    assert info["firmware"] == 1.0
+    # Power state must not be sourced from GATT (advertisement is authoritative).
+    assert "isOn" not in info
+    assert "main_isOn" not in info