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

Add and optimize led series (#352)

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>
Retha Runolfsson 1 өдөр өмнө
parent
commit
c7dc6a477c

+ 9 - 3
switchbot/__init__.py

@@ -11,11 +11,14 @@ from bleak_retry_connector import (
 from .adv_parser import SwitchbotSupportedType, parse_advertisement_data
 from .const import (
     AirPurifierMode,
+    BulbColorMode,
+    CeilingLightColorMode,
     FanMode,
     HumidifierAction,
     HumidifierMode,
     HumidifierWaterLevel,
     LockStatus,
+    StripLightColorMode,
     SwitchbotAccountConnectionError,
     SwitchbotApiError,
     SwitchbotAuthenticationError,
@@ -28,11 +31,11 @@ from .devices.bot import Switchbot
 from .devices.bulb import SwitchbotBulb
 from .devices.ceiling_light import SwitchbotCeilingLight
 from .devices.curtain import SwitchbotCurtain
-from .devices.device import ColorMode, SwitchbotDevice, SwitchbotEncryptedDevice
+from .devices.device import SwitchbotDevice, SwitchbotEncryptedDevice
 from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
 from .devices.fan import SwitchbotFan
 from .devices.humidifier import SwitchbotHumidifier
-from .devices.light_strip import SwitchbotLightStrip
+from .devices.light_strip import SwitchbotLightStrip, SwitchbotStripLight3
 from .devices.lock import SwitchbotLock
 from .devices.plug import SwitchbotPlugMini
 from .devices.relay_switch import SwitchbotRelaySwitch, SwitchbotRelaySwitch2PM
@@ -43,13 +46,15 @@ from .models import SwitchBotAdvertisement
 
 __all__ = [
     "AirPurifierMode",
-    "ColorMode",
+    "BulbColorMode",
+    "CeilingLightColorMode",
     "FanMode",
     "GetSwitchbotDevices",
     "HumidifierAction",
     "HumidifierMode",
     "HumidifierWaterLevel",
     "LockStatus",
+    "StripLightColorMode",
     "SwitchBotAdvertisement",
     "Switchbot",
     "Switchbot",
@@ -76,6 +81,7 @@ __all__ = [
     "SwitchbotRelaySwitch",
     "SwitchbotRelaySwitch2PM",
     "SwitchbotRollerShade",
+    "SwitchbotStripLight3",
     "SwitchbotSupportedType",
     "SwitchbotSupportedType",
     "SwitchbotVacuum",

+ 13 - 1
switchbot/adv_parser.py

@@ -24,7 +24,7 @@ from .adv_parsers.hubmini_matter import process_hubmini_matter
 from .adv_parsers.humidifier import process_evaporative_humidifier, process_wohumidifier
 from .adv_parsers.keypad import process_wokeypad
 from .adv_parsers.leak import process_leak
-from .adv_parsers.light_strip import process_wostrip
+from .adv_parsers.light_strip import process_light, process_wostrip
 from .adv_parsers.lock import process_lock2, process_wolock, process_wolock_pro
 from .adv_parsers.meter import process_wosensorth, process_wosensorth_c
 from .adv_parsers.motion import process_wopresence
@@ -325,6 +325,18 @@ SUPPORTED_TYPES: dict[str | bytes, SwitchbotSupportedType] = {
         "func": process_relay_switch_2pm,
         "manufacturer_id": 2409,
     },
+    b"\x00\x10\xd0\xb0": {
+        "modelName": SwitchbotModel.FLOOR_LAMP,
+        "modelFriendlyName": "Floor Lamp",
+        "func": process_light,
+        "manufacturer_id": 2409,
+    },
+    b"\x00\x10\xd0\xb1": {
+        "modelName": SwitchbotModel.STRIP_LIGHT_3,
+        "modelFriendlyName": "Strip Light 3",
+        "func": process_light,
+        "manufacturer_id": 2409,
+    },
 }
 
 _SWITCHBOT_MODEL_TO_CHAR = {

+ 15 - 4
switchbot/adv_parsers/light_strip.py

@@ -2,6 +2,8 @@
 
 from __future__ import annotations
 
+import struct
+
 
 def process_wostrip(
     data: bytes | None, mfr_data: bytes | None
@@ -14,8 +16,17 @@ def process_wostrip(
         "isOn": bool(mfr_data[7] & 0b10000000),
         "brightness": mfr_data[7] & 0b01111111,
         "delay": bool(mfr_data[8] & 0b10000000),
-        "preset": bool(mfr_data[8] & 0b00001000),
-        "color_mode": mfr_data[8] & 0b00000111,
-        "speed": mfr_data[9] & 0b01111111,
-        "loop_index": mfr_data[10] & 0b11111110,
+        "network_state": (mfr_data[8] & 0b01110000) >> 4,
+        "color_mode": mfr_data[8] & 0b00001111,
     }
+
+
+def process_light(data: bytes | None, mfr_data: bytes | None) -> dict[str, bool | int]:
+    """Support for strip light 3 and floor lamp."""
+    common_data = process_wostrip(data, mfr_data)
+    if not common_data:
+        return {}
+
+    light_data = {"cw": struct.unpack(">H", mfr_data[16:18])[0]}
+
+    return common_data | light_data

+ 6 - 0
switchbot/const/__init__.py

@@ -10,6 +10,7 @@ from .evaporative_humidifier import (
     HumidifierWaterLevel,
 )
 from .fan import FanMode
+from .light import BulbColorMode, CeilingLightColorMode, StripLightColorMode
 
 # Preserve old LockStatus export for backwards compatibility
 from .lock import LockStatus
@@ -85,6 +86,8 @@ class SwitchbotModel(StrEnum):
     LOCK_LITE = "Lock Lite"
     GARAGE_DOOR_OPENER = "Garage Door Opener"
     RELAY_SWITCH_2PM = "Relay Switch 2PM"
+    STRIP_LIGHT_3 = "Strip Light 3"
+    FLOOR_LAMP = "Floor Lamp"
 
 
 __all__ = [
@@ -92,11 +95,14 @@ __all__ = [
     "DEFAULT_RETRY_TIMEOUT",
     "DEFAULT_SCAN_TIMEOUT",
     "AirPurifierMode",
+    "BulbColorMode",
+    "CeilingLightColorMode",
     "FanMode",
     "HumidifierAction",
     "HumidifierMode",
     "HumidifierWaterLevel",
     "LockStatus",
+    "StripLightColorMode",
     "SwitchbotAccountConnectionError",
     "SwitchbotApiError",
     "SwitchbotAuthenticationError",

+ 27 - 0
switchbot/const/light.py

@@ -0,0 +1,27 @@
+from enum import Enum
+
+
+class StripLightColorMode(Enum):
+    RGB = 2
+    SCENE = 3
+    MUSIC = 4
+    CONTROLLER = 5
+    COLOR_TEMP = 6
+    UNKNOWN = 10
+
+
+class BulbColorMode(Enum):
+    COLOR_TEMP = 1
+    RGB = 2
+    DYNAMIC = 3
+    UNKNOWN = 10
+
+
+class CeilingLightColorMode(Enum):
+    COLOR_TEMP = 0
+    NIGHT = 1
+    MUSIC = 4
+    UNKNOWN = 10
+
+
+DEFAULT_COLOR_TEMP = 4001

+ 25 - 11
switchbot/devices/base_light.py

@@ -1,13 +1,12 @@
 from __future__ import annotations
 
 import logging
-import time
 from abc import abstractmethod
 from typing import Any
 
 from ..helpers import create_background_task
 from ..models import SwitchBotAdvertisement
-from .device import ColorMode, SwitchbotDevice
+from .device import SwitchbotDevice
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -43,9 +42,10 @@ class SwitchbotBaseLight(SwitchbotDevice):
         return self._get_adv_value("brightness") or 0
 
     @property
-    def color_mode(self) -> ColorMode:
+    @abstractmethod
+    def color_mode(self) -> Any:
         """Return the current color mode."""
-        return ColorMode(self._get_adv_value("color_mode") or 0)
+        raise NotImplementedError("Subclasses must implement color mode")
 
     @property
     def min_temp(self) -> int:
@@ -57,10 +57,19 @@ class SwitchbotBaseLight(SwitchbotDevice):
         """Return maximum color temp."""
         return 6500
 
+    @property
+    def get_effect_list(self) -> list[str] | None:
+        """Return the list of supported effects."""
+        return None
+
     def is_on(self) -> bool | None:
         """Return bulb state from cache."""
         return self._get_adv_value("isOn")
 
+    def get_effect(self):
+        """Return the current effect."""
+        return self._get_adv_value("effect")
+
     @abstractmethod
     async def turn_on(self) -> bool:
         """Turn device on."""
@@ -81,13 +90,18 @@ class SwitchbotBaseLight(SwitchbotDevice):
     async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool:
         """Set rgb."""
 
-    def poll_needed(self, last_poll_time: float | None) -> bool:
-        """Return if poll is needed."""
-        return False
-
-    async def update(self) -> None:
-        """Update device data."""
-        self._last_full_update = time.monotonic()
+    async def _send_multiple_commands(self, keys: list[str]) -> bool:
+        """
+        Send multiple commands to device.
+
+        Since we current have no way to tell which command the device
+        needs we send both.
+        """
+        final_result = False
+        for key in keys:
+            result = await self._send_command(key)
+            final_result |= self._check_command_result(result, 0, {1})
+        return final_result
 
 
 class SwitchbotSequenceBaseLight(SwitchbotBaseLight):

+ 75 - 35
switchbot/devices/bulb.py

@@ -1,9 +1,11 @@
 from __future__ import annotations
 
 import logging
+from typing import Any
 
+from ..const.light import BulbColorMode
 from .base_light import SwitchbotSequenceBaseLight
-from .device import REQ_HEADER, ColorMode
+from .device import REQ_HEADER, SwitchbotOperationError, update_after_operation
 
 BULB_COMMAND_HEADER = "4701"
 BULB_REQUEST = f"{REQ_HEADER}4801"
@@ -18,52 +20,67 @@ BRIGHTNESS_KEY = f"{BULB_COMMAND}14"
 RGB_KEY = f"{BULB_COMMAND}16"
 CW_KEY = f"{BULB_COMMAND}17"
 
+DEVICE_GET_VERSION_KEY = "570003"
+DEVICE_GET_BASIC_SETTINGS_KEY = "570f4801"
+
 _LOGGER = logging.getLogger(__name__)
 
 
+EFFECT_DICT = {
+    "Colorful": "570F4701010300",
+    "Flickering": "570F4701010301",
+    "Breathing": "570F4701010302",
+}
+
+
 class SwitchbotBulb(SwitchbotSequenceBaseLight):
     """Representation of a Switchbot bulb."""
 
     @property
-    def color_modes(self) -> set[ColorMode]:
+    def color_modes(self) -> set[BulbColorMode]:
         """Return the supported color modes."""
-        return {ColorMode.RGB, ColorMode.COLOR_TEMP}
+        return {BulbColorMode.RGB, BulbColorMode.COLOR_TEMP}
 
-    async def update(self) -> None:
-        """Update state of device."""
-        result = await self._send_command(BULB_REQUEST)
-        self._update_state(result)
-        await super().update()
+    @property
+    def color_mode(self) -> BulbColorMode:
+        """Return the current color mode."""
+        return BulbColorMode(self._get_adv_value("color_mode") or 10)
+
+    @property
+    def get_effect_list(self) -> list[str]:
+        """Return the list of supported effects."""
+        return list(EFFECT_DICT.keys())
 
+    @update_after_operation
     async def turn_on(self) -> bool:
         """Turn device on."""
         result = await self._send_command(BULB_ON_KEY)
-        self._update_state(result)
-        return self._check_command_result(result, 1, {0x80})
+        return self._check_command_result(result, 0, {1})
 
+    @update_after_operation
     async def turn_off(self) -> bool:
         """Turn device off."""
         result = await self._send_command(BULB_OFF_KEY)
-        self._update_state(result)
-        return self._check_command_result(result, 1, {0x00})
+        return self._check_command_result(result, 0, {1})
 
+    @update_after_operation
     async def set_brightness(self, brightness: int) -> bool:
         """Set brightness."""
         assert 0 <= brightness <= 100, "Brightness must be between 0 and 100"
         result = await self._send_command(f"{BRIGHTNESS_KEY}{brightness:02X}")
-        self._update_state(result)
-        return self._check_command_result(result, 1, {0x80})
+        return self._check_command_result(result, 0, {1})
 
+    @update_after_operation
     async def set_color_temp(self, brightness: int, color_temp: int) -> bool:
         """Set color temp."""
         assert 0 <= brightness <= 100, "Brightness must be between 0 and 100"
-        assert 2700 <= color_temp <= 6500, "Color Temp must be between 0 and 100"
+        assert 2700 <= color_temp <= 6500, "Color Temp must be between 2700 and 6500"
         result = await self._send_command(
             f"{CW_BRIGHTNESS_KEY}{brightness:02X}{color_temp:04X}"
         )
-        self._update_state(result)
-        return self._check_command_result(result, 1, {0x80})
+        return self._check_command_result(result, 0, {1})
 
+    @update_after_operation
     async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool:
         """Set rgb."""
         assert 0 <= brightness <= 100, "Brightness must be between 0 and 100"
@@ -73,22 +90,45 @@ class SwitchbotBulb(SwitchbotSequenceBaseLight):
         result = await self._send_command(
             f"{RGB_BRIGHTNESS_KEY}{brightness:02X}{r:02X}{g:02X}{b:02X}"
         )
-        self._update_state(result)
-        return self._check_command_result(result, 1, {0x80})
-
-    def _update_state(self, result: bytes | None) -> None:
-        """Update device state."""
-        if not result or len(result) < 10:
-            return
-        self._state["r"] = result[3]
-        self._state["g"] = result[4]
-        self._state["b"] = result[5]
-        self._state["cw"] = int(result[6:8].hex(), 16)
-        self._override_state(
-            {
-                "isOn": result[1] == 0x80,
-                "color_mode": result[10],
-            }
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def set_effect(self, effect: str) -> bool:
+        """Set effect."""
+        effect_template = EFFECT_DICT.get(effect)
+        if not effect_template:
+            raise SwitchbotOperationError(f"Effect {effect} not supported")
+        result = await self._send_command(effect_template)
+        if result:
+            self._override_state({"effect": effect})
+        return result
+
+    async def get_basic_info(self) -> dict[str, Any] | None:
+        """Get device basic settings."""
+        if not (_data := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)):
+            return None
+        if not (_version_info := await self._get_basic_info(DEVICE_GET_VERSION_KEY)):
+            return None
+
+        _LOGGER.debug(
+            "data: %s, version info: %s, address: %s",
+            _data,
+            _version_info,
+            self._device.address,
         )
-        _LOGGER.debug("%s: update state: %s = %s", self.name, result.hex(), self._state)
-        self._fire_callbacks()
+
+        self._state["r"] = _data[3]
+        self._state["g"] = _data[4]
+        self._state["b"] = _data[5]
+        self._state["cw"] = int.from_bytes(_data[6:8], "big")
+
+        return {
+            "isOn": bool(_data[1] & 0b10000000),
+            "brightness": _data[2] & 0b01111111,
+            "r": self._state["r"],
+            "g": self._state["g"],
+            "b": self._state["b"],
+            "cw": self._state["cw"],
+            "color_mode": _data[10] & 0b00001111,
+            "firmware": _version_info[2] / 10.0,
+        }

+ 52 - 29
switchbot/devices/ceiling_light.py

@@ -1,9 +1,11 @@
 from __future__ import annotations
 
 import logging
+from typing import Any
 
-from .base_light import SwitchbotBaseLight
-from .device import REQ_HEADER, ColorMode
+from ..const.light import DEFAULT_COLOR_TEMP, CeilingLightColorMode
+from .base_light import SwitchbotSequenceBaseLight
+from .device import REQ_HEADER, update_after_operation
 
 CEILING_LIGHT_COMMAND_HEADER = "5401"
 CEILING_LIGHT_REQUEST = f"{REQ_HEADER}5501"
@@ -14,56 +16,77 @@ CEILING_LIGHT_OFF_KEY = f"{CEILING_LIGHT_COMMAND}02FF01FFFF"
 CW_BRIGHTNESS_KEY = f"{CEILING_LIGHT_COMMAND}010001"
 BRIGHTNESS_KEY = f"{CEILING_LIGHT_COMMAND}01FF01"
 
+DEVICE_GET_VERSION_KEY = "5702"
+DEVICE_GET_BASIC_SETTINGS_KEY = "570f5581"
 
 _LOGGER = logging.getLogger(__name__)
 
 
-class SwitchbotCeilingLight(SwitchbotBaseLight):
-    """Representation of a Switchbot bulb."""
+class SwitchbotCeilingLight(SwitchbotSequenceBaseLight):
+    """Representation of a Switchbot ceiling light."""
 
     @property
-    def color_modes(self) -> set[ColorMode]:
+    def color_modes(self) -> set[CeilingLightColorMode]:
         """Return the supported color modes."""
-        return {ColorMode.COLOR_TEMP}
+        return {CeilingLightColorMode.COLOR_TEMP}
 
+    @property
+    def color_mode(self) -> CeilingLightColorMode:
+        """Return the current color mode."""
+        return CeilingLightColorMode(self._get_adv_value("color_mode") or 10)
+
+    @update_after_operation
     async def turn_on(self) -> bool:
         """Turn device on."""
         result = await self._send_command(CEILING_LIGHT_ON_KEY)
-        ret = self._check_command_result(result, 0, {0x01})
-        self._override_state({"isOn": True})
-        self._fire_callbacks()
-        return ret
+        return self._check_command_result(result, 0, {1})
 
+    @update_after_operation
     async def turn_off(self) -> bool:
         """Turn device off."""
         result = await self._send_command(CEILING_LIGHT_OFF_KEY)
-        ret = self._check_command_result(result, 0, {0x01})
-        self._override_state({"isOn": False})
-        self._fire_callbacks()
-        return ret
+        return self._check_command_result(result, 0, {1})
 
+    @update_after_operation
     async def set_brightness(self, brightness: int) -> bool:
         """Set brightness."""
         assert 0 <= brightness <= 100, "Brightness must be between 0 and 100"
-        result = await self._send_command(f"{BRIGHTNESS_KEY}{brightness:02X}0FA1")
-        ret = self._check_command_result(result, 0, {0x01})
-        self._override_state({"brightness": brightness, "isOn": True})
-        self._fire_callbacks()
-        return ret
+        color_temp = self._state.get("cw", DEFAULT_COLOR_TEMP)
+        result = await self._send_command(
+            f"{BRIGHTNESS_KEY}{brightness:02X}{color_temp:04X}"
+        )
+        return self._check_command_result(result, 0, {1})
 
+    @update_after_operation
     async def set_color_temp(self, brightness: int, color_temp: int) -> bool:
         """Set color temp."""
         assert 0 <= brightness <= 100, "Brightness must be between 0 and 100"
-        assert 2700 <= color_temp <= 6500, "Color Temp must be between 0 and 100"
+        assert 2700 <= color_temp <= 6500, "Color Temp must be between 2700 and 6500"
         result = await self._send_command(
             f"{CW_BRIGHTNESS_KEY}{brightness:02X}{color_temp:04X}"
         )
-        ret = self._check_command_result(result, 0, {0x01})
-        self._state["cw"] = color_temp
-        self._override_state({"brightness": brightness, "isOn": True})
-        self._fire_callbacks()
-        return ret
-
-    async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool:
-        """Set rgb."""
-        # Not supported on this device
+        return self._check_command_result(result, 0, {1})
+
+    async def get_basic_info(self) -> dict[str, Any] | None:
+        """Get device basic settings."""
+        if not (_data := await self._get_basic_info(DEVICE_GET_BASIC_SETTINGS_KEY)):
+            return None
+        if not (_version_info := await self._get_basic_info(DEVICE_GET_VERSION_KEY)):
+            return None
+
+        _LOGGER.debug(
+            "data: %s, version info: %s, address: %s",
+            _data,
+            _version_info,
+            self._device.address,
+        )
+
+        self._state["cw"] = int.from_bytes(_data[3:5], "big")
+
+        return {
+            "isOn": bool(_data[1] & 0b10000000),
+            "color_mode": _data[1] & 0b01000000,
+            "brightness": _data[2] & 0b01111111,
+            "cw": self._state["cw"],
+            "firmware": _version_info[2] / 10.0,
+        }

+ 0 - 8
switchbot/devices/device.py

@@ -8,7 +8,6 @@ import logging
 import time
 from collections.abc import Callable
 from dataclasses import replace
-from enum import Enum
 from typing import Any, TypeVar, cast
 from uuid import UUID
 
@@ -60,13 +59,6 @@ DBUS_ERROR_BACKOFF_TIME = 0.25
 DISCONNECT_DELAY = 8.5
 
 
-class ColorMode(Enum):
-    OFF = 0
-    COLOR_TEMP = 1
-    RGB = 2
-    EFFECT = 3
-
-
 # If the scanner is in passive mode, we
 # need to poll the device to get the
 # battery and a few rarely updating

+ 199 - 35
switchbot/devices/light_strip.py

@@ -1,9 +1,19 @@
 from __future__ import annotations
 
 import logging
+from typing import Any
 
+from bleak.backends.device import BLEDevice
+
+from ..const import SwitchbotModel
+from ..const.light import StripLightColorMode
 from .base_light import SwitchbotSequenceBaseLight
-from .device import REQ_HEADER, ColorMode
+from .device import (
+    REQ_HEADER,
+    SwitchbotEncryptedDevice,
+    SwitchbotOperationError,
+    update_after_operation,
+)
 
 STRIP_COMMMAND_HEADER = "4901"
 STRIP_REQUEST = f"{REQ_HEADER}4A01"
@@ -12,49 +22,132 @@ STRIP_COMMAND = f"{REQ_HEADER}{STRIP_COMMMAND_HEADER}"
 # Strip keys
 STRIP_ON_KEY = f"{STRIP_COMMAND}01"
 STRIP_OFF_KEY = f"{STRIP_COMMAND}02"
+COLOR_TEMP_KEY = f"{STRIP_COMMAND}11"
 RGB_BRIGHTNESS_KEY = f"{STRIP_COMMAND}12"
 BRIGHTNESS_KEY = f"{STRIP_COMMAND}14"
 
+DEVICE_GET_VERSION_KEY = "570003"
+
 _LOGGER = logging.getLogger(__name__)
 
+EFFECT_DICT = {
+    "Christmas": [
+        "570F49070200033C01",
+        "570F490701000600009902006D0EFF0021",
+        "570F490701000603009902006D0EFF0021",
+    ],
+    "Halloween": ["570F49070200053C04", "570F490701000300FF6A009E00ED00EA0F"],
+    "Sunset": [
+        "570F49070200033C3C",
+        "570F490701000900FF9000ED8C04DD5800",
+        "570F490701000903FF2E008E0B004F0500",
+        "570F4907010009063F0010270056140033",
+    ],
+    "Vitality": [
+        "570F49070200053C02",
+        "570F490701000600C5003FD9530AEC9800",
+        "570F490701000603FFDF0000895500468B",
+    ],
+    "Flashing": [
+        "570F49070200053C02",
+        "570F4907010006000000FF00FF00FF0000",
+        "570F490701000603FFFF0000FFFFA020F0",
+    ],
+    "Strobe": ["570F49070200043C02", "570F490701000300FF00E19D70FFFF0515"],
+    "Fade": [
+        "570F49070200043C04",
+        "570F490701000500FF5481FF00E19D70FF",
+        "570F490701000503FF0515FF7FEB",
+    ],
+    "Smooth": [
+        "570F49070200033C02",
+        "570F4907010007000036FC00F6FF00ED13",
+        "570F490701000703F6FF00FF8300FF0800",
+        "570F490701000706FF00E1",
+    ],
+    "Forest": [
+        "570F49070200033C06",
+        "570F490701000400006400228B223CB371",
+        "570F49070100040390EE90",
+    ],
+    "Ocean": [
+        "570F49070200033C06",
+        "570F4907010007004400FF0061FF007BFF",
+        "570F490701000703009DFF00B2FF00CBFF",
+        "570F49070100070600E9FF",
+    ],
+    "Autumn": [
+        "570F49070200043C05",
+        "570F490701000700D10035922D13A16501",
+        "570F490701000703AB9100DD8C00F4AA29",
+        "570F490701000706E8D000",
+    ],
+    "Cool": [
+        "570F49070200043C04",
+        "570F490701000600001A63006C9A00468B",
+        "570F490701000603009DA50089BE4378B6",
+    ],
+    "Flow": [
+        "570F49070200033C02",
+        "570F490701000600FF00D8E100FFAA00FF",
+        "570F4907010006037F00FF5000FF1900FF",
+    ],
+    "Relax": [
+        "570F49070200033C03",
+        "570F490701000400FF8C00FF7200FF1D00",
+        "570F490701000403FF5500",
+    ],
+    "Modern": [
+        "570F49070200043C03",
+        "570F49070100060089231A5F8969829E5A",
+        "570F490701000603BCB05EEDBE5AFF9D60",
+    ],
+    "Rose": [
+        "570F49070200043C04",
+        "570F490701000500FF1969BC215F7C0225",
+        "570F490701000503600C2B35040C",
+    ],
+}
+
 
 class SwitchbotLightStrip(SwitchbotSequenceBaseLight):
     """Representation of a Switchbot light strip."""
 
     @property
-    def color_modes(self) -> set[ColorMode]:
+    def color_modes(self) -> set[StripLightColorMode]:
         """Return the supported color modes."""
-        return {ColorMode.RGB}
+        return {StripLightColorMode.RGB}
 
-    async def update(self) -> None:
-        """Update state of device."""
-        result = await self._send_command(STRIP_REQUEST)
-        self._update_state(result)
-        await super().update()
+    @property
+    def color_mode(self) -> StripLightColorMode:
+        """Return the current color mode."""
+        return StripLightColorMode(self._get_adv_value("color_mode") or 10)
 
+    @property
+    def get_effect_list(self) -> list[str]:
+        """Return the list of supported effects."""
+        return list(EFFECT_DICT.keys())
+
+    @update_after_operation
     async def turn_on(self) -> bool:
         """Turn device on."""
         result = await self._send_command(STRIP_ON_KEY)
-        self._update_state(result)
-        return self._check_command_result(result, 1, {0x80})
+        return self._check_command_result(result, 0, {1})
 
+    @update_after_operation
     async def turn_off(self) -> bool:
         """Turn device off."""
         result = await self._send_command(STRIP_OFF_KEY)
-        self._update_state(result)
-        return self._check_command_result(result, 1, {0x00})
+        return self._check_command_result(result, 0, {1})
 
+    @update_after_operation
     async def set_brightness(self, brightness: int) -> bool:
         """Set brightness."""
         assert 0 <= brightness <= 100, "Brightness must be between 0 and 100"
         result = await self._send_command(f"{BRIGHTNESS_KEY}{brightness:02X}")
-        self._update_state(result)
-        return self._check_command_result(result, 1, {0x80})
-
-    async def set_color_temp(self, brightness: int, color_temp: int) -> bool:
-        """Set color temp."""
-        # not supported on this device
+        return self._check_command_result(result, 0, {1})
 
+    @update_after_operation
     async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool:
         """Set rgb."""
         assert 0 <= brightness <= 100, "Brightness must be between 0 and 100"
@@ -64,21 +157,92 @@ class SwitchbotLightStrip(SwitchbotSequenceBaseLight):
         result = await self._send_command(
             f"{RGB_BRIGHTNESS_KEY}{brightness:02X}{r:02X}{g:02X}{b:02X}"
         )
-        self._update_state(result)
-        return self._check_command_result(result, 1, {0x80})
-
-    def _update_state(self, result: bytes | None) -> None:
-        """Update device state."""
-        if not result or len(result) < 10:
-            return
-        self._state["r"] = result[3]
-        self._state["g"] = result[4]
-        self._state["b"] = result[5]
-        self._override_state(
-            {
-                "isOn": result[1] == 0x80,
-                "color_mode": result[10],
-            }
+        return self._check_command_result(result, 0, {1})
+
+    @update_after_operation
+    async def set_effect(self, effect: str) -> bool:
+        """Set effect."""
+        effect_template = EFFECT_DICT.get(effect)
+        if not effect_template:
+            raise SwitchbotOperationError(f"Effect {effect} not supported")
+        result = await self._send_multiple_commands(effect_template)
+        if result:
+            self._override_state({"effect": effect})
+        return result
+
+    async def get_basic_info(
+        self,
+        device_get_basic_info: str = STRIP_REQUEST,
+        device_get_version_info: str = DEVICE_GET_VERSION_KEY,
+    ) -> dict[str, Any] | None:
+        """Get device basic settings."""
+        if not (_data := await self._get_basic_info(device_get_basic_info)):
+            return None
+        if not (_version_info := await self._get_basic_info(device_get_version_info)):
+            return None
+
+        _LOGGER.debug(
+            "data: %s, version info: %s, address: %s",
+            _data,
+            _version_info,
+            self._device.address,
+        )
+
+        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 {
+            "isOn": bool(_data[1] & 0b10000000),
+            "brightness": _data[2] & 0b01111111,
+            "r": self._state["r"],
+            "g": self._state["g"],
+            "b": self._state["b"],
+            "cw": self._state["cw"],
+            "color_mode": _data[10] & 0b00001111,
+            "firmware": _version_info[2] / 10.0,
+        }
+
+
+class SwitchbotStripLight3(SwitchbotEncryptedDevice, SwitchbotLightStrip):
+    """Support for switchbot strip light3 and floor lamp."""
+
+    def __init__(
+        self,
+        device: BLEDevice,
+        key_id: str,
+        encryption_key: str,
+        interface: int = 0,
+        model: SwitchbotModel = SwitchbotModel.STRIP_LIGHT_3,
+        **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.STRIP_LIGHT_3,
+        **kwargs: Any,
+    ) -> bool:
+        return await super().verify_encryption_key(
+            device, key_id, encryption_key, model, **kwargs
+        )
+
+    @property
+    def color_modes(self) -> set[StripLightColorMode]:
+        """Return the supported color modes."""
+        return {StripLightColorMode.RGB, StripLightColorMode.COLOR_TEMP}
+
+    @update_after_operation
+    async def set_color_temp(self, brightness: int, color_temp: int) -> bool:
+        """Set color temp."""
+        assert 0 <= brightness <= 100
+        assert self.min_temp <= color_temp <= self.max_temp
+        result = await self._send_command(
+            f"{COLOR_TEMP_KEY}{brightness:02X}{color_temp:04X}"
         )
-        _LOGGER.debug("%s: update state: %s = %s", self.name, result.hex(), self._state)
-        self._fire_callbacks()
+        return self._check_command_result(result, 0, {1})

+ 1 - 1
tests/__init__.py

@@ -6,7 +6,7 @@ from switchbot import SwitchbotModel
 @dataclass
 class AdvTestCase:
     manufacturer_data: bytes | None
-    service_data: bytes
+    service_data: bytes | None
     data: dict
     model: str | bytes
     modelFriendlyName: str

+ 182 - 0
tests/test_adv_parser.py

@@ -3290,6 +3290,84 @@ def test_humidifer_with_empty_data() -> None:
             "Garage Door Opener",
             SwitchbotModel.GARAGE_DOOR_OPENER,
         ),
+        AdvTestCase(
+            b'\xc0N0\xe0U\x9a\x85\x9e"\xd0\x00\x00\x00\x00\x00\x00\x12\x91\x00',
+            b"\x00\x00\x00\x00\x10\xd0\xb1",
+            {
+                "sequence_number": 133,
+                "isOn": True,
+                "brightness": 30,
+                "delay": False,
+                "network_state": 2,
+                "color_mode": 2,
+                "cw": 4753,
+            },
+            b"\x00\x10\xd0\xb1",
+            "Strip Light 3",
+            SwitchbotModel.STRIP_LIGHT_3,
+        ),
+        AdvTestCase(
+            b'\xa0\x85\xe3e,\x06P\xaa"\xd4\x00\x00\x00\x00\x00\x00\r\x93\x00',
+            b"\x00\x00\x00\x00\x10\xd0\xb0",
+            {
+                "sequence_number": 80,
+                "isOn": True,
+                "brightness": 42,
+                "delay": False,
+                "network_state": 2,
+                "color_mode": 2,
+                "cw": 3475,
+            },
+            b"\x00\x10\xd0\xb0",
+            "Floor Lamp",
+            SwitchbotModel.FLOOR_LAMP,
+        ),
+        AdvTestCase(
+            b"\xef\xfe\xfb\x9d\x10\xfe\n\x01\x18\xf3$",
+            b"q\x00",
+            {
+                "brightness": 1,
+                "color_mode": 1,
+                "cw": 6387,
+                "isOn": False,
+                "sequence_number": 10,
+            },
+            "q",
+            "Ceiling Light",
+            SwitchbotModel.CEILING_LIGHT,
+        ),
+        AdvTestCase(
+            b'|,g\xc8\x15&jR"l\x00\x00\x00\x00\x00\x00',
+            b"r\x00d",
+            {
+                "brightness": 82,
+                "color_mode": 2,
+                "delay": False,
+                "isOn": False,
+                "sequence_number": 106,
+                "network_state": 2,
+            },
+            "r",
+            "Light Strip",
+            SwitchbotModel.LIGHT_STRIP,
+        ),
+        AdvTestCase(
+            b"@L\xca\xa7_\x12\x02\x81\x12\x00\x00",
+            b"u\x00d",
+            {
+                "brightness": 1,
+                "color_mode": 2,
+                "delay": False,
+                "isOn": True,
+                "loop_index": 0,
+                "preset": False,
+                "sequence_number": 2,
+                "speed": 0,
+            },
+            "u",
+            "Color Bulb",
+            SwitchbotModel.COLOR_BULB,
+        ),
     ],
 )
 def test_adv_active(test_case: AdvTestCase) -> None:
@@ -3376,6 +3454,70 @@ def test_adv_active(test_case: AdvTestCase) -> None:
             "Garage Door Opener",
             SwitchbotModel.GARAGE_DOOR_OPENER,
         ),
+        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\xb1",
+            "Strip Light 3",
+            SwitchbotModel.STRIP_LIGHT_3,
+        ),
+        AdvTestCase(
+            b'\xa0\x85\xe3e,\x06P\xaa"\xd4\x00\x00\x00\x00\x00\x00\r\x93\x00',
+            None,
+            {
+                "sequence_number": 80,
+                "isOn": True,
+                "brightness": 42,
+                "delay": False,
+                "network_state": 2,
+                "color_mode": 2,
+                "cw": 3475,
+            },
+            b"\x00\x10\xd0\xb0",
+            "Floor Lamp",
+            SwitchbotModel.FLOOR_LAMP,
+        ),
+        AdvTestCase(
+            b'|,g\xc8\x15&jR"l\x00\x00\x00\x00\x00\x00',
+            None,
+            {
+                "brightness": 82,
+                "color_mode": 2,
+                "delay": False,
+                "isOn": False,
+                "sequence_number": 106,
+                "network_state": 2,
+            },
+            "r",
+            "Light Strip",
+            SwitchbotModel.LIGHT_STRIP,
+        ),
+        AdvTestCase(
+            b"@L\xca\xa7_\x12\x02\x81\x12\x00\x00",
+            None,
+            {
+                "brightness": 1,
+                "color_mode": 2,
+                "delay": False,
+                "isOn": True,
+                "loop_index": 0,
+                "preset": False,
+                "sequence_number": 2,
+                "speed": 0,
+            },
+            "u",
+            "Color Bulb",
+            SwitchbotModel.COLOR_BULB,
+        ),
     ],
 )
 def test_adv_passive(test_case: AdvTestCase) -> None:
@@ -3464,6 +3606,46 @@ def test_adv_passive(test_case: AdvTestCase) -> None:
             "Garage Door Opener",
             SwitchbotModel.GARAGE_DOOR_OPENER,
         ),
+        AdvTestCase(
+            None,
+            b"\x00\x00\x00\x00\x10\xd0\xb1",
+            {},
+            b"\x00\x10\xd0\xb1",
+            "Strip Light 3",
+            SwitchbotModel.STRIP_LIGHT_3,
+        ),
+        AdvTestCase(
+            None,
+            b"\x00\x00\x00\x00\x10\xd0\xb0",
+            {},
+            b"\x00\x10\xd0\xb0",
+            "Floor Lamp",
+            SwitchbotModel.FLOOR_LAMP,
+        ),
+        AdvTestCase(
+            None,
+            b"q\x00",
+            {},
+            "q",
+            "Ceiling Light",
+            SwitchbotModel.CEILING_LIGHT,
+        ),
+        AdvTestCase(
+            None,
+            b"r\x00d",
+            {},
+            "r",
+            "Light Strip",
+            SwitchbotModel.LIGHT_STRIP,
+        ),
+        AdvTestCase(
+            None,
+            b"u\x00d",
+            {},
+            "u",
+            "Color Bulb",
+            SwitchbotModel.COLOR_BULB,
+        ),
     ],
 )
 def test_adv_with_empty_data(test_case: AdvTestCase) -> None:

