Browse Source

Add Permanent Outdoor Light support (#464)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: testdev <testdev@onero.com>
7eaves 8 hours ago
parent
commit
b29eaba326

+ 2 - 0
switchbot/__init__.py

@@ -54,6 +54,7 @@ from .devices.keypad_vision import SwitchbotKeypadVision
 from .devices.light_strip import (
     SwitchbotCandleWarmerLamp,
     SwitchbotLightStrip,
+    SwitchbotPermanentOutdoorLight,
     SwitchbotRgbicLight,
     SwitchbotRgbicNeonLight,
     SwitchbotStripLight3,
@@ -118,6 +119,7 @@ __all__ = [
     "SwitchbotModel",
     "SwitchbotModel",
     "SwitchbotOperationError",
+    "SwitchbotPermanentOutdoorLight",
     "SwitchbotPlugMini",
     "SwitchbotPlugMini",
     "SwitchbotRelaySwitch",

+ 12 - 0
switchbot/adv_parser.py

@@ -677,6 +677,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_rgbic_light,
         "manufacturer_id": 2409,
     },
+    b"\x00\x10\xd0\xb7": {
+        "modelName": SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
+        "modelFriendlyName": "Permanent Outdoor Light",
+        "func": process_rgbic_light,
+        "manufacturer_id": 2409,
+    },
+    b"\x01\x10\xd0\xb7": {
+        "modelName": SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
+        "modelFriendlyName": "Permanent Outdoor Light",
+        "func": process_rgbic_light,
+        "manufacturer_id": 2409,
+    },
     b"\x00\x10\xd0\xb5": {
         "modelName": SwitchbotModel.RGBIC_NEON_WIRE_ROPE_LIGHT,
         "modelFriendlyName": "RGBIC Neon Wire Rope Light",

+ 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"
+    PERMANENT_OUTDOOR_LIGHT = "Permanent Outdoor Light"
     RGBIC_NEON_ROPE_LIGHT = "RGBIC Neon Rope Light"
     RGBIC_NEON_WIRE_ROPE_LIGHT = "RGBIC Neon Wire Rope Light"
     K11_VACUUM = "K11+ Vacuum"

+ 7 - 0
switchbot/devices/light_strip.py

@@ -23,6 +23,7 @@ _RGBICWW_STRIP_LIGHT_COLOR_MODE_MAP = {
     RGBICStripLightColorMode.MUSIC: ColorMode.EFFECT,
     RGBICStripLightColorMode.CONTROLLER: ColorMode.EFFECT,
     RGBICStripLightColorMode.COLOR_TEMP: ColorMode.COLOR_TEMP,
+    RGBICStripLightColorMode.EFFECT: ColorMode.EFFECT,
     RGBICStripLightColorMode.UNKNOWN: ColorMode.OFF,
 }
 LIGHT_STRIP_CONTROL_HEADER = "570F4901"
@@ -330,6 +331,12 @@ class SwitchbotRgbicLight(SwitchbotEncryptedDevice, SwitchbotLightStrip):
         return _RGBICWW_STRIP_LIGHT_COLOR_MODE_MAP.get(device_mode, ColorMode.OFF)
 
 
+class SwitchbotPermanentOutdoorLight(SwitchbotRgbicLight):
+    """Support for Switchbot Permanent Outdoor Light."""
+
+    _model = SwitchbotModel.PERMANENT_OUTDOOR_LIGHT
+
+
 class SwitchbotRgbicNeonLight(SwitchbotEncryptedDevice, SwitchbotLightStrip):
     """Support for Switchbot RGBIC Neon lights."""
 

+ 18 - 0
tests/__init__.py

@@ -113,6 +113,24 @@ RGBIC_NEON_LIGHT_INFO = AdvTestCase(
 )
 
 
+PERMANENT_OUTDOOR_LIGHT_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\xb7",
+    {
+        "sequence_number": 133,
+        "isOn": True,
+        "brightness": 30,
+        "delay": False,
+        "network_state": 2,
+        "color_mode": 2,
+        "cw": 0,
+    },
+    b"\x00\x10\xd0\xb7",
+    "Permanent Outdoor Light",
+    SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
+)
+
+
 SMART_THERMOSTAT_RADIATOR_INFO = AdvTestCase(
     b"\xb0\xe9\xfe\xa2T|6\xe4\x00\x9c\xa3A\x00",
     b"\x00 d\x00\x116@",

+ 40 - 0
tests/test_adv_parser.py

@@ -3625,6 +3625,22 @@ def test_humidifer_with_empty_data() -> None:
             "RGBICWW Strip Light",
             SwitchbotModel.RGBICWW_STRIP_LIGHT,
         ),
+        AdvTestCase(
+            b'\xc0N0\xe0U\x9a\x85\x9e"\xd0\x00\x00\x00\x00\x00\x00\x12\x91\x00',
+            b"\x00\x00\x00\x00\x10\xd0\xb7",
+            {
+                "sequence_number": 133,
+                "isOn": True,
+                "brightness": 30,
+                "delay": False,
+                "network_state": 2,
+                "color_mode": 2,
+                "cw": 0,
+            },
+            b"\x00\x10\xd0\xb7",
+            "Permanent Outdoor Light",
+            SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
+        ),
         AdvTestCase(
             b"@L\xca!pz/\x8b'\x00\x11:\x00",
             b"\x00\x00\x00\x00\x10\xd0\xb6",
@@ -4043,6 +4059,22 @@ def test_adv_active(test_case: AdvTestCase) -> None:
             "RGBICWW Strip Light",
             SwitchbotModel.RGBICWW_STRIP_LIGHT,
         ),
+        AdvTestCase(
+            b'\xc0N0\xe0U\x9a\x85\x9e"\xd0\x00\x00\x00\x00\x00\x00\x12\x91\x00',
+            None,
+            {
+                "sequence_number": 133,
+                "isOn": True,
+                "brightness": 30,
+                "delay": False,
+                "network_state": 2,
+                "color_mode": 2,
+                "cw": 0,
+            },
+            b"\x00\x10\xd0\xb7",
+            "Permanent Outdoor Light",
+            SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
+        ),
         AdvTestCase(
             b"@L\xca!pz/\x8b'\x00\x11:\x00",
             None,
@@ -4380,6 +4412,14 @@ def test_adv_passive(test_case: AdvTestCase) -> None:
             "RGBICWW Strip Light",
             SwitchbotModel.RGBICWW_STRIP_LIGHT,
         ),
+        AdvTestCase(
+            None,
+            b"\x00\x00\x00\x00\x10\xd0\xb7",
+            {},
+            b"\x00\x10\xd0\xb7",
+            "Permanent Outdoor Light",
+            SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
+        ),
         AdvTestCase(
             None,
             b"\x00\x00M\x00\x10\xfb\xa8",

+ 33 - 0
tests/test_strip_light.py

@@ -12,6 +12,7 @@ from switchbot.devices.device import SwitchbotOperationError
 from . import (
     CANDLE_WARMER_LAMP_INFO,
     FLOOR_LAMP_INFO,
+    PERMANENT_OUTDOOR_LIGHT_INFO,
     RGBIC_NEON_LIGHT_INFO,
     RGBICWW_FLOOR_LAMP_INFO,
     RGBICWW_STRIP_LIGHT_INFO,
@@ -25,6 +26,7 @@ ALL_LIGHT_CASES = [
     (CANDLE_WARMER_LAMP_INFO, light_strip.SwitchbotCandleWarmerLamp),
     (RGBICWW_STRIP_LIGHT_INFO, light_strip.SwitchbotRgbicLight),
     (RGBICWW_FLOOR_LAMP_INFO, light_strip.SwitchbotRgbicLight),
+    (PERMANENT_OUTDOOR_LIGHT_INFO, light_strip.SwitchbotPermanentOutdoorLight),
     (RGBIC_NEON_LIGHT_INFO, light_strip.SwitchbotRgbicNeonLight),
 ]
 
@@ -51,6 +53,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.PERMANENT_OUTDOOR_LIGHT: ("romance", "energy", "heartbeat"),
     }
     return EXPECTED[adv_info.modelName]
 
@@ -366,6 +369,10 @@ 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.SwitchbotPermanentOutdoorLight,
+            SwitchbotModel.PERMANENT_OUTDOOR_LIGHT,
+        ),
         (light_strip.SwitchbotRgbicNeonLight, SwitchbotModel.RGBIC_NEON_ROPE_LIGHT),
         (light_strip.SwitchbotCandleWarmerLamp, SwitchbotModel.CANDLE_WARMER_LAMP),
     ],
@@ -440,3 +447,29 @@ async def test_exception_with_wrong_model():
         match="Current device aa:bb:cc:dd:ee:ff does not support this functionality",
     ):
         await device.set_rgb(100, 255, 128, 64)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("color_mode_value", "expected_color_mode"),
+    [
+        (1, ColorMode.EFFECT),  # SEGMENTED
+        (2, ColorMode.RGB),
+        (3, ColorMode.EFFECT),  # SCENE
+        (4, ColorMode.EFFECT),  # MUSIC
+        (5, ColorMode.EFFECT),  # CONTROLLER
+        (6, ColorMode.COLOR_TEMP),
+        (7, ColorMode.EFFECT),  # EFFECT (RGBIC-specific)
+        (10, ColorMode.OFF),  # UNKNOWN
+    ],
+)
+async def test_permanent_outdoor_light_color_mode(
+    color_mode_value, expected_color_mode
+):
+    """Test that POL correctly handles all RGBICStripLightColorMode values including EFFECT (7)."""
+    device = create_device_for_command_testing(
+        PERMANENT_OUTDOOR_LIGHT_INFO,
+        light_strip.SwitchbotPermanentOutdoorLight,
+        init_data={"color_mode": color_mode_value},
+    )
+    assert device.color_mode == expected_color_mode