diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 53e827cea72..3580bcf9b38 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -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, diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 7c465e43db0..6c28023a5e3 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -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 diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index df6a5f41306..9e8dd3999a6 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -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", diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index 2c7eda3a1e0..cca318c364d 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -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"