Add transition support to zwave_js lights (#52160)

* Add transition support to zwave_js lights

* Add transition support to color_switch lights

* simplify and add tests

* fix logic

* add check for color transition to add SUPPORT_TRANSITON supported features

* Use new metadata property

* Use new metadata property

* update tests and device state dump json files

* fix file perms

* update tests and fixtures with new metadata

* update test

* update test

* update tests for color transitions

* check for color tansitions as well

* more tests

* fix color transtions

* remove unneed default

* set add_to_watched_value_ids to false

* set transition default

* properly set default

* update tests

* make sure transition is an int

* suggested changes

* Update homeassistant/components/zwave_js/light.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zwave_js/light.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zwave_js/light.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zwave_js/light.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zwave_js/light.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* formatting

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Chris 2021-07-09 09:15:20 -07:00 committed by GitHub
parent 1e6229dd7b
commit 92ab471f7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 214 additions and 50 deletions

View File

@ -44,6 +44,8 @@ MULTI_COLOR_MAP = {
ColorComponent.PURPLE: "purple", ColorComponent.PURPLE: "purple",
} }
TRANSITION_DURATION = "transitionDuration"
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -109,8 +111,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
self._supported_color_modes = set() self._supported_color_modes = set()
# get additional (optional) values and set features # get additional (optional) values and set features
self._target_value = self.get_zwave_value("targetValue") self._target_brightness = self.get_zwave_value(
self._dimming_duration = self.get_zwave_value("duration") "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() self._calculate_color_values()
if self._supports_rgbw: if self._supports_rgbw:
self._supported_color_modes.add(COLOR_MODE_RGBW) self._supported_color_modes.add(COLOR_MODE_RGBW)
@ -123,7 +130,17 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
# Entity class attributes # Entity class attributes
self._attr_supported_features = 0 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 self._attr_supported_features |= SUPPORT_TRANSITION
@callback @callback
@ -183,6 +200,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on.""" """Turn the device on."""
transition = kwargs.get(ATTR_TRANSITION)
# RGB/HS color # RGB/HS color
hs_color = kwargs.get(ATTR_HS_COLOR) hs_color = kwargs.get(ATTR_HS_COLOR)
if hs_color is not None and self._supports_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 # turn of white leds when setting rgb
colors[ColorComponent.WARM_WHITE] = 0 colors[ColorComponent.WARM_WHITE] = 0
colors[ColorComponent.COLD_WHITE] = 0 colors[ColorComponent.COLD_WHITE] = 0
await self._async_set_colors(colors) await self._async_set_colors(colors, transition)
# Color temperature # Color temperature
color_temp = kwargs.get(ATTR_COLOR_TEMP) color_temp = kwargs.get(ATTR_COLOR_TEMP)
@ -222,7 +242,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
ColorComponent.BLUE: 0, ColorComponent.BLUE: 0,
ColorComponent.WARM_WHITE: warm, ColorComponent.WARM_WHITE: warm,
ColorComponent.COLD_WHITE: cold, ColorComponent.COLD_WHITE: cold,
} },
transition,
) )
# RGBW # RGBW
@ -238,18 +259,18 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
if self._cold_white: if self._cold_white:
rgbw_channels[ColorComponent.COLD_WHITE] = rgbw[3] rgbw_channels[ColorComponent.COLD_WHITE] = rgbw[3]
await self._async_set_colors(rgbw_channels) await self._async_set_colors(rgbw_channels, transition)
# set brightness # set brightness
await self._async_set_brightness( await self._async_set_brightness(kwargs.get(ATTR_BRIGHTNESS), transition)
kwargs.get(ATTR_BRIGHTNESS), kwargs.get(ATTR_TRANSITION)
)
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off.""" """Turn the light off."""
await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION)) 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).""" """Set (multiple) defined colors to given value(s)."""
# prefer the (new) combined color property # prefer the (new) combined color property
# https://github.com/zwave-js/node-zwave-js/pull/1782 # https://github.com/zwave-js/node-zwave-js/pull/1782
@ -258,21 +279,36 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
CommandClass.SWITCH_COLOR, CommandClass.SWITCH_COLOR,
value_property_key=None, 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): if combined_color_val and isinstance(combined_color_val.value, dict):
colors_dict = {} colors_dict = {}
for color, value in colors.items(): for color, value in colors.items():
color_name = MULTI_COLOR_MAP[color] color_name = MULTI_COLOR_MAP[color]
colors_dict[color_name] = value colors_dict[color_name] = value
# set updated color object # 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 return
# fallback to setting the color(s) one by one if multicolor fails # fallback to setting the color(s) one by one if multicolor fails
# not sure this is needed at all, but just in case # not sure this is needed at all, but just in case
for color, value in colors.items(): 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.""" """Set defined color to given value."""
# actually set the new color value # actually set the new color value
target_zwave_value = self.get_zwave_value( target_zwave_value = self.get_zwave_value(
@ -283,10 +319,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
if target_zwave_value is None: if target_zwave_value is None:
# guard for unsupported color # guard for unsupported color
return 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( async def _async_set_brightness(
self, brightness: int | None, transition: int | None = None self, brightness: int | None, transition: float | None = None
) -> None: ) -> None:
"""Set new brightness to light.""" """Set new brightness to light."""
if brightness is None: if brightness is None:
@ -297,40 +333,17 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
zwave_brightness = byte_to_zwave_brightness(brightness) zwave_brightness = byte_to_zwave_brightness(brightness)
# set transition value before sending new brightness # set transition value before sending new brightness
await self._async_set_transition_duration(transition) zwave_transition = None
# setting a value requires setting targetValue if self.supports_brightness_transition:
await self.info.node.async_set_value(self._target_value, zwave_brightness) if transition is not None:
zwave_transition = {TRANSITION_DURATION: f"{int(transition)}s"}
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
else: else:
minutes = round(transition / 60) zwave_transition = {TRANSITION_DURATION: "default"}
LOGGER.debug(
"Transition rounded to %d minutes for %s",
minutes,
self.entity_id,
)
duration = minutes + 128
# only send value if it differs from current # setting a value requires setting targetValue
# this prevents sending a command for nothing await self.info.node.async_set_value(
if self._dimming_duration.value != duration: # pragma: no cover self._target_brightness, zwave_brightness, zwave_transition
await self.info.node.async_set_value(self._dimming_duration, duration) )
@callback @callback
def _calculate_color_values(self) -> None: def _calculate_color_values(self) -> None:

View File

@ -12,6 +12,7 @@ from homeassistant.components.light import (
ATTR_RGB_COLOR, ATTR_RGB_COLOR,
ATTR_RGBW_COLOR, ATTR_RGBW_COLOR,
ATTR_SUPPORTED_COLOR_MODES, ATTR_SUPPORTED_COLOR_MODES,
ATTR_TRANSITION,
SUPPORT_TRANSITION, SUPPORT_TRANSITION,
) )
from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON 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, "readable": True,
"writeable": True, "writeable": True,
"label": "Target value", "label": "Target value",
"valueChangeOptions": ["transitionDuration"],
}, },
} }
assert args["value"] == 255 assert args["value"] == 255
client.async_send_command.reset_mock() 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 # Test brightness update from value updated event
event = Event( event = Event(
type="value updated", type="value updated",
@ -133,9 +169,49 @@ async def test_light(hass, client, bulb_6_multi_color, integration):
"readable": True, "readable": True,
"writeable": True, "writeable": True,
"label": "Target value", "label": "Target value",
"valueChangeOptions": ["transitionDuration"],
}, },
} }
assert args["value"] == 50 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() 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() 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 # Test turning on with color temp
await hass.services.async_call( await hass.services.async_call(
"light", "light",
@ -377,6 +470,24 @@ async def test_light(hass, client, bulb_6_multi_color, integration):
client.async_send_command.reset_mock() 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 # Test turning off
await hass.services.async_call( await hass.services.async_call(
"light", "light",
@ -403,6 +514,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration):
"readable": True, "readable": True,
"writeable": True, "writeable": True,
"label": "Target value", "label": "Target value",
"valueChangeOptions": ["transitionDuration"],
}, },
} }
assert args["value"] == 0 assert args["value"] == 0
@ -480,6 +592,7 @@ async def test_rgbw_light(hass, client, zen_31, integration):
"readable": True, "readable": True,
"writeable": True, "writeable": True,
"label": "Target value", "label": "Target value",
"valueChangeOptions": ["transitionDuration"],
}, },
"value": 59, "value": 59,
} }

