mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 21:57:51 +00:00
ZHA: Support light flashing (#32234)
This commit is contained in:
parent
3ab04118f6
commit
8e3492d4f5
@ -39,6 +39,7 @@ class Channels:
|
|||||||
"""Initialize instance."""
|
"""Initialize instance."""
|
||||||
self._pools: List[zha_typing.ChannelPoolType] = []
|
self._pools: List[zha_typing.ChannelPoolType] = []
|
||||||
self._power_config = None
|
self._power_config = None
|
||||||
|
self._identify = None
|
||||||
self._semaphore = asyncio.Semaphore(3)
|
self._semaphore = asyncio.Semaphore(3)
|
||||||
self._unique_id = str(zha_device.ieee)
|
self._unique_id = str(zha_device.ieee)
|
||||||
self._zdo_channel = base.ZDOChannel(zha_device.device.endpoints[0], zha_device)
|
self._zdo_channel = base.ZDOChannel(zha_device.device.endpoints[0], zha_device)
|
||||||
@ -60,6 +61,17 @@ class Channels:
|
|||||||
if self._power_config is None:
|
if self._power_config is None:
|
||||||
self._power_config = channel
|
self._power_config = channel
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identify_ch(self) -> zha_typing.ChannelType:
|
||||||
|
"""Return power configuration channel."""
|
||||||
|
return self._identify
|
||||||
|
|
||||||
|
@identify_ch.setter
|
||||||
|
def identify_ch(self, channel: zha_typing.ChannelType) -> None:
|
||||||
|
"""Power configuration channel setter."""
|
||||||
|
if self._identify is None:
|
||||||
|
self._identify = channel
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def semaphore(self) -> asyncio.Semaphore:
|
def semaphore(self) -> asyncio.Semaphore:
|
||||||
"""Return semaphore for concurrent tasks."""
|
"""Return semaphore for concurrent tasks."""
|
||||||
@ -242,6 +254,8 @@ class ChannelPool:
|
|||||||
# on power configuration channel per device
|
# on power configuration channel per device
|
||||||
continue
|
continue
|
||||||
self._channels.power_configuration_ch = channel
|
self._channels.power_configuration_ch = channel
|
||||||
|
elif channel.name == const.CHANNEL_IDENTIFY:
|
||||||
|
self._channels.identify_ch = channel
|
||||||
|
|
||||||
self.all_channels[channel.id] = channel
|
self.all_channels[channel.id] = channel
|
||||||
|
|
||||||
|
@ -153,7 +153,13 @@ class Groups(ZigbeeChannel):
|
|||||||
class Identify(ZigbeeChannel):
|
class Identify(ZigbeeChannel):
|
||||||
"""Identify channel."""
|
"""Identify channel."""
|
||||||
|
|
||||||
pass
|
@callback
|
||||||
|
def cluster_command(self, tsn, command_id, args):
|
||||||
|
"""Handle commands received to this cluster."""
|
||||||
|
cmd = parse_and_log_command(self, tsn, command_id, args)
|
||||||
|
|
||||||
|
if cmd == "trigger_effect":
|
||||||
|
self.async_send_signal(f"{self.unique_id}_{cmd}", args[0])
|
||||||
|
|
||||||
|
|
||||||
@registries.BINDABLE_CLUSTERS.register(general.LevelControl.cluster_id)
|
@registries.BINDABLE_CLUSTERS.register(general.LevelControl.cluster_id)
|
||||||
|
@ -62,6 +62,7 @@ CHANNEL_EVENT_RELAY = "event_relay"
|
|||||||
CHANNEL_FAN = "fan"
|
CHANNEL_FAN = "fan"
|
||||||
CHANNEL_HUMIDITY = "humidity"
|
CHANNEL_HUMIDITY = "humidity"
|
||||||
CHANNEL_IAS_WD = "ias_wd"
|
CHANNEL_IAS_WD = "ias_wd"
|
||||||
|
CHANNEL_IDENTIFY = "identify"
|
||||||
CHANNEL_ILLUMINANCE = "illuminance"
|
CHANNEL_ILLUMINANCE = "illuminance"
|
||||||
CHANNEL_LEVEL = ATTR_LEVEL
|
CHANNEL_LEVEL = ATTR_LEVEL
|
||||||
CHANNEL_MULTISTATE_INPUT = "multistate_input"
|
CHANNEL_MULTISTATE_INPUT = "multistate_input"
|
||||||
|
@ -24,6 +24,7 @@ from .core.const import (
|
|||||||
SIGNAL_SET_LEVEL,
|
SIGNAL_SET_LEVEL,
|
||||||
)
|
)
|
||||||
from .core.registries import ZHA_ENTITIES
|
from .core.registries import ZHA_ENTITIES
|
||||||
|
from .core.typing import ZhaDeviceType
|
||||||
from .entity import ZhaEntity
|
from .entity import ZhaEntity
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@ -37,6 +38,8 @@ UPDATE_COLORLOOP_DIRECTION = 0x2
|
|||||||
UPDATE_COLORLOOP_TIME = 0x4
|
UPDATE_COLORLOOP_TIME = 0x4
|
||||||
UPDATE_COLORLOOP_HUE = 0x8
|
UPDATE_COLORLOOP_HUE = 0x8
|
||||||
|
|
||||||
|
FLASH_EFFECTS = {light.FLASH_SHORT: 0x00, light.FLASH_LONG: 0x01}
|
||||||
|
|
||||||
UNSUPPORTED_ATTRIBUTE = 0x86
|
UNSUPPORTED_ATTRIBUTE = 0x86
|
||||||
SCAN_INTERVAL = timedelta(minutes=60)
|
SCAN_INTERVAL = timedelta(minutes=60)
|
||||||
STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, light.DOMAIN)
|
STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, light.DOMAIN)
|
||||||
@ -61,7 +64,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
|
|||||||
class Light(ZhaEntity, light.Light):
|
class Light(ZhaEntity, light.Light):
|
||||||
"""Representation of a ZHA or ZLL light."""
|
"""Representation of a ZHA or ZLL light."""
|
||||||
|
|
||||||
def __init__(self, unique_id, zha_device, channels, **kwargs):
|
def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs):
|
||||||
"""Initialize the ZHA light."""
|
"""Initialize the ZHA light."""
|
||||||
super().__init__(unique_id, zha_device, channels, **kwargs)
|
super().__init__(unique_id, zha_device, channels, **kwargs)
|
||||||
self._supported_features = 0
|
self._supported_features = 0
|
||||||
@ -74,6 +77,7 @@ class Light(ZhaEntity, light.Light):
|
|||||||
self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF)
|
self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF)
|
||||||
self._level_channel = self.cluster_channels.get(CHANNEL_LEVEL)
|
self._level_channel = self.cluster_channels.get(CHANNEL_LEVEL)
|
||||||
self._color_channel = self.cluster_channels.get(CHANNEL_COLOR)
|
self._color_channel = self.cluster_channels.get(CHANNEL_COLOR)
|
||||||
|
self._identify_channel = self.zha_device.channels.identify_ch
|
||||||
|
|
||||||
if self._level_channel:
|
if self._level_channel:
|
||||||
self._supported_features |= light.SUPPORT_BRIGHTNESS
|
self._supported_features |= light.SUPPORT_BRIGHTNESS
|
||||||
@ -93,6 +97,9 @@ class Light(ZhaEntity, light.Light):
|
|||||||
self._supported_features |= light.SUPPORT_EFFECT
|
self._supported_features |= light.SUPPORT_EFFECT
|
||||||
self._effect_list.append(light.EFFECT_COLORLOOP)
|
self._effect_list.append(light.EFFECT_COLORLOOP)
|
||||||
|
|
||||||
|
if self._identify_channel:
|
||||||
|
self._supported_features |= light.SUPPORT_FLASH
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Return true if entity is on."""
|
"""Return true if entity is on."""
|
||||||
@ -188,6 +195,7 @@ class Light(ZhaEntity, light.Light):
|
|||||||
duration = transition * 10 if transition else 0
|
duration = transition * 10 if transition else 0
|
||||||
brightness = kwargs.get(light.ATTR_BRIGHTNESS)
|
brightness = kwargs.get(light.ATTR_BRIGHTNESS)
|
||||||
effect = kwargs.get(light.ATTR_EFFECT)
|
effect = kwargs.get(light.ATTR_EFFECT)
|
||||||
|
flash = kwargs.get(light.ATTR_FLASH)
|
||||||
|
|
||||||
if brightness is None and self._off_brightness is not None:
|
if brightness is None and self._off_brightness is not None:
|
||||||
brightness = self._off_brightness
|
brightness = self._off_brightness
|
||||||
@ -277,6 +285,12 @@ class Light(ZhaEntity, light.Light):
|
|||||||
t_log["color_loop_set"] = result
|
t_log["color_loop_set"] = result
|
||||||
self._effect = None
|
self._effect = None
|
||||||
|
|
||||||
|
if flash is not None and self._supported_features & light.SUPPORT_FLASH:
|
||||||
|
result = await self._identify_channel.trigger_effect(
|
||||||
|
FLASH_EFFECTS[flash], 0 # effect identifier, effect variant
|
||||||
|
)
|
||||||
|
t_log["trigger_effect"] = result
|
||||||
|
|
||||||
self._off_brightness = None
|
self._off_brightness = None
|
||||||
self.debug("turned on: %s", t_log)
|
self.debug("turned on: %s", t_log)
|
||||||
self.async_schedule_update_ha_state()
|
self.async_schedule_update_ha_state()
|
||||||
|
@ -9,7 +9,8 @@ import zigpy.zcl.clusters.general as general
|
|||||||
import zigpy.zcl.clusters.lighting as lighting
|
import zigpy.zcl.clusters.lighting as lighting
|
||||||
import zigpy.zcl.foundation as zcl_f
|
import zigpy.zcl.foundation as zcl_f
|
||||||
|
|
||||||
from homeassistant.components.light import DOMAIN
|
from homeassistant.components.light import DOMAIN, FLASH_LONG, FLASH_SHORT
|
||||||
|
from homeassistant.components.zha.light import FLASH_EFFECTS
|
||||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE
|
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
@ -26,7 +27,11 @@ OFF = 0
|
|||||||
LIGHT_ON_OFF = {
|
LIGHT_ON_OFF = {
|
||||||
1: {
|
1: {
|
||||||
"device_type": zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT,
|
"device_type": zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT,
|
||||||
"in_clusters": [general.Basic.cluster_id, general.OnOff.cluster_id],
|
"in_clusters": [
|
||||||
|
general.Basic.cluster_id,
|
||||||
|
general.Identify.cluster_id,
|
||||||
|
general.OnOff.cluster_id,
|
||||||
|
],
|
||||||
"out_clusters": [general.Ota.cluster_id],
|
"out_clusters": [general.Ota.cluster_id],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -48,6 +53,7 @@ LIGHT_COLOR = {
|
|||||||
"device_type": zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT,
|
"device_type": zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT,
|
||||||
"in_clusters": [
|
"in_clusters": [
|
||||||
general.Basic.cluster_id,
|
general.Basic.cluster_id,
|
||||||
|
general.Identify.cluster_id,
|
||||||
general.LevelControl.cluster_id,
|
general.LevelControl.cluster_id,
|
||||||
general.OnOff.cluster_id,
|
general.OnOff.cluster_id,
|
||||||
lighting.Color.cluster_id,
|
lighting.Color.cluster_id,
|
||||||
@ -61,6 +67,10 @@ LIGHT_COLOR = {
|
|||||||
"zigpy.zcl.clusters.lighting.Color.request",
|
"zigpy.zcl.clusters.lighting.Color.request",
|
||||||
new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
|
new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
|
||||||
)
|
)
|
||||||
|
@asynctest.patch(
|
||||||
|
"zigpy.zcl.clusters.general.Identify.request",
|
||||||
|
new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
|
||||||
|
)
|
||||||
@asynctest.patch(
|
@asynctest.patch(
|
||||||
"zigpy.zcl.clusters.general.LevelControl.request",
|
"zigpy.zcl.clusters.general.LevelControl.request",
|
||||||
new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
|
new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
|
||||||
@ -88,6 +98,7 @@ async def test_light(
|
|||||||
cluster_on_off = zigpy_device.endpoints[1].on_off
|
cluster_on_off = zigpy_device.endpoints[1].on_off
|
||||||
cluster_level = getattr(zigpy_device.endpoints[1], "level", None)
|
cluster_level = getattr(zigpy_device.endpoints[1], "level", None)
|
||||||
cluster_color = getattr(zigpy_device.endpoints[1], "light_color", None)
|
cluster_color = getattr(zigpy_device.endpoints[1], "light_color", None)
|
||||||
|
cluster_identify = getattr(zigpy_device.endpoints[1], "identify", None)
|
||||||
|
|
||||||
# test that the lights were created and that they are unavailable
|
# test that the lights were created and that they are unavailable
|
||||||
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
|
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
|
||||||
@ -104,6 +115,11 @@ async def test_light(
|
|||||||
# test turning the lights on and off from the HA
|
# test turning the lights on and off from the HA
|
||||||
await async_test_on_off_from_hass(hass, cluster_on_off, entity_id)
|
await async_test_on_off_from_hass(hass, cluster_on_off, entity_id)
|
||||||
|
|
||||||
|
# test short flashing the lights from the HA
|
||||||
|
if cluster_identify:
|
||||||
|
await async_test_flash_from_hass(hass, cluster_identify, entity_id, FLASH_SHORT)
|
||||||
|
|
||||||
|
# test turning the lights on and off from the HA
|
||||||
if cluster_level:
|
if cluster_level:
|
||||||
await async_test_level_on_off_from_hass(
|
await async_test_level_on_off_from_hass(
|
||||||
hass, cluster_on_off, cluster_level, entity_id
|
hass, cluster_on_off, cluster_level, entity_id
|
||||||
@ -124,6 +140,10 @@ async def test_light(
|
|||||||
clusters.append(cluster_color)
|
clusters.append(cluster_color)
|
||||||
await async_test_rejoin(hass, zigpy_device, clusters, reporting)
|
await async_test_rejoin(hass, zigpy_device, clusters, reporting)
|
||||||
|
|
||||||
|
# test long flashing the lights from the HA
|
||||||
|
if cluster_identify:
|
||||||
|
await async_test_flash_from_hass(hass, cluster_identify, entity_id, FLASH_LONG)
|
||||||
|
|
||||||
|
|
||||||
async def async_test_on_off_from_light(hass, cluster, entity_id):
|
async def async_test_on_off_from_light(hass, cluster, entity_id):
|
||||||
"""Test on off functionality from the light."""
|
"""Test on off functionality from the light."""
|
||||||
@ -197,7 +217,7 @@ async def async_test_level_on_off_from_hass(
|
|||||||
assert level_cluster.request.call_count == 0
|
assert level_cluster.request.call_count == 0
|
||||||
assert level_cluster.request.await_count == 0
|
assert level_cluster.request.await_count == 0
|
||||||
assert on_off_cluster.request.call_args == call(
|
assert on_off_cluster.request.call_args == call(
|
||||||
False, 1, (), expect_reply=True, manufacturer=None
|
False, ON, (), expect_reply=True, manufacturer=None
|
||||||
)
|
)
|
||||||
on_off_cluster.request.reset_mock()
|
on_off_cluster.request.reset_mock()
|
||||||
level_cluster.request.reset_mock()
|
level_cluster.request.reset_mock()
|
||||||
@ -210,7 +230,7 @@ async def async_test_level_on_off_from_hass(
|
|||||||
assert level_cluster.request.call_count == 1
|
assert level_cluster.request.call_count == 1
|
||||||
assert level_cluster.request.await_count == 1
|
assert level_cluster.request.await_count == 1
|
||||||
assert on_off_cluster.request.call_args == call(
|
assert on_off_cluster.request.call_args == call(
|
||||||
False, 1, (), expect_reply=True, manufacturer=None
|
False, ON, (), expect_reply=True, manufacturer=None
|
||||||
)
|
)
|
||||||
assert level_cluster.request.call_args == call(
|
assert level_cluster.request.call_args == call(
|
||||||
False,
|
False,
|
||||||
@ -232,7 +252,7 @@ async def async_test_level_on_off_from_hass(
|
|||||||
assert level_cluster.request.call_count == 1
|
assert level_cluster.request.call_count == 1
|
||||||
assert level_cluster.request.await_count == 1
|
assert level_cluster.request.await_count == 1
|
||||||
assert on_off_cluster.request.call_args == call(
|
assert on_off_cluster.request.call_args == call(
|
||||||
False, 1, (), expect_reply=True, manufacturer=None
|
False, ON, (), expect_reply=True, manufacturer=None
|
||||||
)
|
)
|
||||||
assert level_cluster.request.call_args == call(
|
assert level_cluster.request.call_args == call(
|
||||||
False,
|
False,
|
||||||
@ -260,3 +280,23 @@ async def async_test_dimmer_from_light(hass, cluster, entity_id, level, expected
|
|||||||
if level == 0:
|
if level == 0:
|
||||||
level = None
|
level = None
|
||||||
assert hass.states.get(entity_id).attributes.get("brightness") == level
|
assert hass.states.get(entity_id).attributes.get("brightness") == level
|
||||||
|
|
||||||
|
|
||||||
|
async def async_test_flash_from_hass(hass, cluster, entity_id, flash):
|
||||||
|
"""Test flash functionality from hass."""
|
||||||
|
# turn on via UI
|
||||||
|
cluster.request.reset_mock()
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN, "turn_on", {"entity_id": entity_id, "flash": flash}, blocking=True
|
||||||
|
)
|
||||||
|
assert cluster.request.call_count == 1
|
||||||
|
assert cluster.request.await_count == 1
|
||||||
|
assert cluster.request.call_args == call(
|
||||||
|
False,
|
||||||
|
64,
|
||||||
|
(zigpy.types.uint8_t, zigpy.types.uint8_t),
|
||||||
|
FLASH_EFFECTS[flash],
|
||||||
|
0,
|
||||||
|
expect_reply=True,
|
||||||
|
manufacturer=None,
|
||||||
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user