+ 219 - 0
tests/test_bulb.py

@@ -0,0 +1,219 @@
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from bleak.backends.device import BLEDevice
+
+from switchbot import SwitchBotAdvertisement, SwitchbotModel
+from switchbot.devices import bulb
+from switchbot.devices.device import SwitchbotOperationError
+
+from .test_adv_parser import generate_ble_device
+
+
+def create_device_for_command_testing(init_data: dict | None = None):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    device = bulb.SwitchbotBulb(ble_device)
+    device.update_from_advertisement(make_advertisement_data(ble_device, init_data))
+    device._send_command = AsyncMock()
+    device._check_command_result = MagicMock()
+    device.update = AsyncMock()
+    return device
+
+
+def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None):
+    """Set advertisement data with defaults."""
+    if init_data is None:
+        init_data = {}
+
+    return SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": b"u\x00d",
+            "data": {
+                "brightness": 1,
+                "color_mode": 2,
+                "delay": False,
+                "isOn": True,
+                "loop_index": 0,
+                "preset": False,
+                "sequence_number": 2,
+                "speed": 0,
+            }
+            | init_data,
+            "isEncrypted": False,
+            "model": "u",
+            "modelFriendlyName": "Color Bulb",
+            "modelName": SwitchbotModel.COLOR_BULB,
+        },
+        device=ble_device,
+        rssi=-80,
+        active=True,
+    )
+
+
+@pytest.mark.asyncio
+async def test_default_info():
+    """Test default initialization of the color bulb."""
+    device = create_device_for_command_testing()
+
+    assert device.rgb is None
+
+    device._state = {"r": 30, "g": 0, "b": 0, "cw": 3200}
+
+    assert device.is_on() is True
+    assert device.on is True
+    assert device.color_mode == bulb.BulbColorMode.RGB
+    assert device.color_modes == {bulb.BulbColorMode.RGB, bulb.BulbColorMode.COLOR_TEMP}
+    assert device.rgb == (30, 0, 0)
+    assert device.color_temp == 3200
+    assert device.brightness == 1
+    assert device.min_temp == 2700
+    assert device.max_temp == 6500
+    assert device.get_effect_list == list(bulb.EFFECT_DICT.keys())
+
+
+@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 = create_device_for_command_testing()
+
+    async def mock_get_basic_info(arg):
+        if arg == bulb.DEVICE_GET_BASIC_SETTINGS_KEY:
+            return basic_info
+        if arg == bulb.DEVICE_GET_VERSION_KEY:
+            return version_info
+        return None
+
+    device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
+
+    assert await device.get_basic_info() is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("info_data", "result"),
+    [
+        (
+            {
+                "basic_info": b"\x01\x80\x01\xff\x91\x96\x00\x00\xff\xff\x02",
+                "version_info": b"\x01\x01\x11",
+            },
+            [True, 1, 255, 145, 150, 0, 2, 1.7],
+        ),
+        (
+            {
+                "basic_info": b"\x01\x80;\x00\x00\x00\x0c\x99\xff\xff\x01",
+                "version_info": b"\x01\x01\x11",
+            },
+            [True, 59, 0, 0, 0, 3225, 1, 1.7],
+        ),
+        (
+            {
+                "basic_info": b"\x01\x80\t!7\xff\x00\x00\xff\xff\x02",
+                "version_info": b"\x01\x01\x11",
+            },
+            [True, 9, 33, 55, 255, 0, 2, 1.7],
+        ),
+    ],
+)
+async def test_get_basic_info(info_data, result):
+    """Test getting basic info from the color bulb."""
+    device = create_device_for_command_testing()
+
+    async def mock_get_basic_info(args: str) -> list[int] | None:
+        if args == bulb.DEVICE_GET_BASIC_SETTINGS_KEY:
+            return info_data["basic_info"]
+        if args == bulb.DEVICE_GET_VERSION_KEY:
+            return info_data["version_info"]
+        return None
+
+    device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
+    info = await device.get_basic_info()
+
+    assert info["isOn"] is result[0]
+    assert info["brightness"] == result[1]
+    assert info["r"] == result[2]
+    assert info["g"] == result[3]
+    assert info["b"] == result[4]
+    assert info["cw"] == result[5]
+    assert info["color_mode"] == result[6]
+    assert info["firmware"] == result[7]
+
+
+@pytest.mark.asyncio
+async def test_set_color_temp():
+    """Test setting color temperature."""
+    device = create_device_for_command_testing()
+
+    await device.set_color_temp(50, 3000)
+
+    device._send_command.assert_called_with(f"{bulb.CW_BRIGHTNESS_KEY}320BB8")
+
+
+@pytest.mark.asyncio
+async def test_turn_on():
+    """Test turning on the color bulb."""
+    device = create_device_for_command_testing({"isOn": True})
+
+    await device.turn_on()
+
+    device._send_command.assert_called_with(bulb.BULB_ON_KEY)
+
+    assert device.is_on() is True
+
+
+@pytest.mark.asyncio
+async def test_turn_off():
+    """Test turning off the color bulb."""
+    device = create_device_for_command_testing({"isOn": False})
+
+    await device.turn_off()
+
+    device._send_command.assert_called_with(bulb.BULB_OFF_KEY)
+
+    assert device.is_on() is False
+
+
+@pytest.mark.asyncio
+async def test_set_brightness():
+    """Test setting brightness."""
+    device = create_device_for_command_testing()
+
+    await device.set_brightness(75)
+
+    device._send_command.assert_called_with(f"{bulb.BRIGHTNESS_KEY}4B")
+
+
+@pytest.mark.asyncio
+async def test_set_rgb():
+    """Test setting RGB values."""
+    device = create_device_for_command_testing()
+
+    await device.set_rgb(100, 255, 128, 64)
+
+    device._send_command.assert_called_with(f"{bulb.RGB_BRIGHTNESS_KEY}64FF8040")
+
+
+@pytest.mark.asyncio
+async def test_set_effect_with_invalid_effect():
+    """Test setting an invalid effect."""
+    device = create_device_for_command_testing()
+
+    with pytest.raises(
+        SwitchbotOperationError, match="Effect invalid_effect not supported"
+    ):
+        await device.set_effect("invalid_effect")
+
+
+@pytest.mark.asyncio
+async def test_set_effect_with_valid_effect():
+    """Test setting a valid effect."""
+    device = create_device_for_command_testing()
+
+    await device.set_effect("Colorful")
+
+    device._send_command.assert_called_with(bulb.EFFECT_DICT["Colorful"])
+
+    assert device.get_effect() == "Colorful"