View File

@ -75,9 +75,12 @@
"type": "number", "type": "number",
"readable": true, "readable": true,
"writeable": true, "writeable": true,
"label": "Target value",
"valueChangeOptions": [
"transitionDuration"
],
"min": 0, "min": 0,
"max": 99, "max": 99
"label": "Target value"
} }
}, },
{ {
@ -339,6 +342,23 @@
"description": "The target value of the Blue color." "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", "commandClassName": "Configuration",
"commandClass": 112, "commandClass": 112,

View File

@ -99,6 +99,9 @@
"readable": true, "readable": true,
"writeable": true, "writeable": true,
"label": "Target value", "label": "Target value",
"valueChangeOptions": [
"transitionDuration"
],
"min": 0, "min": 0,
"max": 99 "max": 99
} }

View File

@ -1430,6 +1430,9 @@
"readable": true, "readable": true,
"writeable": true, "writeable": true,
"label": "Target value", "label": "Target value",
"valueChangeOptions": [
"transitionDuration"
],
"min": 0, "min": 0,
"max": 99 "max": 99
}, },
@ -1982,6 +1985,9 @@
"readable": true, "readable": true,
"writeable": true, "writeable": true,
"label": "Target value", "label": "Target value",
"valueChangeOptions": [
"transitionDuration"
],
"min": 0, "min": 0,
"max": 99 "max": 99
}, },
@ -2100,6 +2106,9 @@
"readable": true, "readable": true,
"writeable": true, "writeable": true,
"label": "Target value", "label": "Target value",
"valueChangeOptions": [
"transitionDuration"
],
"min": 0, "min": 0,
"max": 99 "max": 99
}, },
@ -2218,6 +2227,9 @@
"readable": true, "readable": true,
"writeable": true, "writeable": true,
"label": "Target value", "label": "Target value",
"valueChangeOptions": [
"transitionDuration"
],
"min": 0, "min": 0,
"max": 99 "max": 99
}, },
@ -2336,6 +2348,9 @@
"readable": true, "readable": true,
"writeable": true, "writeable": true,
"label": "Target value", "label": "Target value",
"valueChangeOptions": [
"transitionDuration"
],
"min": 0, "min": 0,
"max": 99 "max": 99
}, },