Add effect mode support for switchbot light (#147326)

* add support for strip light3 and floor lamp

* clear the color mode

* add led unit test

* use property for effect

* fix color mode issue

* remove new products

* fix adv data

* adjust log level

* add translation and icon
This commit is contained in:
Retha Runolfsson 2025-06-25 20:45:07 +08:00 committed by GitHub
parent 8393f17bb3
commit c5f8acfe93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 420 additions and 144 deletions

View File

@ -60,6 +60,35 @@
}
}
}
},
"light": {
"light": {
"state_attributes": {
"effect": {
"state": {
"christmas": "mdi:string-lights",
"halloween": "mdi:halloween",
"sunset": "mdi:weather-sunset",
"vitality": "mdi:parachute",
"flashing": "mdi:flash",
"strobe": "mdi:led-strip-variant",
"fade": "mdi:water-opacity",
"smooth": "mdi:led-strip-variant",
"forest": "mdi:forest",
"ocean": "mdi:waves",
"autumn": "mdi:leaf-maple",
"cool": "mdi:emoticon-cool-outline",
"flow": "mdi:pulse",
"relax": "mdi:coffee",
"modern": "mdi:school-outline",
"rose": "mdi:flower",
"colorful": "mdi:looks",
"flickering": "mdi:led-strip-variant",
"breathing": "mdi:heart-pulse"
}
}
}
}
}
}
}

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from typing import Any, cast
import switchbot
@ -10,14 +11,16 @@ from switchbot import ColorMode as SwitchBotColorMode
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
LightEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
from .coordinator import SwitchbotConfigEntry
from .entity import SwitchbotEntity, exception_handler
SWITCHBOT_COLOR_MODE_TO_HASS = {
@ -25,6 +28,7 @@ SWITCHBOT_COLOR_MODE_TO_HASS = {
SwitchBotColorMode.COLOR_TEMP: ColorMode.COLOR_TEMP,
}
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@ -42,34 +46,69 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity):
_device: switchbot.SwitchbotBaseLight
_attr_name = None
_attr_translation_key = "light"
def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
"""Initialize the Switchbot light."""
super().__init__(coordinator)
device = self._device
self._attr_max_color_temp_kelvin = device.max_temp
self._attr_min_color_temp_kelvin = device.min_temp
self._attr_supported_color_modes = {
SWITCHBOT_COLOR_MODE_TO_HASS[mode] for mode in device.color_modes
}
self._async_update_attrs()
@property
def max_color_temp_kelvin(self) -> int:
"""Return the max color temperature."""
return self._device.max_temp
@callback
def _async_update_attrs(self) -> None:
"""Handle updating _attr values."""
device = self._device
self._attr_is_on = self._device.on
self._attr_brightness = max(0, min(255, round(device.brightness * 2.55)))
if device.color_mode == SwitchBotColorMode.COLOR_TEMP:
self._attr_color_temp_kelvin = device.color_temp
self._attr_color_mode = ColorMode.COLOR_TEMP
return
self._attr_rgb_color = device.rgb
self._attr_color_mode = ColorMode.RGB
@property
def min_color_temp_kelvin(self) -> int:
"""Return the min color temperature."""
return self._device.min_temp
@property
def supported_color_modes(self) -> set[ColorMode]:
"""Return the supported color modes."""
return {SWITCHBOT_COLOR_MODE_TO_HASS[mode] for mode in self._device.color_modes}
@property
def supported_features(self) -> LightEntityFeature:
"""Return the supported features."""
return LightEntityFeature.EFFECT if self.effect_list else LightEntityFeature(0)
@property
def brightness(self) -> int | None:
"""Return the brightness of the light."""
return max(0, min(255, round(self._device.brightness * 2.55)))
@property
def color_mode(self) -> ColorMode | None:
"""Return the color mode of the light."""
return SWITCHBOT_COLOR_MODE_TO_HASS.get(
self._device.color_mode, ColorMode.UNKNOWN
)
@property
def effect_list(self) -> list[str] | None:
"""Return the list of effects supported by the light."""
return self._device.get_effect_list
@property
def effect(self) -> str | None:
"""Return the current effect of the light."""
return self._device.get_effect()
@property
def rgb_color(self) -> tuple[int, int, int] | None:
"""Return the RGB color of the light."""
return self._device.rgb
@property
def color_temp_kelvin(self) -> int | None:
"""Return the color temperature of the light."""
return self._device.color_temp
@property
def is_on(self) -> bool:
"""Return true if the light is on."""
return self._device.on
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
_LOGGER.debug("Turning on light %s, address %s", kwargs, self._address)
brightness = round(
cast(int, kwargs.get(ATTR_BRIGHTNESS, self.brightness)) / 255 * 100
)
@ -82,6 +121,10 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity):
kelvin = max(2700, min(6500, kwargs[ATTR_COLOR_TEMP_KELVIN]))
await self._device.set_color_temp(brightness, kelvin)
return
if ATTR_EFFECT in kwargs:
effect = kwargs[ATTR_EFFECT]
await self._device.set_effect(effect)
return
if ATTR_RGB_COLOR in kwargs:
rgb = kwargs[ATTR_RGB_COLOR]
await self._device.set_rgb(brightness, rgb[0], rgb[1], rgb[2])
@ -94,4 +137,5 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity):
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
_LOGGER.debug("Turning off light %s, address %s", kwargs, self._address)
await self._device.turn_off()

