diff --git a/switchbot/__init__.py b/switchbot/__init__.py index f5981fb6..a6e03947 100644 --- a/switchbot/__init__.py +++ b/switchbot/__init__.py @@ -48,6 +48,7 @@ from .devices.keypad_vision import SwitchbotKeypadVision from .devices.light_strip import ( SwitchbotLightStrip, + SwitchbotPermanentOutdoorLight, SwitchbotRgbicLight, SwitchbotStripLight3, ) @@ -106,6 +107,7 @@ "SwitchbotModel", "SwitchbotModel", "SwitchbotOperationError", + "SwitchbotPermanentOutdoorLight", "SwitchbotPlugMini", "SwitchbotPlugMini", "SwitchbotRelaySwitch", diff --git a/switchbot/adv_parser.py b/switchbot/adv_parser.py index 7c121029..5e108f52 100644 --- a/switchbot/adv_parser.py +++ b/switchbot/adv_parser.py @@ -659,6 +659,18 @@ class SwitchbotSupportedType(TypedDict): "func": process_rgbic_light, "manufacturer_id": 2409, }, + b"\x00\x10\xd0\xb7": { + "modelName": SwitchbotModel.PERMANENT_OUTDOOR_LIGHT, + "modelFriendlyName": "Permanent Outdoor Light", + "func": process_light, + "manufacturer_id": 2409, + }, + b"\x01\x10\xd0\xb7": { + "modelName": SwitchbotModel.PERMANENT_OUTDOOR_LIGHT, + "modelFriendlyName": "Permanent Outdoor Light", + "func": process_light, + "manufacturer_id": 2409, + }, b"\x00\x10\xfb\xa8": { "modelName": SwitchbotModel.K11_VACUUM, "modelFriendlyName": "K11+ Vacuum", diff --git a/switchbot/adv_parsers/light_strip.py b/switchbot/adv_parsers/light_strip.py index c6d8ad7b..e4218e92 100644 --- a/switchbot/adv_parsers/light_strip.py +++ b/switchbot/adv_parsers/light_strip.py @@ -29,7 +29,10 @@ def process_light( if not common_data: return {} - light_data = {"cw": _UNPACK_UINT16_BE(mfr_data, cw_offset)[0]} + if mfr_data is not None and len(mfr_data) >= cw_offset + 2: + light_data = {"cw": _UNPACK_UINT16_BE(mfr_data, cw_offset)[0]} + else: + light_data = {"cw": 0} return common_data | light_data diff --git a/switchbot/const/__init__.py b/switchbot/const/__init__.py index 4e6e2512..54d5633d 100644 --- a/switchbot/const/__init__.py +++ b/switchbot/const/__init__.py @@ -97,6 +97,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" K11_VACUUM = "K11+ Vacuum" CLIMATE_PANEL = "Climate Panel" SMART_THERMOSTAT_RADIATOR = "Smart Thermostat Radiator" diff --git a/switchbot/devices/light_strip.py b/switchbot/devices/light_strip.py index 7a5dd250..8f0a0996 100644 --- a/switchbot/devices/light_strip.py +++ b/switchbot/devices/light_strip.py @@ -25,6 +25,7 @@ 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" @@ -304,6 +305,47 @@ def color_modes(self) -> set[ColorMode]: return {ColorMode.RGB, ColorMode.COLOR_TEMP} +class SwitchbotPermanentOutdoorLight(SwitchbotEncryptedDevice, SwitchbotLightStrip): + """Support for Switchbot Permanent Outdoor Light.""" + + _effect_dict = RGBIC_EFFECTS + + def __init__( + self, + device: BLEDevice, + key_id: str, + encryption_key: str, + interface: int = 0, + model: SwitchbotModel = SwitchbotModel.PERMANENT_OUTDOOR_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.PERMANENT_OUTDOOR_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) + + class SwitchbotRgbicLight(SwitchbotEncryptedDevice, SwitchbotLightStrip): """Support for Switchbot RGBIC lights.""" diff --git a/tests/__init__.py b/tests/__init__.py index e6ea3e06..01f0d736 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -82,6 +82,24 @@ class 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": 4753, + }, + 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@", diff --git a/tests/test_adv_parser.py b/tests/test_adv_parser.py index c71c3b30..b6e12867 100644 --- a/tests/test_adv_parser.py +++ b/tests/test_adv_parser.py @@ -3575,6 +3575,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": 4753, + }, + b"\x00\x10\xd0\xb7", + "Permanent Outdoor Light", + SwitchbotModel.PERMANENT_OUTDOOR_LIGHT, + ), AdvTestCase( b"\xb0\xe9\xfe\xe4\xbf\xd8\x0b\x01\x11f\x00\x16M\x15", b"\x00\x00M\x00\x10\xfb\xa8", @@ -3947,6 +3963,38 @@ 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": 4753, + }, + b"\x00\x10\xd0\xb7", + "Permanent Outdoor Light", + SwitchbotModel.PERMANENT_OUTDOOR_LIGHT, + ), + AdvTestCase( + b'\xc0N0\xe0U\x9a\x85\x9e"\xd0\x00\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"\xb0\xe9\xfe\xe4\xbf\xd8\x0b\x01\x11f\x00\x16M\x15", None, @@ -4244,6 +4292,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", diff --git a/tests/test_strip_light.py b/tests/test_strip_light.py index bc667157..bf15d407 100644 --- a/tests/test_strip_light.py +++ b/tests/test_strip_light.py @@ -11,6 +11,7 @@ from . import ( FLOOR_LAMP_INFO, + PERMANENT_OUTDOOR_LIGHT_INFO, RGBICWW_FLOOR_LAMP_INFO, RGBICWW_STRIP_LIGHT_INFO, STRIP_LIGHT_3_INFO, @@ -24,6 +25,7 @@ (FLOOR_LAMP_INFO, light_strip.SwitchbotStripLight3), (RGBICWW_STRIP_LIGHT_INFO, light_strip.SwitchbotRgbicLight), (RGBICWW_FLOOR_LAMP_INFO, light_strip.SwitchbotRgbicLight), + (PERMANENT_OUTDOOR_LIGHT_INFO, light_strip.SwitchbotPermanentOutdoorLight), ] ) def device_case(request): @@ -38,6 +40,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] @@ -404,3 +407,29 @@ def __init__(self, device: BLEDevice, model: str = "unknown") -> None: 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