+ 178 - 0
tests/test_ceiling_light.py

@@ -0,0 +1,178 @@
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from bleak.backends.device import BLEDevice
+
+from switchbot import SwitchBotAdvertisement, SwitchbotModel
+from switchbot.devices import ceiling_light
+
+from .test_adv_parser import generate_ble_device
+
+
+def create_device_for_command_testing(init_data: dict | None = None):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    device = ceiling_light.SwitchbotCeilingLight(ble_device)
+    device.update_from_advertisement(make_advertisement_data(ble_device, init_data))
+    device._send_command = AsyncMock()
+    device._check_command_result = MagicMock()
+    device.update = AsyncMock()
+    return device
+
+
+def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None):
+    """Set advertisement data with defaults."""
+    if init_data is None:
+        init_data = {}
+
+    return SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": b"q\x00",
+            "data": {
+                "brightness": 1,
+                "color_mode": 1,
+                "cw": 6387,
+                "isOn": False,
+                "sequence_number": 10,
+            }
+            | init_data,
+            "isEncrypted": False,
+            "model": b"q\x00",
+            "modelFriendlyName": "Ceiling Light",
+            "modelName": SwitchbotModel.CEILING_LIGHT,
+        },
+        device=ble_device,
+        rssi=-80,
+        active=True,
+    )
+
+
+@pytest.mark.asyncio
+async def test_default_info():
+    """Test default initialization of the ceiling light."""
+    device = create_device_for_command_testing()
+
+    assert device.rgb is None
+
+    device._state = {"cw": 3200}
+
+    assert device.is_on() is False
+    assert device.on is False
+    assert device.color_mode == ceiling_light.CeilingLightColorMode.NIGHT
+    assert device.color_modes == {ceiling_light.CeilingLightColorMode.COLOR_TEMP}
+    assert device.color_temp == 3200
+    assert device.brightness == 1
+    assert device.min_temp == 2700
+    assert device.max_temp == 6500
+    assert device.get_effect_list is None
+
+
+@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 = create_device_for_command_testing()
+
+    async def mock_get_basic_info(arg):
+        if arg == ceiling_light.DEVICE_GET_BASIC_SETTINGS_KEY:
+            return basic_info
+        if arg == ceiling_light.DEVICE_GET_VERSION_KEY:
+            return version_info
+        return None
+
+    device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
+
+    assert await device.get_basic_info() is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("info_data", "result"),
+    [
+        (
+            {
+                "basic_info": b"\x01\x80=\x0f\xa1\x00\x01",
+                "version_info": b"\x01d\x15\x0f\x00\x00\x00\x00\x00\x00\x00\n\x00",
+            },
+            [True, 61, 4001, 0, 2.1],
+        ),
+        (
+            {
+                "basic_info": b"\x01\x80\x0e\x12B\x00\x01",
+                "version_info": b"\x01d\x15\x0f\x00\x00\x00\x00\x00\x00\x00\n\x00",
+            },
+            [True, 14, 4674, 0, 2.1],
+        ),
+        (
+            {
+                "basic_info": b"\x01\x00\x0e\x10\x96\x00\x01",
+                "version_info": b"\x01d\x15\x0f\x00\x00\x00\x00\x00\x00\x00\n\x00",
+            },
+            [False, 14, 4246, 0, 2.1],
+        ),
+    ],
+)
+async def test_get_basic_info(info_data, result):
+    """Test getting basic info from the ceiling light."""
+    device = create_device_for_command_testing()
+
+    async def mock_get_basic_info(args: str) -> list[int] | None:
+        if args == ceiling_light.DEVICE_GET_BASIC_SETTINGS_KEY:
+            return info_data["basic_info"]
+        if args == ceiling_light.DEVICE_GET_VERSION_KEY:
+            return info_data["version_info"]
+        return None
+
+    device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
+    info = await device.get_basic_info()
+
+    assert info["isOn"] is result[0]
+    assert info["brightness"] == result[1]
+    assert info["cw"] == result[2]
+    assert info["color_mode"] == result[3]
+    assert info["firmware"] == result[4]
+
+
+@pytest.mark.asyncio
+async def test_set_color_temp():
+    """Test setting color temperature."""
+    device = create_device_for_command_testing()
+
+    await device.set_color_temp(50, 3000)
+
+    device._send_command.assert_called_with(f"{ceiling_light.CW_BRIGHTNESS_KEY}320BB8")
+
+
+@pytest.mark.asyncio
+async def test_turn_on():
+    """Test turning on the ceiling light."""
+    device = create_device_for_command_testing({"isOn": True})
+
+    await device.turn_on()
+
+    device._send_command.assert_called_with(ceiling_light.CEILING_LIGHT_ON_KEY)
+
+    assert device.is_on() is True
+
+
+@pytest.mark.asyncio
+async def test_turn_off():
+    """Test turning off the ceiling light."""
+    device = create_device_for_command_testing({"isOn": False})
+
+    await device.turn_off()
+
+    device._send_command.assert_called_with(ceiling_light.CEILING_LIGHT_OFF_KEY)
+
+    assert device.is_on() is False
+
+
+@pytest.mark.asyncio
+async def test_set_brightness():
+    """Test setting brightness."""
+    device = create_device_for_command_testing()
+
+    await device.set_brightness(75)
+
+    device._send_command.assert_called_with(f"{ceiling_light.BRIGHTNESS_KEY}4B0FA1")