View File

@ -246,6 +246,35 @@
}
}
}
},
"light": {
"light": {
"state_attributes": {
"effect": {
"state": {
"christmas": "Christmas",
"halloween": "Halloween",
"sunset": "Sunset",
"vitality": "Vitality",
"flashing": "Flashing",
"strobe": "Strobe",
"fade": "Fade",
"smooth": "Smooth",
"forest": "Forest",
"ocean": "Ocean",
"autumn": "Autumn",
"cool": "Cool",
"flow": "Flow",
"relax": "Relax",
"modern": "Modern",
"rose": "Rose",
"colorful": "Colorful",
"flickering": "Flickering",
"breathing": "Breathing"
}
}
}
}
}
},
"exceptions": {

View File

@ -883,3 +883,61 @@ EVAPORATIVE_HUMIDIFIER_SERVICE_INFO = BluetoothServiceInfoBleak(
connectable=True,
tx_power=-127,
)
BULB_SERVICE_INFO = BluetoothServiceInfoBleak(
name="Bulb",
manufacturer_data={
2409: b"@L\xca\xa7_\x12\x02\x81\x12\x00\x00",
},
service_data={
"0000fd3d-0000-1000-8000-00805f9b34fb": b"u\x00d",
},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
source="local",
advertisement=generate_advertisement_data(
local_name="Bulb",
manufacturer_data={
2409: b"@L\xca\xa7_\x12\x02\x81\x12\x00\x00",
},
service_data={
"0000fd3d-0000-1000-8000-00805f9b34fb": b"u\x00d",
},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Bulb"),
time=0,
connectable=True,
tx_power=-127,
)
CEILING_LIGHT_SERVICE_INFO = BluetoothServiceInfoBleak(
name="Ceiling Light",
manufacturer_data={
2409: b"\xef\xfe\xfb\x9d\x10\xfe\n\x01\x18\xf3\xa4",
},
service_data={
"0000fd3d-0000-1000-8000-00805f9b34fb": b"q\x00",
},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
source="local",
advertisement=generate_advertisement_data(
local_name="Ceiling Light",
manufacturer_data={
2409: b"\xef\xfe\xfb\x9d\x10\xfe\n\x01\x18\xf3$",
},
service_data={
"0000fd3d-0000-1000-8000-00805f9b34fb": b"q\x00",
},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Ceiling Light"),
time=0,
connectable=True,
tx_power=-127,
)

View File

@ -5,12 +5,12 @@ from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from switchbot import ColorMode as switchbotColorMode
from switchbot.devices.device import SwitchbotOperationError
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_RGB_COLOR,
DOMAIN as LIGHT_DOMAIN,
SERVICE_TURN_OFF,
@ -20,89 +20,111 @@ from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from . import WOSTRIP_SERVICE_INFO
from . import BULB_SERVICE_INFO, CEILING_LIGHT_SERVICE_INFO, WOSTRIP_SERVICE_INFO
from tests.common import MockConfigEntry
from tests.components.bluetooth import inject_bluetooth_service_info
@pytest.mark.parametrize(
(
"service",
"service_data",
"mock_method",
"expected_args",
"color_modes",
"color_mode",
),
COMMON_PARAMETERS = (
"service",
"service_data",
"mock_method",
"expected_args",
)
TURN_ON_PARAMETERS = (
SERVICE_TURN_ON,
{},
"turn_on",
{},
)
TURN_OFF_PARAMETERS = (
SERVICE_TURN_OFF,
{},
"turn_off",
{},
)
SET_BRIGHTNESS_PARAMETERS = (
SERVICE_TURN_ON,
{ATTR_BRIGHTNESS: 128},
"set_brightness",
(round(128 / 255 * 100),),
)
SET_RGB_PARAMETERS = (
SERVICE_TURN_ON,
{ATTR_BRIGHTNESS: 128, ATTR_RGB_COLOR: (255, 0, 0)},
"set_rgb",
(round(128 / 255 * 100), 255, 0, 0),
)
SET_COLOR_TEMP_PARAMETERS = (
SERVICE_TURN_ON,
{ATTR_BRIGHTNESS: 128, ATTR_COLOR_TEMP_KELVIN: 4000},
"set_color_temp",
(round(128 / 255 * 100), 4000),
)
BULB_PARAMETERS = (
COMMON_PARAMETERS,
[
(
SERVICE_TURN_OFF,
{},
"turn_off",
(),
{switchbotColorMode.RGB},
switchbotColorMode.RGB,
),
TURN_ON_PARAMETERS,
TURN_OFF_PARAMETERS,
SET_BRIGHTNESS_PARAMETERS,
SET_RGB_PARAMETERS,
SET_COLOR_TEMP_PARAMETERS,
(
SERVICE_TURN_ON,
{},
"turn_on",
(),
{switchbotColorMode.RGB},
switchbotColorMode.RGB,
),
(
SERVICE_TURN_ON,
{ATTR_BRIGHTNESS: 128},
"set_brightness",
(round(128 / 255 * 100),),
{switchbotColorMode.RGB},
switchbotColorMode.RGB,
),
(
SERVICE_TURN_ON,
{ATTR_RGB_COLOR: (255, 0, 0)},
"set_rgb",
(round(255 / 255 * 100), 255, 0, 0),
{switchbotColorMode.RGB},
switchbotColorMode.RGB,
),
(
SERVICE_TURN_ON,
{ATTR_COLOR_TEMP_KELVIN: 4000},
"set_color_temp",
(100, 4000),
{switchbotColorMode.COLOR_TEMP},
switchbotColorMode.COLOR_TEMP,
{ATTR_EFFECT: "Breathing"},
"set_effect",
("Breathing",),
),
],
)
async def test_light_strip_services(
CEILING_LIGHT_PARAMETERS = (
COMMON_PARAMETERS,
[
TURN_ON_PARAMETERS,
TURN_OFF_PARAMETERS,
SET_BRIGHTNESS_PARAMETERS,
SET_COLOR_TEMP_PARAMETERS,
],
)
STRIP_LIGHT_PARAMETERS = (
COMMON_PARAMETERS,
[
TURN_ON_PARAMETERS,
TURN_OFF_PARAMETERS,
SET_BRIGHTNESS_PARAMETERS,
SET_RGB_PARAMETERS,
(
SERVICE_TURN_ON,
{ATTR_EFFECT: "Halloween"},
"set_effect",
("Halloween",),
),
],
)
@pytest.mark.parametrize(*BULB_PARAMETERS)
async def test_bulb_services(
hass: HomeAssistant,
mock_entry_factory: Callable[[str], MockConfigEntry],
service: str,
service_data: dict,
mock_method: str,
expected_args: Any,
color_modes: set | None,
color_mode: switchbotColorMode | None,
) -> None:
"""Test all SwitchBot light strip services with proper parameters."""
inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO)
"""Test all SwitchBot bulb services."""
inject_bluetooth_service_info(hass, BULB_SERVICE_INFO)
entry = mock_entry_factory(sensor_type="light_strip")
entry = mock_entry_factory(sensor_type="bulb")
entry.add_to_hass(hass)
entity_id = "light.test_name"
mocked_instance = AsyncMock(return_value=True)
with patch.multiple(
"homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip",
color_modes=color_modes,
color_mode=color_mode,
update=AsyncMock(return_value=None),
"homeassistant.components.switchbot.light.switchbot.SwitchbotBulb",
**{mock_method: mocked_instance},
update=AsyncMock(return_value=None),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@ -117,79 +139,173 @@ async def test_light_strip_services(
mocked_instance.assert_awaited_once_with(*expected_args)
@pytest.mark.parametrize(
("exception", "error_message"),
[
(
SwitchbotOperationError("Operation failed"),
"An error occurred while performing the action: Operation failed",
),
],
)
@pytest.mark.parametrize(
("service", "service_data", "mock_method", "color_modes", "color_mode"),
[
(
SERVICE_TURN_ON,
{},
"turn_on",
{switchbotColorMode.RGB},
switchbotColorMode.RGB,
),
(
SERVICE_TURN_OFF,
{},
"turn_off",
{switchbotColorMode.RGB},
switchbotColorMode.RGB,
),
(
SERVICE_TURN_ON,
{ATTR_BRIGHTNESS: 128},
"set_brightness",
{switchbotColorMode.RGB},
switchbotColorMode.RGB,
),
(
SERVICE_TURN_ON,
{ATTR_RGB_COLOR: (255, 0, 0)},
"set_rgb",
{switchbotColorMode.RGB},
switchbotColorMode.RGB,
),
(
SERVICE_TURN_ON,
{ATTR_COLOR_TEMP_KELVIN: 4000},
"set_color_temp",
{switchbotColorMode.COLOR_TEMP},
switchbotColorMode.COLOR_TEMP,
),
],
)
async def test_exception_handling_light_service(
@pytest.mark.parametrize(*BULB_PARAMETERS)
async def test_bulb_services_exception(
hass: HomeAssistant,
mock_entry_factory: Callable[[str], MockConfigEntry],
service: str,
service_data: dict,
mock_method: str,
color_modes: set | None,
color_mode: switchbotColorMode | None,
exception: Exception,
error_message: str,
expected_args: Any,
) -> None:
"""Test exception handling for light service with exception."""
inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO)
"""Test all SwitchBot bulb services with exception."""
inject_bluetooth_service_info(hass, BULB_SERVICE_INFO)
entry = mock_entry_factory(sensor_type="light_strip")
entry = mock_entry_factory(sensor_type="bulb")
entry.add_to_hass(hass)
entity_id = "light.test_name"
exception = SwitchbotOperationError("Operation failed")
error_message = "An error occurred while performing the action: Operation failed"
with patch.multiple(
"homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip",
color_modes=color_modes,
color_mode=color_mode,
update=AsyncMock(return_value=None),
"homeassistant.components.switchbot.light.switchbot.SwitchbotBulb",
**{mock_method: AsyncMock(side_effect=exception)},
update=AsyncMock(return_value=None),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError, match=error_message):
await hass.services.async_call(
LIGHT_DOMAIN,
service,
{**service_data, ATTR_ENTITY_ID: entity_id},
blocking=True,
)
@pytest.mark.parametrize(*CEILING_LIGHT_PARAMETERS)
async def test_ceiling_light_services(
hass: HomeAssistant,
mock_entry_factory: Callable[[str], MockConfigEntry],
service: str,
service_data: dict,
mock_method: str,
expected_args: Any,
) -> None:
"""Test all SwitchBot ceiling light services."""
inject_bluetooth_service_info(hass, CEILING_LIGHT_SERVICE_INFO)
entry = mock_entry_factory(sensor_type="ceiling_light")
entry.add_to_hass(hass)
entity_id = "light.test_name"
mocked_instance = AsyncMock(return_value=True)
with patch.multiple(
"homeassistant.components.switchbot.light.switchbot.SwitchbotCeilingLight",
**{mock_method: mocked_instance},
update=AsyncMock(return_value=None),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
LIGHT_DOMAIN,
service,
{**service_data, ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mocked_instance.assert_awaited_once_with(*expected_args)
@pytest.mark.parametrize(*CEILING_LIGHT_PARAMETERS)
async def test_ceiling_light_services_exception(
hass: HomeAssistant,
mock_entry_factory: Callable[[str], MockConfigEntry],
service: str,
service_data: dict,
mock_method: str,
expected_args: Any,
) -> None:
"""Test all SwitchBot ceiling light services with exception."""
inject_bluetooth_service_info(hass, CEILING_LIGHT_SERVICE_INFO)
entry = mock_entry_factory(sensor_type="ceiling_light")
entry.add_to_hass(hass)
entity_id = "light.test_name"
exception = SwitchbotOperationError("Operation failed")
error_message = "An error occurred while performing the action: Operation failed"
with patch.multiple(
"homeassistant.components.switchbot.light.switchbot.SwitchbotCeilingLight",
**{mock_method: AsyncMock(side_effect=exception)},
update=AsyncMock(return_value=None),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
with pytest.raises(HomeAssistantError, match=error_message):
await hass.services.async_call(
LIGHT_DOMAIN,
service,
{**service_data, ATTR_ENTITY_ID: entity_id},
blocking=True,
)
@pytest.mark.parametrize(*STRIP_LIGHT_PARAMETERS)
async def test_strip_light_services(
hass: HomeAssistant,
mock_entry_factory: Callable[[str], MockConfigEntry],
service: str,
service_data: dict,
mock_method: str,
expected_args: Any,
) -> None:
"""Test all SwitchBot strip light services."""
inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO)
entry = mock_entry_factory(sensor_type="light_strip")
entry.add_to_hass(hass)
entity_id = "light.test_name"
mocked_instance = AsyncMock(return_value=True)
with patch.multiple(
"homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip",
**{mock_method: mocked_instance},
update=AsyncMock(return_value=None),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
LIGHT_DOMAIN,
service,
{**service_data, ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mocked_instance.assert_awaited_once_with(*expected_args)
@pytest.mark.parametrize(*STRIP_LIGHT_PARAMETERS)
async def test_strip_light_services_exception(
hass: HomeAssistant,
mock_entry_factory: Callable[[str], MockConfigEntry],
service: str,
service_data: dict,
mock_method: str,
expected_args: Any,
) -> None:
"""Test all SwitchBot strip light services with exception."""
inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO)
entry = mock_entry_factory(sensor_type="light_strip")
entry.add_to_hass(hass)
entity_id = "light.test_name"
exception = SwitchbotOperationError("Operation failed")
error_message = "An error occurred while performing the action: Operation failed"
with patch.multiple(
"homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip",
**{mock_method: AsyncMock(side_effect=exception)},
update=AsyncMock(return_value=None),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()