Support non-dimmable color lights in Z-Wave JS (#127808)

* 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

* fix discovery to handle all 3 switch CCs, limit search to same endpoint

* Update homeassistant/components/zwave_js/discovery.py

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

* Update homeassistant/components/zwave_js/discovery.py

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

* add test for Smart Switch 7 state

* Add type annotations

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
AlCalzone 2024-10-10 14:36:37 +02:00 committed by GitHub
parent 67f67a02f8
commit f446e42317
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 2676 additions and 364 deletions

View file

@ -238,6 +238,12 @@ SWITCH_BINARY_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema(
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(
command_class={CommandClass.SOUND_SWITCH},
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 =======
# locks
# Door Lock CC
@ -990,11 +969,6 @@ DISCOVERY_SCHEMAS = [
),
entity_category=EntityCategory.CONFIG,
),
# binary switches
ZWaveDiscoverySchema(
platform=Platform.SWITCH,
primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA,
),
# switch for Indicator CC
ZWaveDiscoverySchema(
platform=Platform.SWITCH,
@ -1082,15 +1056,51 @@ DISCOVERY_SCHEMAS = [
device_class_generic={"Thermostat"},
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
),
# lights
# primary value is the currentValue (brightness)
# catch any device with multilevel CC as light
# NOTE: keep this at the bottom of the discovery scheme,
# to handle all others that need the multilevel CC first
# Handle the different combinations of Binary Switch, Multilevel Switch and Color Switch
# to create switches and/or (colored) lights. The goal is to:
# - couple Color Switch CC with Multilevel Switch CC if possible
# - couple Color Switch CC with Binary Switch CC as the first fallback
# - use Color Switch CC standalone as the last fallback
#
# Multilevel Switch CC (+ Color Switch CC) -> Dimmable light with or without color support.
ZWaveDiscoverySchema(
platform=Platform.LIGHT,
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
),
# Binary Switch CC when Multilevel Switch and Color Switch CC exist ->
# On/Off switch, assign color to light entity instead
ZWaveDiscoverySchema(
platform=Platform.SWITCH,
primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA,
required_values=[
SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
COLOR_SWITCH_CURRENT_VALUE_SCHEMA,
],
),
# Binary Switch CC and Color Switch CC ->
# Colored light that uses Binary Switch CC for turning on/off.
ZWaveDiscoverySchema(
platform=Platform.LIGHT,
hint="color_onoff",
primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA,
required_values=[COLOR_SWITCH_CURRENT_VALUE_SCHEMA],
),
# Binary Switch CC without Color Switch CC -> On/Off switch
ZWaveDiscoverySchema(
platform=Platform.SWITCH,
primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA,
absent_values=[COLOR_SWITCH_CURRENT_VALUE_SCHEMA],
),
# 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,
],
),
# light for Basic CC with target
ZWaveDiscoverySchema(
platform=Platform.LIGHT,
@ -1315,14 +1325,20 @@ def async_discover_single_value(
# check additional required values
if schema.required_values is not None and not all(
any(check_value(val, val_scheme) for val in value.node.values.values())
any(
check_value(val, val_scheme, primary_value=value)
for val in value.node.values.values()
)
for val_scheme in schema.required_values
):
continue
# check for values that may not be present
if schema.absent_values is not None and any(
any(check_value(val, val_scheme) for val in value.node.values.values())
any(
check_value(val, val_scheme, primary_value=value)
for val in value.node.values.values()
)
for val_scheme in schema.absent_values
):
continue
@ -1441,7 +1457,11 @@ def async_discover_single_configuration_value(
@callback
def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool:
def check_value(
value: ZwaveValue,
schema: ZWaveValueDiscoverySchema,
primary_value: ZwaveValue | None = None,
) -> bool:
"""Check if value matches scheme."""
# check command_class
if (
@ -1452,6 +1472,14 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool:
# check endpoint
if schema.endpoint is not None and value.endpoint not in schema.endpoint:
return False
# If the schema does not require an endpoint, make sure the value is on the
# same endpoint as the primary value
if (
schema.endpoint is None
and primary_value is not None
and value.endpoint != primary_value.endpoint
):
return False
# check property
if schema.property is not None and value.property_ not in schema.property:
return False

View file

@ -76,8 +76,8 @@ async def async_setup_entry(
driver = client.driver
assert driver is not None # Driver is ready before platforms are loaded.
if info.platform_hint == "black_is_off":
async_add_entities([ZwaveBlackIsOffLight(config_entry, driver, info)])
if info.platform_hint == "color_onoff":
async_add_entities([ZwaveColorOnOffLight(config_entry, driver, info)])
else:
async_add_entities([ZwaveLight(config_entry, driver, info)])
@ -111,9 +111,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
self._supports_color = False
self._supports_rgbw = 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._rgbw_color: tuple[int, int, int, int] | None = None
self._color_mode: str | None = None
self._color_temp: int | None = None
self._min_mireds = 153 # 6500K 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._target_brightness: Value | None = None
# get additional (optional) values and set features
# If the command class is Basic, we must geenerate a name that includes
# the command class name to avoid ambiguity
self._target_brightness = self.get_zwave_value(
TARGET_VALUE_PROPERTY,
CommandClass.SWITCH_MULTILEVEL,
add_to_watched_value_ids=False,
)
if self.info.primary_value.command_class == CommandClass.BASIC:
if self.info.primary_value.command_class == CommandClass.SWITCH_BINARY:
# This light can not be dimmed separately from the color channels
self._target_brightness = self.get_zwave_value(
TARGET_VALUE_PROPERTY,
CommandClass.SWITCH_BINARY,
add_to_watched_value_ids=False,
)
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(
include_value_name=True, alternate_value_name="Basic"
)
@ -146,6 +160,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
CommandClass.BASIC,
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(
TARGET_COLOR_PROPERTY,
CommandClass.SWITCH_COLOR,
@ -216,7 +237,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
@property
def rgbw_color(self) -> tuple[int, int, int, int] | None:
"""Return the hs color."""
"""Return the RGBW color."""
return self._rgbw_color
@property
@ -243,11 +264,39 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
"""Turn the device on."""
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
hs_color = kwargs.get(ATTR_HS_COLOR)
if hs_color is not None and self._supports_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 = {
ColorComponent.RED: red,
ColorComponent.GREEN: green,
@ -257,10 +306,9 @@ 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, transition)
return colors
# Color temperature
color_temp = kwargs.get(ATTR_COLOR_TEMP)
if color_temp is not None and self._supports_color_temp:
# Limit color temp to min/max values
cold = max(
@ -275,20 +323,18 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
),
)
warm = 255 - cold
await self._async_set_colors(
{
# turn off color leds when setting color temperature
ColorComponent.RED: 0,
ColorComponent.GREEN: 0,
ColorComponent.BLUE: 0,
ColorComponent.WARM_WHITE: warm,
ColorComponent.COLD_WHITE: cold,
},
transition,
)
colors = {
ColorComponent.WARM_WHITE: warm,
ColorComponent.COLD_WHITE: cold,
}
if self._supports_color:
# turn off color leds when setting color temperature
colors[ColorComponent.RED] = 0
colors[ColorComponent.GREEN] = 0
colors[ColorComponent.BLUE] = 0
return colors
# RGBW
rgbw = kwargs.get(ATTR_RGBW_COLOR)
if rgbw is not None and self._supports_rgbw:
rgbw_channels = {
ColorComponent.RED: rgbw[0],
@ -300,17 +346,15 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
if self._cold_white:
rgbw_channels[ColorComponent.COLD_WHITE] = rgbw[3]
await self._async_set_colors(rgbw_channels, transition)
# set brightness
await self._async_set_brightness(kwargs.get(ATTR_BRIGHTNESS), transition)
return rgbw_channels
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION))
return None
async def _async_set_colors(
self, colors: dict[ColorComponent, int], transition: float | None = None
self,
colors: dict[ColorComponent, int],
transition: float | None = None,
) -> None:
"""Set (multiple) defined colors to given value(s)."""
# prefer the (new) combined color property
@ -361,9 +405,14 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
zwave_transition = {TRANSITION_DURATION_OPTION: "default"}
# setting a value requires setting targetValue
await self._async_set_value(
self._target_brightness, zwave_brightness, zwave_transition
)
if self._supports_dimming:
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
# to avoid waiting for the value to be updated from the device which is
# typically delayed and causes a confusing UX.
@ -427,15 +476,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
"""Calculate light colors."""
(red_val, green_val, blue_val, ww_val, cw_val) = self._get_color_values()
# prefer the (new) combined color property
# https://github.com/zwave-js/node-zwave-js/pull/1782
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
if self._current_color and isinstance(self._current_color.value, dict):
multi_color = self._current_color.value
else:
multi_color = {}
@ -486,11 +528,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
self._color_mode = ColorMode.RGBW
class ZwaveBlackIsOffLight(ZwaveLight):
"""Representation of a Z-Wave light where setting the color to black turns it off.
class ZwaveColorOnOffLight(ZwaveLight):
"""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
channels.
Dimming for RGB lights is realized by scaling the color channels.
"""
def __init__(
@ -499,61 +540,137 @@ class ZwaveBlackIsOffLight(ZwaveLight):
"""Initialize the light."""
super().__init__(config_entry, driver, info)
self._last_color: dict[str, int] | None = None
self._supported_color_modes.discard(ColorMode.BRIGHTNESS)
self._last_on_color: dict[ColorComponent, int] | None = None
self._last_brightness: int | None = None
@property
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
return 255
def brightness(self) -> int | None:
"""Return the brightness of this light between 0..255.
@property
def is_on(self) -> bool | None:
"""Return true if device is on (brightness above 0)."""
Z-Wave multilevel switches use a range of [0, 99] to control brightness.
"""
if self.info.primary_value.value is 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:
"""Turn the device on."""
if (
kwargs.get(ATTR_RGBW_COLOR) 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)
return
transition = kwargs.get(ATTR_TRANSITION)
# turn on light to last color if known, otherwise set to white
if self._last_color is not None:
await self._async_set_colors(
{
ColorComponent.RED: self._last_color["red"],
ColorComponent.GREEN: self._last_color["green"],
ColorComponent.BLUE: self._last_color["blue"],
},
transition,
)
else:
await self._async_set_colors(
{
brightness = kwargs.get(ATTR_BRIGHTNESS)
hs_color = kwargs.get(ATTR_HS_COLOR)
new_colors: dict[ColorComponent, int] | None = None
scale: float | None = None
if brightness is None and hs_color is None:
# Turned on without specifying brightness or color
if self._last_on_color is not None:
if self._target_brightness:
# Color is already set, use the binary switch to turn on
await self._async_set_brightness(None, transition)
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.GREEN: 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:
"""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.GREEN: 0,
ColorComponent.BLUE: 0,
},
kwargs.get(ATTR_TRANSITION),
)
await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION))
}
await self._async_set_colors(
colors,
kwargs.get(ATTR_TRANSITION),
)

