Browse Source

Add support for Candle Warmer Lamp (#454)

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>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
Samuel Xiao 1 day ago
parent
commit
7e8c18ade3

+ 2 - 0
switchbot/__init__.py

@@ -48,6 +48,7 @@ from .devices.fan import SwitchbotFan
 from .devices.humidifier import SwitchbotHumidifier
 from .devices.keypad_vision import SwitchbotKeypadVision
 from .devices.light_strip import (
+    SwitchbotCandleWarmerLamp,
     SwitchbotLightStrip,
     SwitchbotRgbicLight,
     SwitchbotStripLight3,
@@ -93,6 +94,7 @@ __all__ = [
     "SwitchbotBaseLight",
     "SwitchbotBlindTilt",
     "SwitchbotBulb",
+    "SwitchbotCandleWarmerLamp",
     "SwitchbotCeilingLight",
     "SwitchbotCurtain",
     "SwitchbotDevice",

+ 18 - 1
switchbot/adv_parser.py

@@ -28,7 +28,12 @@ from .adv_parsers.humidifier import process_evaporative_humidifier, process_wohu
 from .adv_parsers.keypad import process_wokeypad
 from .adv_parsers.keypad_vision import process_keypad_vision, process_keypad_vision_pro
 from .adv_parsers.leak import process_leak
-from .adv_parsers.light_strip import process_light, process_rgbic_light, process_wostrip
+from .adv_parsers.light_strip import (
+    process_candle_warmer_lamp,
+    process_light,
+    process_rgbic_light,
+    process_wostrip,
+)
 from .adv_parsers.lock import (
     process_lock2,
     process_locklite,
@@ -612,6 +617,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_light,
         "manufacturer_id": 2409,
     },
+    b"\x00\x11\x22\xb8": {
+        "modelName": SwitchbotModel.CANDLE_WARMER_LAMP,
+        "modelFriendlyName": "Candle Warmer Lamp",
+        "func": process_candle_warmer_lamp,
+        "manufacturer_id": 2409,
+    },
+    b"\x01\x11\x22\xb8": {
+        "modelName": SwitchbotModel.CANDLE_WARMER_LAMP,
+        "modelFriendlyName": "Candle Warmer Lamp",
+        "func": process_candle_warmer_lamp,
+        "manufacturer_id": 2409,
+    },
     b"\x00\x10\xd0\xb1": {
         "modelName": SwitchbotModel.STRIP_LIGHT_3,
         "modelFriendlyName": "Strip Light 3",

+ 15 - 0
switchbot/adv_parsers/light_strip.py

@@ -21,6 +21,21 @@ def process_wostrip(
     }
 
 
+def process_candle_warmer_lamp(
+    data: bytes | None, mfr_data: bytes | None
+) -> dict[str, bool | int]:
+    """Process Candle Warmer Lamp services data."""
+    if mfr_data is None:
+        return {}
+    return {
+        "sequence_number": mfr_data[6],
+        "isOn": bool(mfr_data[7] & 0b10000000),
+        "brightness": mfr_data[7] & 0b01111111,
+        "delay": bool(mfr_data[8] & 0b10000000),
+        "network_state": (mfr_data[8] & 0b01110000) >> 4,
+    }
+
+
 def process_light(
     data: bytes | None, mfr_data: bytes | None, cw_offset: int = 16
 ) -> dict[str, bool | int]:

+ 1 - 0
switchbot/const/__init__.py

@@ -96,6 +96,7 @@ class SwitchbotModel(StrEnum):
     RELAY_SWITCH_2PM = "Relay Switch 2PM"
     STRIP_LIGHT_3 = "Strip Light 3"
     FLOOR_LAMP = "Floor Lamp"
+    CANDLE_WARMER_LAMP = "Candle Warmer Lamp"
     PLUG_MINI_EU = "Plug Mini (EU)"
     RGBICWW_STRIP_LIGHT = "RGBICWW Strip Light"
     RGBICWW_FLOOR_LAMP = "RGBICWW Floor Lamp"

+ 1 - 0
switchbot/const/light.py

@@ -6,6 +6,7 @@ class ColorMode(Enum):
     COLOR_TEMP = 1
     RGB = 2
     EFFECT = 3
+    BRIGHTNESS = 4
 
 
 class StripLightColorMode(Enum):

+ 32 - 0
switchbot/devices/light_strip.py

@@ -280,6 +280,38 @@ class SwitchbotStripLight3(SwitchbotEncryptedDevice, SwitchbotLightStrip):
         return {ColorMode.RGB, ColorMode.COLOR_TEMP}
 
 
+class SwitchbotCandleWarmerLamp(SwitchbotEncryptedDevice, SwitchbotLightStrip):
+    """Support for Switchbot Candle Warmer Lamp."""
+
+    _model = SwitchbotModel.CANDLE_WARMER_LAMP
+    _effect_dict = {}
+    _set_rgb_command = ""
+    _set_color_temp_command = ""
+
+    @property
+    def color_modes(self) -> set[ColorMode]:
+        """Return the supported color modes."""
+        return {ColorMode.BRIGHTNESS}
+
+    @property
+    def color_mode(self) -> ColorMode:
+        """Return the current color mode."""
+        return ColorMode.BRIGHTNESS
+
+    async def get_basic_info(self) -> dict[str, Any] | None:
+        """Get device basic settings."""
+        if not (
+            res := await self._get_multi_commands_results(self._get_basic_info_command)
+        ):
+            return None
+        _version_info, _data = res
+        return {
+            "isOn": bool(_data[1] & 0b10000000),
+            "brightness": _data[2] & 0b01111111,
+            "firmware": _version_info[2] / 10.0,
+        }
+
+
 class SwitchbotRgbicLight(SwitchbotEncryptedDevice, SwitchbotLightStrip):
     """Support for Switchbot RGBIC lights."""
 

+ 15 - 0
tests/__init__.py

@@ -47,6 +47,21 @@ FLOOR_LAMP_INFO = AdvTestCase(
     SwitchbotModel.FLOOR_LAMP,
 )
 
+CANDLE_WARMER_LAMP_INFO = AdvTestCase(
+    b"\x90\xe5\xb1h\xda\xaa\n\xb0 \x00",
+    b"\x00\x00\x00\x00\x11\x22\xb8",
+    {
+        "brightness": 48,
+        "delay": False,
+        "isOn": True,
+        "network_state": 2,
+        "sequence_number": 10,
+    },
+    b"\x00\x11\x22\xb8",
+    "Candle Warmer Lamp",
+    SwitchbotModel.CANDLE_WARMER_LAMP,
+)
+
 RGBICWW_STRIP_LIGHT_INFO = AdvTestCase(
     b'(7/L\x94\xb2\x0c\x9e"\x00\x11:\x00',
     b"\x00\x00\x00\x00\x10\xd0\xb3",

+ 36 - 0
tests/test_adv_parser.py

@@ -3486,6 +3486,20 @@ def test_humidifer_with_empty_data() -> None:
             "Floor Lamp",
             SwitchbotModel.FLOOR_LAMP,
         ),
+        AdvTestCase(
+            b"\x90\xe5\xb1h\xda\xaa\n\xb0 \x00",
+            b"\x00\x00\x00\x00\x11\x22\xb8",
+            {
+                "brightness": 48,
+                "delay": False,
+                "isOn": True,
+                "network_state": 2,
+                "sequence_number": 10,
+            },
+            b"\x00\x11\x22\xb8",
+            "Candle Warmer Lamp",
+            SwitchbotModel.CANDLE_WARMER_LAMP,
+        ),
         AdvTestCase(
             b"\xef\xfe\xfb\x9d\x10\xfe\n\x01\x18\xf3$",
             b"q\x00",
@@ -3935,6 +3949,20 @@ def test_adv_active(test_case: AdvTestCase) -> None:
             "RGBICWW Floor Lamp",
             SwitchbotModel.RGBICWW_FLOOR_LAMP,
         ),
+        AdvTestCase(
+            b"\x90\xe5\xb1h\xda\xaa\n\xb0 \x00",
+            None,
+            {
+                "brightness": 48,
+                "delay": False,
+                "isOn": True,
+                "network_state": 2,
+                "sequence_number": 10,
+            },
+            b"\x00\x11\x22\xb8",
+            "Candle Warmer Lamp",
+            SwitchbotModel.CANDLE_WARMER_LAMP,
+        ),
         AdvTestCase(
             b'(7/L\x94\xb2\x0c\x9e"\x00\x11:\x00',
             None,
@@ -4197,6 +4225,14 @@ def test_adv_passive(test_case: AdvTestCase) -> None:
             "Floor Lamp",
             SwitchbotModel.FLOOR_LAMP,
         ),
+        AdvTestCase(
+            None,
+            b"\x00\x00\x00\x00\x11\x22\xb8",
+            {},
+            b"\x00\x11\x22\xb8",
+            "Candle Warmer Lamp",
+            SwitchbotModel.CANDLE_WARMER_LAMP,
+        ),
         AdvTestCase(
             None,
             b"q\x00",

+ 58 - 10
tests/test_strip_light.py

@@ -10,6 +10,7 @@ from switchbot.devices.base_light import SwitchbotBaseLight
 from switchbot.devices.device import SwitchbotOperationError
 
 from . import (
+    CANDLE_WARMER_LAMP_INFO,
     FLOOR_LAMP_INFO,
     RGBICWW_FLOOR_LAMP_INFO,
     RGBICWW_STRIP_LIGHT_INFO,
@@ -17,15 +18,23 @@ from . import (
 )
 from .test_adv_parser import AdvTestCase, generate_ble_device
 
+ALL_LIGHT_CASES = [
+    (STRIP_LIGHT_3_INFO, light_strip.SwitchbotStripLight3),
+    (FLOOR_LAMP_INFO, light_strip.SwitchbotStripLight3),
+    (CANDLE_WARMER_LAMP_INFO, light_strip.SwitchbotCandleWarmerLamp),
+    (RGBICWW_STRIP_LIGHT_INFO, light_strip.SwitchbotRgbicLight),
+    (RGBICWW_FLOOR_LAMP_INFO, light_strip.SwitchbotRgbicLight),
+]
 
-@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),
-    ]
-)
+# RGB/effect-capable devices only; excludes brightness-only lights like CWL.
+RGB_LIGHT_CASES = [
+    case
+    for case in ALL_LIGHT_CASES
+    if case[1] is not light_strip.SwitchbotCandleWarmerLamp
+]
+
+
+@pytest.fixture(params=RGB_LIGHT_CASES)
 def device_case(request):
     return request.param
 
@@ -114,13 +123,51 @@ async def test_default_info(device_case, expected_effects):
         assert effect in effect_list
 
 
+@pytest.mark.asyncio
+async def test_candle_warmer_lamp_info() -> None:
+    """Test default initialization of the candle warmer lamp."""
+    adv_info, dev_cls = CANDLE_WARMER_LAMP_INFO, light_strip.SwitchbotCandleWarmerLamp
+    device = create_device_for_command_testing(adv_info, dev_cls)
+    assert device.rgb is None
+    assert device.is_on() is True
+    assert device.on is True
+    assert device.color_mode == ColorMode.BRIGHTNESS
+    assert device.color_modes == {
+        ColorMode.BRIGHTNESS,
+    }
+    assert device.brightness == adv_info.data["brightness"]
+    assert device.get_effect_list is None
+
+
+@pytest.mark.asyncio
+async def test_candle_warmer_lamp_unsupported_operations() -> None:
+    """Test that RGB/color-temp/effect operations are not supported on CWL."""
+    device = create_device_for_command_testing(
+        CANDLE_WARMER_LAMP_INFO, light_strip.SwitchbotCandleWarmerLamp
+    )
+    with pytest.raises(
+        SwitchbotOperationError,
+        match="does not support this functionality",
+    ):
+        await device.set_rgb(100, 255, 128, 64)
+    with pytest.raises(
+        SwitchbotOperationError,
+        match="does not support this functionality",
+    ):
+        await device.set_color_temp(100, 4000)
+    with pytest.raises(SwitchbotOperationError, match="not supported"):
+        await device.set_effect("sunset")
+
+
 @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_case):
+@pytest.mark.parametrize(("adv_info", "dev_cls"), ALL_LIGHT_CASES)
+async def test_get_basic_info_returns_none(
+    basic_info, version_info, adv_info, dev_cls
+) -> None:
     """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)
 
     device._send_command = AsyncMock(side_effect=[version_info, basic_info])
@@ -309,6 +356,7 @@ async def test_set_effect_normalizes_case(device_case):
     [
         (light_strip.SwitchbotStripLight3, SwitchbotModel.STRIP_LIGHT_3),
         (light_strip.SwitchbotRgbicLight, SwitchbotModel.RGBICWW_STRIP_LIGHT),
+        (light_strip.SwitchbotCandleWarmerLamp, SwitchbotModel.CANDLE_WARMER_LAMP),
     ],
 )
 def test_default_model_classvar(dev_cls, expected_model):