Add Shelly RGB/RGBW profiles support (#113808)

* Add Shelly RGB/RGBW profiles support

* Update homeassistant/components/shelly/light.py

Co-authored-by: Robert Svensson <Kane610@users.noreply.github.com>

* Use walrus in rgbw_key_ids

* Use walrus in light_key_ids

---------

Co-authored-by: Robert Svensson <Kane610@users.noreply.github.com>
This commit is contained in:
Shay Levy 2024-03-21 21:04:50 +02:00 committed by GitHub
parent 8728057b1b
commit 63275d61a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 227 additions and 39 deletions

View File

@ -35,8 +35,11 @@ DATA_CONFIG_ENTRY: Final = "config_entry"
CONF_COAP_PORT: Final = "coap_port"
FIRMWARE_PATTERN: Final = re.compile(r"^(\d{8})")
# max light transition time in milliseconds
MAX_TRANSITION_TIME: Final = 5000
# max BLOCK light transition time in milliseconds (min=0)
BLOCK_MAX_TRANSITION_TIME_MS: Final = 5000
# min RPC light transition time in seconds (max=10800, limited by light entity to 6553)
RPC_MIN_TRANSITION_TIME_SEC = 0.5
RGBW_MODELS: Final = (
MODEL_BULB,

View File

@ -24,14 +24,15 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
BLOCK_MAX_TRANSITION_TIME_MS,
DUAL_MODE_LIGHT_MODELS,
KELVIN_MAX_VALUE,
KELVIN_MIN_VALUE_COLOR,
KELVIN_MIN_VALUE_WHITE,
LOGGER,
MAX_TRANSITION_TIME,
MODELS_SUPPORTING_LIGHT_TRANSITION,
RGBW_MODELS,
RPC_MIN_TRANSITION_TIME_SEC,
SHBLB_1_RGB_EFFECTS,
STANDARD_RGB_EFFECTS,
)
@ -116,9 +117,16 @@ def async_setup_rpc_entry(
)
return
light_key_ids = get_rpc_key_ids(coordinator.device.status, "light")
if light_key_ids:
if light_key_ids := get_rpc_key_ids(coordinator.device.status, "light"):
async_add_entities(RpcShellyLight(coordinator, id_) for id_ in light_key_ids)
return
if rgb_key_ids := get_rpc_key_ids(coordinator.device.status, "rgb"):
async_add_entities(RpcShellyRgbLight(coordinator, id_) for id_ in rgb_key_ids)
return
if rgbw_key_ids := get_rpc_key_ids(coordinator.device.status, "rgbw"):
async_add_entities(RpcShellyRgbwLight(coordinator, id_) for id_ in rgbw_key_ids)
class BlockShellyLight(ShellyBlockEntity, LightEntity):
@ -280,7 +288,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity):
if ATTR_TRANSITION in kwargs:
params["transition"] = min(
int(kwargs[ATTR_TRANSITION] * 1000), MAX_TRANSITION_TIME
int(kwargs[ATTR_TRANSITION] * 1000), BLOCK_MAX_TRANSITION_TIME_MS
)
if ATTR_BRIGHTNESS in kwargs and brightness_supported(supported_color_modes):
@ -352,7 +360,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity):
if ATTR_TRANSITION in kwargs:
params["transition"] = min(
int(kwargs[ATTR_TRANSITION] * 1000), MAX_TRANSITION_TIME
int(kwargs[ATTR_TRANSITION] * 1000), BLOCK_MAX_TRANSITION_TIME_MS
)
self.control_result = await self.set_state(**params)
@ -366,40 +374,14 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity):
super()._update_callback()
class RpcShellySwitchAsLight(ShellyRpcEntity, LightEntity):
"""Entity that controls a relay as light on RPC based Shelly devices."""
class RpcShellyLightBase(ShellyRpcEntity, LightEntity):
"""Base Entity for RPC based Shelly devices."""
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
_component: str = "Light"
def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None:
"""Initialize light."""
super().__init__(coordinator, f"switch:{id_}")
self._id = id_
@property
def is_on(self) -> bool:
"""If light is on."""
return bool(self.status["output"])
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on light."""
await self.call_rpc("Switch.Set", {"id": self._id, "on": True})
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off light."""
await self.call_rpc("Switch.Set", {"id": self._id, "on": False})
class RpcShellyLight(ShellyRpcEntity, LightEntity):
"""Entity that controls a light on RPC based Shelly devices."""
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None:
"""Initialize light."""
super().__init__(coordinator, f"light:{id_}")
super().__init__(coordinator, f"{self._component.lower()}:{id_}")
self._id = id_
@property
@ -412,6 +394,16 @@ class RpcShellyLight(ShellyRpcEntity, LightEntity):
"""Return the brightness of this light between 0..255."""
return percentage_to_brightness(self.status["brightness"])
@property
def rgb_color(self) -> tuple[int, int, int]:
"""Return the rgb color value [int, int, int]."""
return cast(tuple, self.status["rgb"])
@property
def rgbw_color(self) -> tuple[int, int, int, int]:
"""Return the rgbw color value [int, int, int, int]."""
return (*self.status["rgb"], self.status["white"])
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on light."""
params: dict[str, Any] = {"id": self._id, "on": True}
@ -419,8 +411,66 @@ class RpcShellyLight(ShellyRpcEntity, LightEntity):
if ATTR_BRIGHTNESS in kwargs:
params["brightness"] = brightness_to_percentage(kwargs[ATTR_BRIGHTNESS])
await self.call_rpc("Light.Set", params)
if ATTR_TRANSITION in kwargs:
params["transition_duration"] = max(
kwargs[ATTR_TRANSITION], RPC_MIN_TRANSITION_TIME_SEC
)
if ATTR_RGB_COLOR in kwargs:
params["rgb"] = list(kwargs[ATTR_RGB_COLOR])
if ATTR_RGBW_COLOR in kwargs:
params["rgb"] = list(kwargs[ATTR_RGBW_COLOR][:-1])
params["white"] = kwargs[ATTR_RGBW_COLOR][-1]
await self.call_rpc(f"{self._component}.Set", params)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off light."""
await self.call_rpc("Light.Set", {"id": self._id, "on": False})
params: dict[str, Any] = {"id": self._id, "on": False}
if ATTR_TRANSITION in kwargs:
params["transition_duration"] = max(
kwargs[ATTR_TRANSITION], RPC_MIN_TRANSITION_TIME_SEC
)
await self.call_rpc(f"{self._component}.Set", params)
class RpcShellySwitchAsLight(RpcShellyLightBase):
"""Entity that controls a relay as light on RPC based Shelly devices."""
_component = "Switch"
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
class RpcShellyLight(RpcShellyLightBase):
"""Entity that controls a light on RPC based Shelly devices."""
_component = "Light"
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
_attr_supported_features = LightEntityFeature.TRANSITION
class RpcShellyRgbLight(RpcShellyLightBase):
"""Entity that controls a RGB light on RPC based Shelly devices."""
_component = "RGB"
_attr_color_mode = ColorMode.RGB
_attr_supported_color_modes = {ColorMode.RGB}
_attr_supported_features = LightEntityFeature.TRANSITION
class RpcShellyRgbwLight(RpcShellyLightBase):
"""Entity that controls a RGBW light on RPC based Shelly devices."""
_component = "RGBW"
_attr_color_mode = ColorMode.RGBW
_attr_supported_color_modes = {ColorMode.RGBW}
_attr_supported_features = LightEntityFeature.TRANSITION

