Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions switchbot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from .devices.keypad_vision import SwitchbotKeypadVision
from .devices.light_strip import (
SwitchbotLightStrip,
SwitchbotPermanentOutdoorLight,
SwitchbotRgbicLight,
SwitchbotStripLight3,
)
Expand Down Expand Up @@ -106,6 +107,7 @@
"SwitchbotModel",
"SwitchbotModel",
"SwitchbotOperationError",
"SwitchbotPermanentOutdoorLight",
"SwitchbotPlugMini",
"SwitchbotPlugMini",
"SwitchbotRelaySwitch",
Expand Down
12 changes: 12 additions & 0 deletions switchbot/adv_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion switchbot/adv_parsers/light_strip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions switchbot/const/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
42 changes: 42 additions & 0 deletions switchbot/devices/light_strip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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."""

Expand Down
18 changes: 18 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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@",
Expand Down
56 changes: 56 additions & 0 deletions tests/test_adv_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions tests/test_strip_light.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand All @@ -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]

Expand Down Expand Up @@ -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
Loading