View file

@ -498,6 +498,15 @@ def siren_neo_coolcam_state_state_fixture() -> NodeDataType:
)
@pytest.fixture(name="aeotec_smart_switch_7_state")
def aeotec_smart_switch_7_state_fixture() -> NodeDataType:
"""Load node with fixture data for Aeotec Smart Switch 7."""
return cast(
NodeDataType,
load_json_object_fixture("aeotec_smart_switch_7_state.json", DOMAIN),
)
# model fixtures
@ -1212,3 +1221,13 @@ def siren_neo_coolcam_fixture(
node = Node(client, siren_neo_coolcam_state)
client.driver.controller.nodes[node.node_id] = node
return node
@pytest.fixture(name="aeotec_smart_switch_7")
def aeotec_smart_switch_7_fixture(
client: MagicMock, aeotec_smart_switch_7_state: NodeDataType
) -> Node:
"""Load node for Aeotec Smart Switch 7."""
node = Node(client, aeotec_smart_switch_7_state)
client.driver.controller.nodes[node.node_id] = node
return node

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ from zwave_js_server.model.node import Node
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode
from homeassistant.components.number import (
ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN,
@ -426,3 +427,19 @@ async def test_rediscovery(
assert state
assert state.state == "Beep Beep"
assert "Platform zwave_js does not generate unique IDs" not in caplog.text
async def test_aeotec_smart_switch_7(
hass: HomeAssistant,
aeotec_smart_switch_7: Node,
integration: MockConfigEntry,
) -> None:
"""Test that Smart Switch 7 has a light and a switch entity."""
state = hass.states.get("light.smart_switch_7")
assert state
assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [
ColorMode.HS,
]
state = hass.states.get("switch.smart_switch_7")
assert state

View file

@ -8,6 +8,7 @@ from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
ATTR_COLOR_TEMP,
ATTR_HS_COLOR,
ATTR_MAX_MIREDS,
ATTR_MIN_MIREDS,
ATTR_RGB_COLOR,
@ -37,8 +38,8 @@ from .common import (
ZEN_31_ENTITY,
)
HSM200_V1_ENTITY = "light.hsm200"
ZDB5100_ENTITY = "light.matrix_office"
HSM200_V1_ENTITY = "light.hsm200"
async def test_light(
@ -510,14 +511,388 @@ async def test_light_none_color_value(
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
) -> None:
"""Test the black is off light entity."""
"""Test the light entity for RGB lights with Color Switch CC only."""
node = express_controls_ezmultipli
state = hass.states.get(HSM200_V1_ENTITY)
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
await hass.services.async_call(
LIGHT_DOMAIN,
@ -539,64 +914,14 @@ async def test_black_is_off(
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": 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()
await update_color(0, 0, 0)
state = hass.states.get(HSM200_V1_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": 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()
# 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
@ -619,6 +944,9 @@ async def test_black_is_off(
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
await hass.services.async_call(
LIGHT_DOMAIN,
@ -635,11 +963,131 @@ async def test_black_is_off(
"endpoint": 0,
"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()
# 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(
type="value updated",
data={
@ -652,17 +1100,14 @@ async def test_black_is_off(
"endpoint": 0,
"property": "currentColor",
"newValue": None,
"prevValue": {
"red": 0,
"green": 255,
"blue": 0,
},
"prevValue": None,
"propertyName": "currentColor",
},
},
)
node.receive_event(event)
await hass.async_block_till_done()
state = hass.states.get(HSM200_V1_ENTITY)
assert state.state == STATE_UNKNOWN
@ -687,183 +1132,6 @@ async def test_black_is_off(
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(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,