View File

@ -169,6 +169,8 @@ MOCK_CONFIG = {
"input:1": {"id": 1, "type": "analog", "enable": True},
"input:2": {"id": 2, "name": "Gas", "type": "count", "enable": True},
"light:0": {"name": "test light_0"},
"rgb:0": {"name": "test rgb_0"},
"rgbw:0": {"name": "test rgbw_0"},
"switch:0": {"name": "test switch_0"},
"cover:0": {"name": "test cover_0"},
"thermostat:0": {
@ -223,6 +225,8 @@ MOCK_STATUS_RPC = {
"input:1": {"id": 1, "percent": 89, "xpercent": 8.9},
"input:2": {"id": 2, "counts": {"total": 56174, "xtotal": 561.74}},
"light:0": {"output": True, "brightness": 53.0},
"rgb:0": {"output": True, "brightness": 53.0, "rgb": [45, 55, 65]},
"rgbw:0": {"output": True, "brightness": 53.0, "rgb": [21, 22, 23], "white": 120},
"cloud": {"connected": False},
"cover:0": {
"state": "stopped",

View File

@ -539,6 +539,137 @@ async def test_rpc_light(
assert state.state == STATE_ON
assert state.attributes[ATTR_BRIGHTNESS] == 33
# Turn on, transition = 10.1
mock_rpc_device.call_rpc.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id, ATTR_TRANSITION: 10.1},
blocking=True,
)
mock_rpc_device.mock_update()
mock_rpc_device.call_rpc.assert_called_once_with(
"Light.Set", {"id": 0, "on": True, "transition_duration": 10.1}
)
state = hass.states.get(entity_id)
assert state.state == STATE_ON
# Turn off, transition = 0.4, should be limited to 0.5
mock_rpc_device.call_rpc.reset_mock()
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id, ATTR_TRANSITION: 0.4},
blocking=True,
)
mutate_rpc_device_status(monkeypatch, mock_rpc_device, "light:0", "output", False)
mock_rpc_device.mock_update()
mock_rpc_device.call_rpc.assert_called_once_with(
"Light.Set", {"id": 0, "on": False, "transition_duration": 0.5}
)
state = hass.states.get(entity_id)
assert state.state == STATE_OFF
entry = entity_registry.async_get(entity_id)
assert entry
assert entry.unique_id == "123456789ABC-light:0"
async def test_rpc_device_rgb_profile(
hass: HomeAssistant,
mock_rpc_device: Mock,
entity_registry: EntityRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test RPC device in RGB profile."""
monkeypatch.delitem(mock_rpc_device.status, "light:0")
monkeypatch.delitem(mock_rpc_device.status, "rgbw:0")
entity_id = "light.test_rgb_0"
await init_integration(hass, 2)
# Test initial
state = hass.states.get(entity_id)
attributes = state.attributes
assert state.state == STATE_ON
assert attributes[ATTR_RGB_COLOR] == (45, 55, 65)
assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGB]
assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION
# Turn on, RGB = [70, 80, 90]
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id, ATTR_RGB_COLOR: [70, 80, 90]},
blocking=True,
)
mutate_rpc_device_status(monkeypatch, mock_rpc_device, "rgb:0", "rgb", [70, 80, 90])
mock_rpc_device.mock_update()
mock_rpc_device.call_rpc.assert_called_once_with(
"RGB.Set", {"id": 0, "on": True, "rgb": [70, 80, 90]}
)
state = hass.states.get(entity_id)
attributes = state.attributes
assert state.state == STATE_ON
assert attributes[ATTR_COLOR_MODE] == ColorMode.RGB
assert attributes[ATTR_RGB_COLOR] == (70, 80, 90)
entry = entity_registry.async_get(entity_id)
assert entry
assert entry.unique_id == "123456789ABC-rgb:0"
async def test_rpc_device_rgbw_profile(
hass: HomeAssistant,
mock_rpc_device: Mock,
entity_registry: EntityRegistry,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test RPC device in RGBW profile."""
monkeypatch.delitem(mock_rpc_device.status, "light:0")
monkeypatch.delitem(mock_rpc_device.status, "rgb:0")
entity_id = "light.test_rgbw_0"
await init_integration(hass, 2)
# Test initial
state = hass.states.get(entity_id)
attributes = state.attributes
assert state.state == STATE_ON
assert attributes[ATTR_RGBW_COLOR] == (21, 22, 23, 120)
assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBW]
assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION
# Turn on, RGBW = [72, 82, 92, 128]
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id, ATTR_RGBW_COLOR: [72, 82, 92, 128]},
blocking=True,
)
mutate_rpc_device_status(
monkeypatch, mock_rpc_device, "rgbw:0", "rgb", [72, 82, 92]
)
mutate_rpc_device_status(monkeypatch, mock_rpc_device, "rgbw:0", "white", 128)
mock_rpc_device.mock_update()
mock_rpc_device.call_rpc.assert_called_once_with(
"RGBW.Set", {"id": 0, "on": True, "rgb": [72, 82, 92], "white": 128}
)
state = hass.states.get(entity_id)
attributes = state.attributes
assert state.state == STATE_ON
assert attributes[ATTR_COLOR_MODE] == ColorMode.RGBW
assert attributes[ATTR_RGBW_COLOR] == (72, 82, 92, 128)
entry = entity_registry.async_get(entity_id)
assert entry
assert entry.unique_id == "123456789ABC-rgbw:0"