diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index b50f2231f46..748d199e93f 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -44,6 +44,8 @@ MULTI_COLOR_MAP = { ColorComponent.PURPLE: "purple", } +TRANSITION_DURATION = "transitionDuration" + async def async_setup_entry( hass: HomeAssistant, @@ -109,8 +111,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._supported_color_modes = set() # get additional (optional) values and set features - self._target_value = self.get_zwave_value("targetValue") - self._dimming_duration = self.get_zwave_value("duration") + self._target_brightness = self.get_zwave_value( + "targetValue", add_to_watched_value_ids=False + ) + self._target_color = self.get_zwave_value( + "targetColor", CommandClass.SWITCH_COLOR, add_to_watched_value_ids=False + ) + self._calculate_color_values() if self._supports_rgbw: self._supported_color_modes.add(COLOR_MODE_RGBW) @@ -123,7 +130,17 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # Entity class attributes self._attr_supported_features = 0 - if self._dimming_duration is not None: + self.supports_brightness_transition = bool( + self._target_brightness is not None + and TRANSITION_DURATION + in self._target_brightness.metadata.value_change_options + ) + self.supports_color_transition = bool( + self._target_color is not None + and TRANSITION_DURATION in self._target_color.metadata.value_change_options + ) + + if self.supports_brightness_transition or self.supports_color_transition: self._attr_supported_features |= SUPPORT_TRANSITION @callback @@ -183,6 +200,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" + + transition = kwargs.get(ATTR_TRANSITION) + # RGB/HS color hs_color = kwargs.get(ATTR_HS_COLOR) if hs_color is not None and self._supports_color: @@ -196,7 +216,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # turn of white leds when setting rgb colors[ColorComponent.WARM_WHITE] = 0 colors[ColorComponent.COLD_WHITE] = 0 - await self._async_set_colors(colors) + await self._async_set_colors(colors, transition) # Color temperature color_temp = kwargs.get(ATTR_COLOR_TEMP) @@ -222,7 +242,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): ColorComponent.BLUE: 0, ColorComponent.WARM_WHITE: warm, ColorComponent.COLD_WHITE: cold, - } + }, + transition, ) # RGBW @@ -238,18 +259,18 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if self._cold_white: rgbw_channels[ColorComponent.COLD_WHITE] = rgbw[3] - await self._async_set_colors(rgbw_channels) + await self._async_set_colors(rgbw_channels, transition) # set brightness - await self._async_set_brightness( - kwargs.get(ATTR_BRIGHTNESS), kwargs.get(ATTR_TRANSITION) - ) + await self._async_set_brightness(kwargs.get(ATTR_BRIGHTNESS), transition) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION)) - async def _async_set_colors(self, colors: dict[ColorComponent, int]) -> None: + async def _async_set_colors( + self, colors: dict[ColorComponent, int], transition: float | None = None + ) -> None: """Set (multiple) defined colors to given value(s).""" # prefer the (new) combined color property # https://github.com/zwave-js/node-zwave-js/pull/1782 @@ -258,21 +279,36 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): CommandClass.SWITCH_COLOR, value_property_key=None, ) + zwave_transition = None + + if self.supports_color_transition: + if transition is not None: + zwave_transition = {TRANSITION_DURATION: f"{int(transition)}s"} + else: + zwave_transition = {TRANSITION_DURATION: "default"} + if combined_color_val and isinstance(combined_color_val.value, dict): colors_dict = {} for color, value in colors.items(): color_name = MULTI_COLOR_MAP[color] colors_dict[color_name] = value # set updated color object - await self.info.node.async_set_value(combined_color_val, colors_dict) + await self.info.node.async_set_value( + combined_color_val, colors_dict, zwave_transition + ) return # fallback to setting the color(s) one by one if multicolor fails # not sure this is needed at all, but just in case for color, value in colors.items(): - await self._async_set_color(color, value) + await self._async_set_color(color, value, zwave_transition) - async def _async_set_color(self, color: ColorComponent, new_value: int) -> None: + async def _async_set_color( + self, + color: ColorComponent, + new_value: int, + transition: dict[str, str] | None = None, + ) -> None: """Set defined color to given value.""" # actually set the new color value target_zwave_value = self.get_zwave_value( @@ -283,10 +319,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if target_zwave_value is None: # guard for unsupported color return - await self.info.node.async_set_value(target_zwave_value, new_value) + await self.info.node.async_set_value(target_zwave_value, new_value, transition) async def _async_set_brightness( - self, brightness: int | None, transition: int | None = None + self, brightness: int | None, transition: float | None = None ) -> None: """Set new brightness to light.""" if brightness is None: @@ -297,40 +333,17 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): zwave_brightness = byte_to_zwave_brightness(brightness) # set transition value before sending new brightness - await self._async_set_transition_duration(transition) - # setting a value requires setting targetValue - await self.info.node.async_set_value(self._target_value, zwave_brightness) - - async def _async_set_transition_duration(self, duration: int | None = None) -> None: - """Set the transition time for the brightness value.""" - if self._dimming_duration is None: - return - # pylint: disable=fixme,unreachable - # TODO: setting duration needs to be fixed upstream - # https://github.com/zwave-js/node-zwave-js/issues/1321 - return - - if duration is None: # type: ignore - # no transition specified by user, use defaults - duration = 7621 # anything over 7620 uses the factory default - else: # pragma: no cover - # transition specified by user - transition = duration - if transition <= 127: - duration = transition + zwave_transition = None + if self.supports_brightness_transition: + if transition is not None: + zwave_transition = {TRANSITION_DURATION: f"{int(transition)}s"} else: - minutes = round(transition / 60) - LOGGER.debug( - "Transition rounded to %d minutes for %s", - minutes, - self.entity_id, - ) - duration = minutes + 128 + zwave_transition = {TRANSITION_DURATION: "default"} - # only send value if it differs from current - # this prevents sending a command for nothing - if self._dimming_duration.value != duration: # pragma: no cover - await self.info.node.async_set_value(self._dimming_duration, duration) + # setting a value requires setting targetValue + await self.info.node.async_set_value( + self._target_brightness, zwave_brightness, zwave_transition + ) @callback def _calculate_color_values(self) -> None: diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 2d9cf06b095..43250c3477d 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -12,6 +12,7 @@ from homeassistant.components.light import ( ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_SUPPORTED_COLOR_MODES, + ATTR_TRANSITION, SUPPORT_TRANSITION, ) from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON @@ -62,12 +63,47 @@ async def test_light(hass, client, bulb_6_multi_color, integration): "readable": True, "writeable": True, "label": "Target value", + "valueChangeOptions": ["transitionDuration"], }, } assert args["value"] == 255 client.async_send_command.reset_mock() + # Test turning on with transition + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, ATTR_TRANSITION: 10}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 39 + assert args["valueId"] == { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "metadata": { + "label": "Target value", + "max": 99, + "min": 0, + "type": "number", + "readable": True, + "writeable": True, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + }, + } + assert args["value"] == 255 + assert args["options"]["transitionDuration"] == "10s" + + client.async_send_command.reset_mock() + # Test brightness update from value updated event event = Event( type="value updated", @@ -133,9 +169,49 @@ async def test_light(hass, client, bulb_6_multi_color, integration): "readable": True, "writeable": True, "label": "Target value", + "valueChangeOptions": ["transitionDuration"], }, } assert args["value"] == 50 + assert args["options"]["transitionDuration"] == "default" + + client.async_send_command.reset_mock() + + # Test turning on with brightness and transition + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, + ATTR_BRIGHTNESS: 129, + ATTR_TRANSITION: 20, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 39 + assert args["valueId"] == { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "metadata": { + "label": "Target value", + "max": 99, + "min": 0, + "type": "number", + "readable": True, + "writeable": True, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + }, + } + assert args["value"] == 50 + assert args["options"]["transitionDuration"] == "20s" client.async_send_command.reset_mock() @@ -256,6 +332,23 @@ async def test_light(hass, client, bulb_6_multi_color, integration): client.async_send_command.reset_mock() + # Test turning on with rgb color and transition + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, + ATTR_RGB_COLOR: (128, 76, 255), + ATTR_TRANSITION: 20, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 6 + args = client.async_send_command.call_args_list[5][0][0] + assert args["options"]["transitionDuration"] == "20s" + client.async_send_command.reset_mock() + # Test turning on with color temp await hass.services.async_call( "light", @@ -377,6 +470,24 @@ async def test_light(hass, client, bulb_6_multi_color, integration): client.async_send_command.reset_mock() + # Test turning on with color temp and transition + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, + ATTR_COLOR_TEMP: 170, + ATTR_TRANSITION: 35, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 6 + args = client.async_send_command.call_args_list[5][0][0] + assert args["options"]["transitionDuration"] == "35s" + + client.async_send_command.reset_mock() + # Test turning off await hass.services.async_call( "light", @@ -403,6 +514,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): "readable": True, "writeable": True, "label": "Target value", + "valueChangeOptions": ["transitionDuration"], }, } assert args["value"] == 0 @@ -480,6 +592,7 @@ async def test_rgbw_light(hass, client, zen_31, integration): "readable": True, "writeable": True, "label": "Target value", + "valueChangeOptions": ["transitionDuration"], }, "value": 59, } diff --git a/tests/fixtures/zwave_js/bulb_6_multi_color_state.json b/tests/fixtures/zwave_js/bulb_6_multi_color_state.json index 64bfecfb20b..58608131e90 100644 --- a/tests/fixtures/zwave_js/bulb_6_multi_color_state.json +++ b/tests/fixtures/zwave_js/bulb_6_multi_color_state.json @@ -75,10 +75,13 @@ "type": "number", "readable": true, "writeable": true, + "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, - "max": 99, - "label": "Target value" - } + "max": 99 + } }, { "commandClassName": "Multilevel Switch", @@ -339,6 +342,23 @@ "description": "The target value of the Blue color." } }, + { + "endpoint": 0, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target Color", + "valueChangeOptions": [ + "transitionDuration" + ] + } + }, { "commandClassName": "Configuration", "commandClass": 112, diff --git a/tests/fixtures/zwave_js/light_color_null_values_state.json b/tests/fixtures/zwave_js/light_color_null_values_state.json index 46bc9f29b06..f60b61c7a5a 100644 --- a/tests/fixtures/zwave_js/light_color_null_values_state.json +++ b/tests/fixtures/zwave_js/light_color_null_values_state.json @@ -99,6 +99,9 @@ "readable": true, "writeable": true, "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, "max": 99 } diff --git a/tests/fixtures/zwave_js/zen_31_state.json b/tests/fixtures/zwave_js/zen_31_state.json index d322279fbfa..f0c3c1759eb 100644 --- a/tests/fixtures/zwave_js/zen_31_state.json +++ b/tests/fixtures/zwave_js/zen_31_state.json @@ -1430,6 +1430,9 @@ "readable": true, "writeable": true, "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, "max": 99 }, @@ -1982,6 +1985,9 @@ "readable": true, "writeable": true, "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, "max": 99 }, @@ -2100,6 +2106,9 @@ "readable": true, "writeable": true, "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, "max": 99 }, @@ -2218,6 +2227,9 @@ "readable": true, "writeable": true, "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, "max": 99 }, @@ -2336,6 +2348,9 @@ "readable": true, "writeable": true, "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, "max": 99 },