Support Z-Wave JS dimming lights using color intensity (#122639)

* Z-Wave JS: support non-dimmable color lights

* remove black_is_off light, support on/off/color

* fix: tests for on/off light

* fix: typo

* remove commented out old test code

* add test for off and on

* support colored lights without separate brightness control

* add test for color-only light

* refactor: extract color only light

* fix: preserve color when changing brightness

* extend tests

* refactor again

* refactor scale check

* refactor: remove impossible check

* review feedback

* review feedback

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
AlCalzone 2024-08-29 00:01:53 +02:00 committed by GitHub
parent ada6b7875c
commit c7cfd56b72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 736 additions and 352 deletions

View File

@ -238,6 +238,12 @@ SWITCH_BINARY_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema(
command_class={CommandClass.SWITCH_BINARY}, property={CURRENT_VALUE_PROPERTY} command_class={CommandClass.SWITCH_BINARY}, property={CURRENT_VALUE_PROPERTY}
) )
COLOR_SWITCH_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema(
command_class={CommandClass.SWITCH_COLOR},
property={CURRENT_COLOR_PROPERTY},
property_key={None},
)
SIREN_TONE_SCHEMA = ZWaveValueDiscoverySchema( SIREN_TONE_SCHEMA = ZWaveValueDiscoverySchema(
command_class={CommandClass.SOUND_SWITCH}, command_class={CommandClass.SOUND_SWITCH},
property={TONE_ID_PROPERTY}, property={TONE_ID_PROPERTY},
@ -762,33 +768,6 @@ DISCOVERY_SCHEMAS = [
}, },
), ),
), ),
# HomeSeer HSM-200 v1
ZWaveDiscoverySchema(
platform=Platform.LIGHT,
hint="black_is_off",
manufacturer_id={0x001E},
product_id={0x0001},
product_type={0x0004},
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.SWITCH_COLOR},
property={CURRENT_COLOR_PROPERTY},
property_key={None},
),
absent_values=[SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA],
),
# Logic Group ZDB5100
ZWaveDiscoverySchema(
platform=Platform.LIGHT,
hint="black_is_off",
manufacturer_id={0x0234},
product_id={0x0121},
product_type={0x0003},
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.SWITCH_COLOR},
property={CURRENT_COLOR_PROPERTY},
property_key={None},
),
),
# ====== START OF GENERIC MAPPING SCHEMAS ======= # ====== START OF GENERIC MAPPING SCHEMAS =======
# locks # locks
# Door Lock CC # Door Lock CC
@ -1014,10 +993,11 @@ DISCOVERY_SCHEMAS = [
), ),
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
# binary switches # binary switches without color support
ZWaveDiscoverySchema( ZWaveDiscoverySchema(
platform=Platform.SWITCH, platform=Platform.SWITCH,
primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA,
absent_values=[COLOR_SWITCH_CURRENT_VALUE_SCHEMA],
), ),
# switch for Indicator CC # switch for Indicator CC
ZWaveDiscoverySchema( ZWaveDiscoverySchema(
@ -1111,6 +1091,25 @@ DISCOVERY_SCHEMAS = [
# catch any device with multilevel CC as light # catch any device with multilevel CC as light
# NOTE: keep this at the bottom of the discovery scheme, # NOTE: keep this at the bottom of the discovery scheme,
# to handle all others that need the multilevel CC first # to handle all others that need the multilevel CC first
#
# Colored light (legacy device) that can only be controlled through Color Switch CC.
ZWaveDiscoverySchema(
platform=Platform.LIGHT,
hint="color_onoff",
primary_value=COLOR_SWITCH_CURRENT_VALUE_SCHEMA,
absent_values=[
SWITCH_BINARY_CURRENT_VALUE_SCHEMA,
SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
],
),
# Colored light that can be turned on or off with the Binary Switch CC.
ZWaveDiscoverySchema(
platform=Platform.LIGHT,
hint="color_onoff",
primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA,
required_values=[COLOR_SWITCH_CURRENT_VALUE_SCHEMA],
),
# Dimmable light with or without color support.
ZWaveDiscoverySchema( ZWaveDiscoverySchema(
platform=Platform.LIGHT, platform=Platform.LIGHT,
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,

View File

@ -76,8 +76,8 @@ async def async_setup_entry(
driver = client.driver driver = client.driver
assert driver is not None # Driver is ready before platforms are loaded. assert driver is not None # Driver is ready before platforms are loaded.
if info.platform_hint == "black_is_off": if info.platform_hint == "color_onoff":
async_add_entities([ZwaveBlackIsOffLight(config_entry, driver, info)]) async_add_entities([ZwaveColorOnOffLight(config_entry, driver, info)])
else: else:
async_add_entities([ZwaveLight(config_entry, driver, info)]) async_add_entities([ZwaveLight(config_entry, driver, info)])
@ -111,9 +111,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
self._supports_color = False self._supports_color = False
self._supports_rgbw = False self._supports_rgbw = False
self._supports_color_temp = False self._supports_color_temp = False
self._supports_dimming = False
self._color_mode: str | None = None
self._hs_color: tuple[float, float] | None = None self._hs_color: tuple[float, float] | None = None
self._rgbw_color: tuple[int, int, int, int] | None = None self._rgbw_color: tuple[int, int, int, int] | None = None
self._color_mode: str | None = None
self._color_temp: int | None = None self._color_temp: int | None = None
self._min_mireds = 153 # 6500K as a safe default self._min_mireds = 153 # 6500K as a safe default
self._max_mireds = 370 # 2700K as a safe default self._max_mireds = 370 # 2700K as a safe default
@ -129,15 +130,28 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
) )
self._supported_color_modes: set[ColorMode] = set() self._supported_color_modes: set[ColorMode] = set()
self._target_brightness: Value | None = None
# get additional (optional) values and set features # get additional (optional) values and set features
# If the command class is Basic, we must geenerate a name that includes if self.info.primary_value.command_class == CommandClass.SWITCH_BINARY:
# the command class name to avoid ambiguity # This light can not be dimmed separately from the color channels
self._target_brightness = self.get_zwave_value( self._target_brightness = self.get_zwave_value(
TARGET_VALUE_PROPERTY, TARGET_VALUE_PROPERTY,
CommandClass.SWITCH_MULTILEVEL, CommandClass.SWITCH_BINARY,
add_to_watched_value_ids=False, add_to_watched_value_ids=False,
) )
if self.info.primary_value.command_class == CommandClass.BASIC: self._supports_dimming = False
elif self.info.primary_value.command_class == CommandClass.SWITCH_MULTILEVEL:
# This light can be dimmed separately from the color channels
self._target_brightness = self.get_zwave_value(
TARGET_VALUE_PROPERTY,
CommandClass.SWITCH_MULTILEVEL,
add_to_watched_value_ids=False,
)
self._supports_dimming = True
elif self.info.primary_value.command_class == CommandClass.BASIC:
# If the command class is Basic, we must generate a name that includes
# the command class name to avoid ambiguity
self._attr_name = self.generate_name( self._attr_name = self.generate_name(
include_value_name=True, alternate_value_name="Basic" include_value_name=True, alternate_value_name="Basic"
) )
@ -146,6 +160,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
CommandClass.BASIC, CommandClass.BASIC,
add_to_watched_value_ids=False, add_to_watched_value_ids=False,
) )
self._supports_dimming = True
self._current_color = self.get_zwave_value(
CURRENT_COLOR_PROPERTY,
CommandClass.SWITCH_COLOR,
value_property_key=None,
)
self._target_color = self.get_zwave_value( self._target_color = self.get_zwave_value(
TARGET_COLOR_PROPERTY, TARGET_COLOR_PROPERTY,
CommandClass.SWITCH_COLOR, CommandClass.SWITCH_COLOR,
@ -216,7 +237,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
@property @property
def rgbw_color(self) -> tuple[int, int, int, int] | None: def rgbw_color(self) -> tuple[int, int, int, int] | None:
"""Return the hs color.""" """Return the RGBW color."""
return self._rgbw_color return self._rgbw_color
@property @property
@ -243,11 +264,39 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
"""Turn the device on.""" """Turn the device on."""
transition = kwargs.get(ATTR_TRANSITION) transition = kwargs.get(ATTR_TRANSITION)
brightness = kwargs.get(ATTR_BRIGHTNESS)
hs_color = kwargs.get(ATTR_HS_COLOR)
color_temp = kwargs.get(ATTR_COLOR_TEMP)
rgbw = kwargs.get(ATTR_RGBW_COLOR)
new_colors = self._get_new_colors(hs_color, color_temp, rgbw)
if new_colors is not None:
await self._async_set_colors(new_colors, transition)
# set brightness (or turn on if dimming is not supported)
await self._async_set_brightness(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))
def _get_new_colors(
self,
hs_color: tuple[float, float] | None,
color_temp: int | None,
rgbw: tuple[int, int, int, int] | None,
brightness_scale: float | None = None,
) -> dict[ColorComponent, int] | None:
"""Determine the new color dict to set."""
# RGB/HS color # RGB/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:
red, green, blue = color_util.color_hs_to_RGB(*hs_color) red, green, blue = color_util.color_hs_to_RGB(*hs_color)
if brightness_scale is not None:
red = round(red * brightness_scale)
green = round(green * brightness_scale)
blue = round(blue * brightness_scale)
colors = { colors = {
ColorComponent.RED: red, ColorComponent.RED: red,
ColorComponent.GREEN: green, ColorComponent.GREEN: green,
@ -257,10 +306,9 @@ 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, transition) return colors
# Color temperature # Color temperature
color_temp = kwargs.get(ATTR_COLOR_TEMP)
if color_temp is not None and self._supports_color_temp: if color_temp is not None and self._supports_color_temp:
# Limit color temp to min/max values # Limit color temp to min/max values
cold = max( cold = max(
@ -275,20 +323,18 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
), ),
) )
warm = 255 - cold warm = 255 - cold
await self._async_set_colors( colors = {
{ ColorComponent.WARM_WHITE: warm,
# turn off color leds when setting color temperature ColorComponent.COLD_WHITE: cold,
ColorComponent.RED: 0, }
ColorComponent.GREEN: 0, if self._supports_color:
ColorComponent.BLUE: 0, # turn off color leds when setting color temperature
ColorComponent.WARM_WHITE: warm, colors[ColorComponent.RED] = 0
ColorComponent.COLD_WHITE: cold, colors[ColorComponent.GREEN] = 0
}, colors[ColorComponent.BLUE] = 0
transition, return colors
)
# RGBW # RGBW
rgbw = kwargs.get(ATTR_RGBW_COLOR)
if rgbw is not None and self._supports_rgbw: if rgbw is not None and self._supports_rgbw:
rgbw_channels = { rgbw_channels = {
ColorComponent.RED: rgbw[0], ColorComponent.RED: rgbw[0],
@ -300,17 +346,15 @@ 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, transition)
# set brightness return rgbw_channels
await self._async_set_brightness(kwargs.get(ATTR_BRIGHTNESS), transition)
async def async_turn_off(self, **kwargs: Any) -> None: return None
"""Turn the light off."""
await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION))
async def _async_set_colors( async def _async_set_colors(
self, colors: dict[ColorComponent, int], transition: float | None = None self,
colors: dict[ColorComponent, int],
transition: float | None = 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
@ -361,9 +405,14 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
zwave_transition = {TRANSITION_DURATION_OPTION: "default"} zwave_transition = {TRANSITION_DURATION_OPTION: "default"}
# setting a value requires setting targetValue # setting a value requires setting targetValue
await self._async_set_value( if self._supports_dimming:
self._target_brightness, zwave_brightness, zwave_transition await self._async_set_value(
) self._target_brightness, zwave_brightness, zwave_transition
)
else:
await self._async_set_value(
self._target_brightness, zwave_brightness > 0, zwave_transition
)
# We do an optimistic state update when setting to a previous value # We do an optimistic state update when setting to a previous value
# to avoid waiting for the value to be updated from the device which is # to avoid waiting for the value to be updated from the device which is
# typically delayed and causes a confusing UX. # typically delayed and causes a confusing UX.
@ -427,15 +476,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
"""Calculate light colors.""" """Calculate light colors."""
(red_val, green_val, blue_val, ww_val, cw_val) = self._get_color_values() (red_val, green_val, blue_val, ww_val, cw_val) = self._get_color_values()
# prefer the (new) combined color property if self._current_color and isinstance(self._current_color.value, dict):
# https://github.com/zwave-js/node-zwave-js/pull/1782 multi_color = self._current_color.value
combined_color_val = self.get_zwave_value(
CURRENT_COLOR_PROPERTY,
CommandClass.SWITCH_COLOR,
value_property_key=None,
)
if combined_color_val and isinstance(combined_color_val.value, dict):
multi_color = combined_color_val.value
else: else:
multi_color = {} multi_color = {}
@ -486,11 +528,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
self._color_mode = ColorMode.RGBW self._color_mode = ColorMode.RGBW
class ZwaveBlackIsOffLight(ZwaveLight): class ZwaveColorOnOffLight(ZwaveLight):
"""Representation of a Z-Wave light where setting the color to black turns it off. """Representation of a colored Z-Wave light with an optional binary switch to turn on/off.
Currently only supports lights with RGB, no color temperature, and no white Dimming for RGB lights is realized by scaling the color channels.
channels.
""" """
def __init__( def __init__(
@ -499,61 +540,137 @@ class ZwaveBlackIsOffLight(ZwaveLight):
"""Initialize the light.""" """Initialize the light."""
super().__init__(config_entry, driver, info) super().__init__(config_entry, driver, info)
self._last_color: dict[str, int] | None = None self._last_on_color: dict[ColorComponent, int] | None = None
self._supported_color_modes.discard(ColorMode.BRIGHTNESS) self._last_brightness: int | None = None
@property @property
def brightness(self) -> int: def brightness(self) -> int | None:
"""Return the brightness of this light between 0..255.""" """Return the brightness of this light between 0..255.
return 255
@property Z-Wave multilevel switches use a range of [0, 99] to control brightness.
def is_on(self) -> bool | None: """
"""Return true if device is on (brightness above 0)."""
if self.info.primary_value.value is None: if self.info.primary_value.value is None:
return None return None
return any(value != 0 for value in self.info.primary_value.value.values()) if self._target_brightness and self.info.primary_value.value is False:
# Binary switch exists and is turned off
return 0
# Brightness is encoded in the color channels by scaling them lower than 255
color_values = [
v.value
for v in self._get_color_values()
if v is not None and v.value is not None
]
return max(color_values) if color_values else 0
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."""
if ( if (
kwargs.get(ATTR_RGBW_COLOR) is not None kwargs.get(ATTR_RGBW_COLOR) is not None
or kwargs.get(ATTR_COLOR_TEMP) is not None or kwargs.get(ATTR_COLOR_TEMP) is not None
or kwargs.get(ATTR_HS_COLOR) is not None
): ):
# RGBW and color temp are not supported in this mode,
# delegate to the parent class
await super().async_turn_on(**kwargs) await super().async_turn_on(**kwargs)
return return
transition = kwargs.get(ATTR_TRANSITION) transition = kwargs.get(ATTR_TRANSITION)
# turn on light to last color if known, otherwise set to white brightness = kwargs.get(ATTR_BRIGHTNESS)
if self._last_color is not None: hs_color = kwargs.get(ATTR_HS_COLOR)
await self._async_set_colors( new_colors: dict[ColorComponent, int] | None = None
{ scale: float | None = None
ColorComponent.RED: self._last_color["red"],
ColorComponent.GREEN: self._last_color["green"], if brightness is None and hs_color is None:
ColorComponent.BLUE: self._last_color["blue"], # Turned on without specifying brightness or color
}, if self._last_on_color is not None:
transition, if self._target_brightness:
) # Color is already set, use the binary switch to turn on
else: await self._async_set_brightness(None, transition)
await self._async_set_colors( return
{
# Preserve the previous color
new_colors = self._last_on_color
elif self._supports_color:
# Turned on for the first time. Make it white
new_colors = {
ColorComponent.RED: 255, ColorComponent.RED: 255,
ColorComponent.GREEN: 255, ColorComponent.GREEN: 255,
ColorComponent.BLUE: 255, ColorComponent.BLUE: 255,
}, }
transition, elif brightness is not None:
# If brightness gets set, preserve the color and mix it with the new brightness
if self.color_mode == ColorMode.HS:
scale = brightness / 255
if (
self._last_on_color is not None
and None not in self._last_on_color.values()
):
# Changed brightness from 0 to >0
old_brightness = max(self._last_on_color.values())
new_scale = brightness / old_brightness
scale = new_scale
new_colors = {}
for color, value in self._last_on_color.items():
new_colors[color] = round(value * new_scale)
elif hs_color is None and self._color_mode == ColorMode.HS:
hs_color = self._hs_color
elif hs_color is not None and brightness is None:
# Turned on by using the color controls
current_brightness = self.brightness
if current_brightness == 0 and self._last_brightness is not None:
# Use the last brightness value if the light is currently off
scale = self._last_brightness / 255
elif current_brightness is not None:
scale = current_brightness / 255
# Reset last color until turning off again
self._last_on_color = None
if new_colors is None:
new_colors = self._get_new_colors(
hs_color=hs_color, color_temp=None, rgbw=None, brightness_scale=scale
) )
if new_colors is not None:
await self._async_set_colors(new_colors, transition)
# Turn the binary switch on if there is one
await self._async_set_brightness(brightness, 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."""
self._last_color = self.info.primary_value.value
await self._async_set_colors( # Remember last color and brightness to restore it when turning on
{ self._last_brightness = self.brightness
if self._current_color and isinstance(self._current_color.value, dict):
red = self._current_color.value.get(COLOR_SWITCH_COMBINED_RED)
green = self._current_color.value.get(COLOR_SWITCH_COMBINED_GREEN)
blue = self._current_color.value.get(COLOR_SWITCH_COMBINED_BLUE)
last_color: dict[ColorComponent, int] = {}
if red is not None:
last_color[ColorComponent.RED] = red
if green is not None:
last_color[ColorComponent.GREEN] = green
if blue is not None:
last_color[ColorComponent.BLUE] = blue
if last_color:
self._last_on_color = last_color
if self._target_brightness:
# Turn off the binary switch only
await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION))
else:
# turn off all color channels
colors = {
ColorComponent.RED: 0, ColorComponent.RED: 0,
ColorComponent.GREEN: 0, ColorComponent.GREEN: 0,
ColorComponent.BLUE: 0, ColorComponent.BLUE: 0,
}, }
kwargs.get(ATTR_TRANSITION),
) await self._async_set_colors(
await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION)) colors,
kwargs.get(ATTR_TRANSITION),
)

View File

@ -8,6 +8,7 @@ from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
ATTR_COLOR_MODE, ATTR_COLOR_MODE,
ATTR_COLOR_TEMP, ATTR_COLOR_TEMP,
ATTR_HS_COLOR,
ATTR_MAX_MIREDS, ATTR_MAX_MIREDS,
ATTR_MIN_MIREDS, ATTR_MIN_MIREDS,
ATTR_RGB_COLOR, ATTR_RGB_COLOR,
@ -37,8 +38,8 @@ from .common import (
ZEN_31_ENTITY, ZEN_31_ENTITY,
) )
HSM200_V1_ENTITY = "light.hsm200"
ZDB5100_ENTITY = "light.matrix_office" ZDB5100_ENTITY = "light.matrix_office"
HSM200_V1_ENTITY = "light.hsm200"
async def test_light( async def test_light(
@ -510,14 +511,388 @@ async def test_light_none_color_value(
assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["hs"] assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["hs"]
async def test_black_is_off( async def test_light_on_off_color(
hass: HomeAssistant, client, logic_group_zdb5100, integration
) -> None:
"""Test the light entity for RGB lights without dimming support."""
node = logic_group_zdb5100
state = hass.states.get(ZDB5100_ENTITY)
assert state.state == STATE_OFF
async def update_color(red: int, green: int, blue: int) -> None:
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node.node_id,
"args": {
"commandClassName": "Color Switch",
"commandClass": 51,
"endpoint": 1,
"property": "currentColor",
"propertyKey": 2, # red
"newValue": red,
"prevValue": None,
"propertyName": "currentColor",
"propertyKeyName": "red",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node.node_id,
"args": {
"commandClassName": "Color Switch",
"commandClass": 51,
"endpoint": 1,
"property": "currentColor",
"propertyKey": 3, # green
"newValue": green,
"prevValue": None,
"propertyName": "currentColor",
"propertyKeyName": "green",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node.node_id,
"args": {
"commandClassName": "Color Switch",
"commandClass": 51,
"endpoint": 1,
"property": "currentColor",
"propertyKey": 4, # blue
"newValue": blue,
"prevValue": None,
"propertyName": "currentColor",
"propertyKeyName": "blue",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node.node_id,
"args": {
"commandClassName": "Color Switch",
"commandClass": 51,
"endpoint": 1,
"property": "currentColor",
"newValue": {
"red": red,
"green": green,
"blue": blue,
},
"prevValue": None,
"propertyName": "currentColor",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
async def update_switch_state(state: bool) -> None:
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node.node_id,
"args": {
"commandClassName": "Binary Switch",
"commandClass": 37,
"endpoint": 1,
"property": "currentValue",
"newValue": state,
"prevValue": None,
"propertyName": "currentValue",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
# Turn on the light. Since this is the first call, the light should default to white
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ZDB5100_ENTITY},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 2
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 51,
"endpoint": 1,
"property": "targetColor",
}
assert args["value"] == {
"red": 255,
"green": 255,
"blue": 255,
}
args = client.async_send_command.call_args_list[1][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 37,
"endpoint": 1,
"property": "targetValue",
}
assert args["value"] is True
# Force the light to turn off
await update_switch_state(False)
state = hass.states.get(ZDB5100_ENTITY)
assert state.state == STATE_OFF
# Force the light to turn on (green)
await update_color(0, 255, 0)
await update_switch_state(True)
state = hass.states.get(ZDB5100_ENTITY)
assert state.state == STATE_ON
client.async_send_command.reset_mock()
# Set the brightness to 128. This should be encoded in the color value
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ZDB5100_ENTITY, ATTR_BRIGHTNESS: 128},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 2
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 51,
"endpoint": 1,
"property": "targetColor",
}
assert args["value"] == {
"red": 0,
"green": 128,
"blue": 0,
}
args = client.async_send_command.call_args_list[1][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 37,
"endpoint": 1,
"property": "targetValue",
}
assert args["value"] is True
client.async_send_command.reset_mock()
# Force the light to turn on (green, 50%)
await update_color(0, 128, 0)
# Set the color to red. This should preserve the previous brightness value
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ZDB5100_ENTITY, ATTR_HS_COLOR: (0, 100)},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 2
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 51,
"endpoint": 1,
"property": "targetColor",
}
assert args["value"] == {
"red": 128,
"green": 0,
"blue": 0,
}
args = client.async_send_command.call_args_list[1][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 37,
"endpoint": 1,
"property": "targetValue",
}
assert args["value"] is True
client.async_send_command.reset_mock()
# Force the light to turn on (red, 50%)
await update_color(128, 0, 0)
# Turn the device off. This should only affect the binary switch, not the color
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: ZDB5100_ENTITY},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 1
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 37,
"endpoint": 1,
"property": "targetValue",
}
assert args["value"] is False
client.async_send_command.reset_mock()
# Force the light to turn off
await update_switch_state(False)
# Turn the device on again. This should only affect the binary switch, not the color
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ZDB5100_ENTITY},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 1
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 37,
"endpoint": 1,
"property": "targetValue",
}
assert args["value"] is True
async def test_light_color_only(
hass: HomeAssistant, client, express_controls_ezmultipli, integration hass: HomeAssistant, client, express_controls_ezmultipli, integration
) -> None: ) -> None:
"""Test the black is off light entity.""" """Test the light entity for RGB lights with Color Switch CC only."""
node = express_controls_ezmultipli node = express_controls_ezmultipli
state = hass.states.get(HSM200_V1_ENTITY) state = hass.states.get(HSM200_V1_ENTITY)
assert state.state == STATE_ON assert state.state == STATE_ON
async def update_color(red: int, green: int, blue: int) -> None:
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node.node_id,
"args": {
"commandClassName": "Color Switch",
"commandClass": 51,
"endpoint": 0,
"property": "currentColor",
"propertyKey": 2, # red
"newValue": red,
"prevValue": None,
"propertyName": "currentColor",
"propertyKeyName": "red",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node.node_id,
"args": {
"commandClassName": "Color Switch",
"commandClass": 51,
"endpoint": 0,
"property": "currentColor",
"propertyKey": 3, # green
"newValue": green,
"prevValue": None,
"propertyName": "currentColor",
"propertyKeyName": "green",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node.node_id,
"args": {
"commandClassName": "Color Switch",
"commandClass": 51,
"endpoint": 0,
"property": "currentColor",
"propertyKey": 4, # blue
"newValue": blue,
"prevValue": None,
"propertyName": "currentColor",
"propertyKeyName": "blue",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node.node_id,
"args": {
"commandClassName": "Color Switch",
"commandClass": 51,
"endpoint": 0,
"property": "currentColor",
"newValue": {
"red": red,
"green": green,
"blue": blue,
},
"prevValue": None,
"propertyName": "currentColor",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
# Attempt to turn on the light and ensure it defaults to white # Attempt to turn on the light and ensure it defaults to white
await hass.services.async_call( await hass.services.async_call(
LIGHT_DOMAIN, LIGHT_DOMAIN,
@ -539,64 +914,14 @@ async def test_black_is_off(
client.async_send_command.reset_mock() client.async_send_command.reset_mock()
# Force the light to turn off # Force the light to turn off
event = Event( await update_color(0, 0, 0)
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node.node_id,
"args": {
"commandClassName": "Color Switch",
"commandClass": 51,
"endpoint": 0,
"property": "currentColor",
"newValue": {
"red": 0,
"green": 0,
"blue": 0,
},
"prevValue": {
"red": 0,
"green": 255,
"blue": 0,
},
"propertyName": "currentColor",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
state = hass.states.get(HSM200_V1_ENTITY) state = hass.states.get(HSM200_V1_ENTITY)
assert state.state == STATE_OFF assert state.state == STATE_OFF
# Force the light to turn on # Force the light to turn on (50% green)
event = Event( await update_color(0, 128, 0)
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node.node_id,
"args": {
"commandClassName": "Color Switch",
"commandClass": 51,
"endpoint": 0,
"property": "currentColor",
"newValue": {
"red": 0,
"green": 255,
"blue": 0,
},
"prevValue": {
"red": 0,
"green": 0,
"blue": 0,
},
"propertyName": "currentColor",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
state = hass.states.get(HSM200_V1_ENTITY) state = hass.states.get(HSM200_V1_ENTITY)
assert state.state == STATE_ON assert state.state == STATE_ON
@ -619,6 +944,9 @@ async def test_black_is_off(
client.async_send_command.reset_mock() client.async_send_command.reset_mock()
# Force the light to turn off
await update_color(0, 0, 0)
# Assert that the last color is restored # Assert that the last color is restored
await hass.services.async_call( await hass.services.async_call(
LIGHT_DOMAIN, LIGHT_DOMAIN,
@ -635,11 +963,131 @@ async def test_black_is_off(
"endpoint": 0, "endpoint": 0,
"property": "targetColor", "property": "targetColor",
} }
assert args["value"] == {"red": 0, "green": 255, "blue": 0} assert args["value"] == {"red": 0, "green": 128, "blue": 0}
client.async_send_command.reset_mock() client.async_send_command.reset_mock()
# Force the light to turn on # Force the light to turn on (50% green)
await update_color(0, 128, 0)
state = hass.states.get(HSM200_V1_ENTITY)
assert state.state == STATE_ON
client.async_send_command.reset_mock()
# Assert that the brightness is preserved when changing colors
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: HSM200_V1_ENTITY, ATTR_RGB_COLOR: (255, 0, 0)},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 1
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 51,
"endpoint": 0,
"property": "targetColor",
}
assert args["value"] == {"red": 128, "green": 0, "blue": 0}
client.async_send_command.reset_mock()
# Force the light to turn on (50% red)
await update_color(128, 0, 0)
state = hass.states.get(HSM200_V1_ENTITY)
assert state.state == STATE_ON
# Assert that the color is preserved when changing brightness
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: HSM200_V1_ENTITY, ATTR_BRIGHTNESS: 69},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 1
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 51,
"endpoint": 0,
"property": "targetColor",
}
assert args["value"] == {"red": 69, "green": 0, "blue": 0}
client.async_send_command.reset_mock()
await update_color(69, 0, 0)
# Turn off again
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: HSM200_V1_ENTITY},
blocking=True,
)
await update_color(0, 0, 0)
client.async_send_command.reset_mock()
# Assert that the color is preserved when turning on with brightness
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: HSM200_V1_ENTITY, ATTR_BRIGHTNESS: 123},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 1
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 51,
"endpoint": 0,
"property": "targetColor",
}
assert args["value"] == {"red": 123, "green": 0, "blue": 0}
client.async_send_command.reset_mock()
await update_color(123, 0, 0)
# Turn off again
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: HSM200_V1_ENTITY},
blocking=True,
)
await update_color(0, 0, 0)
client.async_send_command.reset_mock()
# Assert that the brightness is preserved when turning on with color
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: HSM200_V1_ENTITY, ATTR_HS_COLOR: (240, 100)},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 1
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 51,
"endpoint": 0,
"property": "targetColor",
}
assert args["value"] == {"red": 0, "green": 0, "blue": 123}
client.async_send_command.reset_mock()
# Clear the color value to trigger an unknown state
event = Event( event = Event(
type="value updated", type="value updated",
data={ data={
@ -652,17 +1100,14 @@ async def test_black_is_off(
"endpoint": 0, "endpoint": 0,
"property": "currentColor", "property": "currentColor",
"newValue": None, "newValue": None,
"prevValue": { "prevValue": None,
"red": 0,
"green": 255,
"blue": 0,
},
"propertyName": "currentColor", "propertyName": "currentColor",
}, },
}, },
) )
node.receive_event(event) node.receive_event(event)
await hass.async_block_till_done() await hass.async_block_till_done()
state = hass.states.get(HSM200_V1_ENTITY) state = hass.states.get(HSM200_V1_ENTITY)
assert state.state == STATE_UNKNOWN assert state.state == STATE_UNKNOWN
@ -687,183 +1132,6 @@ async def test_black_is_off(
assert args["value"] == {"red": 255, "green": 76, "blue": 255} assert args["value"] == {"red": 255, "green": 76, "blue": 255}
async def test_black_is_off_zdb5100(
hass: HomeAssistant, client, logic_group_zdb5100, integration
) -> None:
"""Test the black is off light entity."""
node = logic_group_zdb5100
state = hass.states.get(ZDB5100_ENTITY)
assert state.state == STATE_OFF
# Attempt to turn on the light and ensure it defaults to white
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ZDB5100_ENTITY},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 1
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 51,
"endpoint": 1,
"property": "targetColor",
}
assert args["value"] == {"red": 255, "green": 255, "blue": 255}
client.async_send_command.reset_mock()
# Force the light to turn off
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node.node_id,
"args": {
"commandClassName": "Color Switch",
"commandClass": 51,
"endpoint": 1,
"property": "currentColor",
"newValue": {
"red": 0,
"green": 0,
"blue": 0,
},
"prevValue": {
"red": 0,
"green": 255,
"blue": 0,
},
"propertyName": "currentColor",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
state = hass.states.get(ZDB5100_ENTITY)
assert state.state == STATE_OFF
# Force the light to turn on
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node.node_id,
"args": {
"commandClassName": "Color Switch",
"commandClass": 51,
"endpoint": 1,
"property": "currentColor",
"newValue": {
"red": 0,
"green": 255,
"blue": 0,
},
"prevValue": {
"red": 0,
"green": 0,
"blue": 0,
},
"propertyName": "currentColor",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
state = hass.states.get(ZDB5100_ENTITY)
assert state.state == STATE_ON
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: ZDB5100_ENTITY},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 1
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 51,
"endpoint": 1,
"property": "targetColor",
}
assert args["value"] == {"red": 0, "green": 0, "blue": 0}
client.async_send_command.reset_mock()
# Assert that the last color is restored
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ZDB5100_ENTITY},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 1
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 51,
"endpoint": 1,
"property": "targetColor",
}
assert args["value"] == {"red": 0, "green": 255, "blue": 0}
client.async_send_command.reset_mock()
# Force the light to turn on
event = Event(
type="value updated",
data={
"source": "node",
"event": "value updated",
"nodeId": node.node_id,
"args": {
"commandClassName": "Color Switch",
"commandClass": 51,
"endpoint": 1,
"property": "currentColor",
"newValue": None,
"prevValue": {
"red": 0,
"green": 255,
"blue": 0,
},
"propertyName": "currentColor",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
state = hass.states.get(ZDB5100_ENTITY)
assert state.state == STATE_UNKNOWN
client.async_send_command.reset_mock()
# Assert that call fails if attribute is added to service call
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ZDB5100_ENTITY, ATTR_RGBW_COLOR: (255, 76, 255, 0)},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 1
args = client.async_send_command.call_args_list[0][0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == node.node_id
assert args["valueId"] == {
"commandClass": 51,
"endpoint": 1,
"property": "targetColor",
}
assert args["value"] == {"red": 255, "green": 76, "blue": 255}
async def test_basic_cc_light( async def test_basic_cc_light(
hass: HomeAssistant, hass: HomeAssistant,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,