+ 301 - 0
tests/test_strip_light.py

@@ -0,0 +1,301 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from bleak.backends.device import BLEDevice
+
+from switchbot import SwitchBotAdvertisement, SwitchbotModel
+from switchbot.devices import light_strip
+from switchbot.devices.base_light import SwitchbotBaseLight
+from switchbot.devices.device import SwitchbotEncryptedDevice, SwitchbotOperationError
+
+from .test_adv_parser import generate_ble_device
+
+
+def create_device_for_command_testing(init_data: dict | None = None):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    device = light_strip.SwitchbotStripLight3(
+        ble_device, "ff", "ffffffffffffffffffffffffffffffff"
+    )
+    device.update_from_advertisement(make_advertisement_data(ble_device, init_data))
+    device._send_command = AsyncMock()
+    device._check_command_result = MagicMock()
+    device.update = AsyncMock()
+    return device
+
+
+def make_advertisement_data(ble_device: BLEDevice, init_data: dict | None = None):
+    """Set advertisement data with defaults."""
+    if init_data is None:
+        init_data = {}
+
+    return SwitchBotAdvertisement(
+        address="aa:bb:cc:dd:ee:ff",
+        data={
+            "rawAdvData": b"\x00\x00\x00\x00\x10\xd0\xb1",
+            "data": {
+                "sequence_number": 133,
+                "isOn": True,
+                "brightness": 30,
+                "delay": False,
+                "network_state": 2,
+                "color_mode": 2,
+                "cw": 4753,
+            }
+            | init_data,
+            "isEncrypted": False,
+            "model": b"\x00\x10\xd0\xb1",
+            "modelFriendlyName": "Strip Light 3",
+            "modelName": SwitchbotModel.STRIP_LIGHT_3,
+        },
+        device=ble_device,
+        rssi=-80,
+        active=True,
+    )
+
+
+@pytest.mark.asyncio
+async def test_default_info():
+    """Test default initialization of the strip light."""
+    device = create_device_for_command_testing()
+
+    assert device.rgb is None
+
+    device._state = {"r": 30, "g": 0, "b": 0, "cw": 3200}
+
+    assert device.is_on() is True
+    assert device.on is True
+    assert device.color_mode == light_strip.StripLightColorMode.RGB
+    assert device.color_modes == {
+        light_strip.StripLightColorMode.RGB,
+        light_strip.StripLightColorMode.COLOR_TEMP,
+    }
+    assert device.rgb == (30, 0, 0)
+    assert device.color_temp == 3200
+    assert device.brightness == 30
+    assert device.min_temp == 2700
+    assert device.max_temp == 6500
+    assert device.get_effect_list == list(light_strip.EFFECT_DICT.keys())
+
+
+@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 = create_device_for_command_testing()
+
+    async def mock_get_basic_info(arg):
+        if arg == light_strip.STRIP_REQUEST:
+            return basic_info
+        if arg == light_strip.DEVICE_GET_VERSION_KEY:
+            return version_info
+        return None
+
+    device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
+
+    assert await device.get_basic_info() is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("info_data", "result"),
+    [
+        (
+            {
+                "basic_info": b"\x01\x00<\xff\x00\xd8\x00\x19d\x00\x03",
+                "version_info": b"\x01\x01\n",
+            },
+            [False, 60, 255, 0, 216, 6500, 3, 1.0],
+        ),
+        (
+            {
+                "basic_info": b"\x01\x80NK\xff:\x00\x19d\xff\x02",
+                "version_info": b"\x01\x01\n",
+            },
+            [True, 78, 75, 255, 58, 6500, 2, 1.0],
+        ),
+        (
+            {
+                "basic_info": b"\x01\x80$K\xff:\x00\x13\xf9\xff\x06",
+                "version_info": b"\x01\x01\n",
+            },
+            [True, 36, 75, 255, 58, 5113, 6, 1.0],
+        ),
+    ],
+)
+async def test_strip_light_get_basic_info(info_data, result):
+    """Test getting basic info from the strip light."""
+    device = create_device_for_command_testing()
+
+    async def mock_get_basic_info(args: str) -> list[int] | None:
+        if args == light_strip.STRIP_REQUEST:
+            return info_data["basic_info"]
+        if args == light_strip.DEVICE_GET_VERSION_KEY:
+            return info_data["version_info"]
+        return None
+
+    device._get_basic_info = AsyncMock(side_effect=mock_get_basic_info)
+    info = await device.get_basic_info()
+
+    assert info["isOn"] is result[0]
+    assert info["brightness"] == result[1]
+    assert info["r"] == result[2]
+    assert info["g"] == result[3]
+    assert info["b"] == result[4]
+    assert info["cw"] == result[5]
+    assert info["color_mode"] == result[6]
+    assert info["firmware"] == result[7]
+
+
+@pytest.mark.asyncio
+async def test_set_color_temp():
+    """Test setting color temperature."""
+    device = create_device_for_command_testing()
+
+    await device.set_color_temp(50, 3000)
+
+    device._send_command.assert_called_with(f"{light_strip.COLOR_TEMP_KEY}320BB8")
+
+
+@pytest.mark.asyncio
+async def test_turn_on():
+    """Test turning on the strip light."""
+    device = create_device_for_command_testing({"isOn": True})
+
+    await device.turn_on()
+
+    device._send_command.assert_called_with(light_strip.STRIP_ON_KEY)
+
+    assert device.is_on() is True
+
+
+@pytest.mark.asyncio
+async def test_turn_off():
+    """Test turning off the strip light."""
+    device = create_device_for_command_testing({"isOn": False})
+
+    await device.turn_off()
+
+    device._send_command.assert_called_with(light_strip.STRIP_OFF_KEY)
+
+    assert device.is_on() is False
+
+
+@pytest.mark.asyncio
+async def test_set_brightness():
+    """Test setting brightness."""
+    device = create_device_for_command_testing()
+
+    await device.set_brightness(75)
+
+    device._send_command.assert_called_with(f"{light_strip.BRIGHTNESS_KEY}4B")
+
+
+@pytest.mark.asyncio
+async def test_set_rgb():
+    """Test setting RGB values."""
+    device = create_device_for_command_testing()
+
+    await device.set_rgb(100, 255, 128, 64)
+
+    device._send_command.assert_called_with(f"{light_strip.RGB_BRIGHTNESS_KEY}64FF8040")
+
+
+@pytest.mark.asyncio
+async def test_set_effect_with_invalid_effect():
+    """Test setting an invalid effect."""
+    device = create_device_for_command_testing()
+
+    with pytest.raises(
+        SwitchbotOperationError, match="Effect invalid_effect not supported"
+    ):
+        await device.set_effect("invalid_effect")
+
+
+@pytest.mark.asyncio
+async def test_set_effect_with_valid_effect():
+    """Test setting a valid effect."""
+    device = create_device_for_command_testing()
+    device._send_multiple_commands = AsyncMock()
+
+    await device.set_effect("Christmas")
+
+    device._send_multiple_commands.assert_called_with(
+        light_strip.EFFECT_DICT["Christmas"]
+    )
+
+    assert device.get_effect() == "Christmas"
+
+
+@pytest.mark.asyncio
+@patch.object(SwitchbotEncryptedDevice, "verify_encryption_key", new_callable=AsyncMock)
+async def test_verify_encryption_key(mock_parent_verify):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    key_id = "ff"
+    encryption_key = "ffffffffffffffffffffffffffffffff"
+
+    mock_parent_verify.return_value = True
+
+    result = await light_strip.SwitchbotStripLight3.verify_encryption_key(
+        device=ble_device,
+        key_id=key_id,
+        encryption_key=encryption_key,
+    )
+
+    mock_parent_verify.assert_awaited_once_with(
+        ble_device,
+        key_id,
+        encryption_key,
+        SwitchbotModel.STRIP_LIGHT_3,
+    )
+
+    assert result is True
+
+
+def create_strip_light_device(init_data: dict | None = None):
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    return light_strip.SwitchbotLightStrip(ble_device)
+
+
+@pytest.mark.asyncio
+async def test_strip_light_supported_color_modes():
+    """Test that the strip light supports the expected color modes."""
+    device = create_strip_light_device()
+
+    assert device.color_modes == {
+        light_strip.StripLightColorMode.RGB,
+    }
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("commands", "results", "final_result"),
+    [
+        (("command1", "command2"), [(b"\x01", False), (None, False)], False),
+        (("command1", "command2"), [(None, False), (b"\x01", True)], True),
+        (("command1", "command2"), [(b"\x01", True), (b"\x01", False)], True),
+    ],
+)
+async def test_send_multiple_commands(commands, results, final_result):
+    """Test sending multiple commands."""
+    device = create_device_for_command_testing()
+
+    device._send_command = AsyncMock(side_effect=[r[0] for r in results])
+
+    device._check_command_result = MagicMock(side_effect=[r[1] for r in results])
+
+    result = await device._send_multiple_commands(list(commands))
+
+    assert result is final_result
+
+
+@pytest.mark.asyncio
+async def test_unimplemented_color_mode():
+    class TestDevice(SwitchbotBaseLight):
+        pass
+
+    ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "any")
+    device = TestDevice(ble_device)
+
+    with pytest.raises(NotImplementedError):
+        _ = device.color_mode