Group by endpoints and not by devices for ZHA Zigbee groups (#34583)

* start implementation
* handle members correctly
* fix group member info
* align groupable devices with group members
* handle group endpoint adding and removing
* update add group
* update group and group member
* update create group
* remove domain check
* update test
* remove temporary 2nd groupable device api
* update test
* rename validator - review comment
* fix test that was never running
* additional testing
* fix coordinator descriptors
* remove check that was done twice
* update test
* Use AsyncMock()
Co-authored-by: Alexei Chetroi <lexoid@gmail.com>
pull/35295/head
David F. Mulcahey 2020-05-04 15:19:53 -04:00 committed by GitHub
parent e54e9279e3
commit 8279efc164
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 438 additions and 167 deletions

View File

@ -53,6 +53,7 @@ from .core.const import (
WARNING_DEVICE_STROBE_HIGH,
WARNING_DEVICE_STROBE_YES,
)
from .core.group import GroupMember
from .core.helpers import async_is_bindable_target, get_matched_clusters
_LOGGER = logging.getLogger(__name__)
@ -209,7 +210,7 @@ async def websocket_get_devices(hass, connection, msg):
"""Get ZHA devices."""
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
devices = [device.async_get_info() for device in zha_gateway.devices.values()]
devices = [device.zha_device_info for device in zha_gateway.devices.values()]
connection.send_result(msg[ID], devices)
@ -221,13 +222,35 @@ async def websocket_get_groupable_devices(hass, connection, msg):
"""Get ZHA devices that can be grouped."""
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
devices = [
device.async_get_info()
for device in zha_gateway.devices.values()
if device.is_groupable or device.is_coordinator
]
devices = [device for device in zha_gateway.devices.values() if device.is_groupable]
groupable_devices = []
connection.send_result(msg[ID], devices)
for device in devices:
entity_refs = zha_gateway.device_registry.get(device.ieee)
for ep_id in device.async_get_groupable_endpoints():
groupable_devices.append(
{
"endpoint_id": ep_id,
"entities": [
{
"name": zha_gateway.ha_entity_registry.async_get(
entity_ref.reference_id
).name,
"original_name": zha_gateway.ha_entity_registry.async_get(
entity_ref.reference_id
).original_name,
}
for entity_ref in entity_refs
if list(entity_ref.cluster_channels.values())[
0
].cluster.endpoint.endpoint_id
== ep_id
],
"device": device.zha_device_info,
}
)
connection.send_result(msg[ID], groupable_devices)
@websocket_api.require_admin
@ -236,7 +259,7 @@ async def websocket_get_groupable_devices(hass, connection, msg):
async def websocket_get_groups(hass, connection, msg):
"""Get ZHA groups."""
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
groups = [group.async_get_info() for group in zha_gateway.groups.values()]
groups = [group.group_info for group in zha_gateway.groups.values()]
connection.send_result(msg[ID], groups)
@ -251,7 +274,7 @@ async def websocket_get_device(hass, connection, msg):
ieee = msg[ATTR_IEEE]
device = None
if ieee in zha_gateway.devices:
device = zha_gateway.devices[ieee].async_get_info()
device = zha_gateway.devices[ieee].zha_device_info
if not device:
connection.send_message(
websocket_api.error_message(
@ -274,7 +297,7 @@ async def websocket_get_group(hass, connection, msg):
group = None
if group_id in zha_gateway.groups:
group = zha_gateway.groups.get(group_id).async_get_info()
group = zha_gateway.groups.get(group_id).group_info
if not group:
connection.send_message(
websocket_api.error_message(
@ -285,13 +308,27 @@ async def websocket_get_group(hass, connection, msg):
connection.send_result(msg[ID], group)
def cv_group_member(value: Any) -> GroupMember:
"""Validate and transform a group member."""
if not isinstance(value, Mapping):
raise vol.Invalid("Not a group member")
try:
group_member = GroupMember(
ieee=EUI64.convert(value["ieee"]), endpoint_id=value["endpoint_id"]
)
except KeyError:
raise vol.Invalid("Not a group member")
return group_member
@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zha/group/add",
vol.Required(GROUP_NAME): cv.string,
vol.Optional(ATTR_MEMBERS): vol.All(cv.ensure_list, [EUI64.convert]),
vol.Optional(ATTR_MEMBERS): vol.All(cv.ensure_list, [cv_group_member]),
}
)
async def websocket_add_group(hass, connection, msg):
@ -300,7 +337,7 @@ async def websocket_add_group(hass, connection, msg):
group_name = msg[GROUP_NAME]
members = msg.get(ATTR_MEMBERS)
group = await zha_gateway.async_create_zigpy_group(group_name, members)
connection.send_result(msg[ID], group.async_get_info())
connection.send_result(msg[ID], group.group_info)
@websocket_api.require_admin
@ -323,7 +360,7 @@ async def websocket_remove_groups(hass, connection, msg):
await asyncio.gather(*tasks)
else:
await zha_gateway.async_remove_zigpy_group(group_ids[0])
ret_groups = [group.async_get_info() for group in zha_gateway.groups.values()]
ret_groups = [group.group_info for group in zha_gateway.groups.values()]
connection.send_result(msg[ID], ret_groups)
@ -333,7 +370,7 @@ async def websocket_remove_groups(hass, connection, msg):
{
vol.Required(TYPE): "zha/group/members/add",
vol.Required(GROUP_ID): cv.positive_int,
vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [EUI64.convert]),
vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [cv_group_member]),
}
)
async def websocket_add_group_members(hass, connection, msg):
@ -353,7 +390,7 @@ async def websocket_add_group_members(hass, connection, msg):
)
)
return
ret_group = zha_group.async_get_info()
ret_group = zha_group.group_info
connection.send_result(msg[ID], ret_group)
@ -363,7 +400,7 @@ async def websocket_add_group_members(hass, connection, msg):
{
vol.Required(TYPE): "zha/group/members/remove",
vol.Required(GROUP_ID): cv.positive_int,
vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [EUI64.convert]),
vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [cv_group_member]),
}
)
async def websocket_remove_group_members(hass, connection, msg):
@ -383,7 +420,7 @@ async def websocket_remove_group_members(hass, connection, msg):
)
)
return
ret_group = zha_group.async_get_info()
ret_group = zha_group.group_info
connection.send_result(msg[ID], ret_group)
@ -608,7 +645,7 @@ async def websocket_get_bindable_devices(hass, connection, msg):
source_device = zha_gateway.get_device(source_ieee)
devices = [
device.async_get_info()
device.zha_device_info
for device in zha_gateway.devices.values()
if async_is_bindable_target(source_device, device)
]

View File

@ -235,13 +235,9 @@ class ZHADevice(LogMixin):
@property
def is_groupable(self):
"""Return true if this device has a group cluster."""
if not self.available:
return False
clusters = self.async_get_clusters()
for cluster_map in clusters.values():
for clusters in cluster_map.values():
if Groups.cluster_id in clusters:
return True
return self.is_coordinator or (
self.available and self.async_get_groupable_endpoints()
)
@property
def skip_configuration(self):
@ -411,8 +407,8 @@ class ZHADevice(LogMixin):
if self._zigpy_device.last_seen is None and last_seen is not None:
self._zigpy_device.last_seen = last_seen
@callback
def async_get_info(self):
@property
def zha_device_info(self):
"""Get ZHA device information."""
device_info = {}
device_info.update(self.device_info)
@ -442,6 +438,15 @@ class ZHADevice(LogMixin):
if ep_id != 0
}
@callback
def async_get_groupable_endpoints(self):
"""Get device endpoints that have a group 'in' cluster."""
return [
ep_id
for (ep_id, clusters) in self.async_get_clusters().items()
if Groups.cluster_id in clusters[CLUSTER_TYPE_IN]
]
@callback
def async_get_std_clusters(self):
"""Get ZHA and ZLL clusters for this device."""
@ -557,7 +562,15 @@ class ZHADevice(LogMixin):
async def async_add_to_group(self, group_id):
"""Add this device to the provided zigbee group."""
await self._zigpy_device.add_to_group(group_id)
try:
await self._zigpy_device.add_to_group(group_id)
except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex:
self.debug(
"Failed to add device '%s' to group: 0x%04x ex: %s",
self._zigpy_device.ieee,
group_id,
str(ex),
)
async def async_remove_from_group(self, group_id):
"""Remove this device from the provided zigbee group."""
@ -571,6 +584,34 @@ class ZHADevice(LogMixin):
str(ex),
)
async def async_add_endpoint_to_group(self, endpoint_id, group_id):
"""Add the device endpoint to the provided zigbee group."""
try:
await self._zigpy_device.endpoints[int(endpoint_id)].add_to_group(group_id)
except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex:
self.debug(
"Failed to add endpoint: %s for device: '%s' to group: 0x%04x ex: %s",
endpoint_id,
self._zigpy_device.ieee,
group_id,
str(ex),
)
async def async_remove_endpoint_from_group(self, endpoint_id, group_id):
"""Remove the device endpoint from the provided zigbee group."""
try:
await self._zigpy_device.endpoints[int(endpoint_id)].remove_from_group(
group_id
)
except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex:
self.debug(
"Failed to remove endpoint: %s for device '%s' from group: 0x%04x ex: %s",
endpoint_id,
self._zigpy_device.ieee,
group_id,
str(ex),
)
async def async_bind_to_group(self, group_id, cluster_bindings):
"""Directly bind this device to a group for the given clusters."""
await self._async_group_binding_operation(

View File

@ -235,21 +235,13 @@ class GroupProbe:
) -> List[str]:
"""Determine the entity domains for this group."""
entity_domains: List[str] = []
if len(group.members) < 2:
_LOGGER.debug(
"Group: %s:0x%04x has less than 2 members so cannot default an entity domain",
group.name,
group.group_id,
)
return entity_domains
zha_gateway = hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY]
all_domain_occurrences = []
for device in group.members:
if device.is_coordinator:
for member in group.members:
if member.device.is_coordinator:
continue
entities = async_entries_for_device(
zha_gateway.ha_entity_registry, device.device_id
zha_gateway.ha_entity_registry, member.device.device_id
)
all_domain_occurrences.extend(
[

View File

@ -78,11 +78,11 @@ from .const import (
ZHA_GW_RADIO_DESCRIPTION,
)
from .device import DeviceStatus, ZHADevice
from .group import ZHAGroup
from .group import GroupMember, ZHAGroup
from .patches import apply_application_controller_patch
from .registries import GROUP_ENTITY_DOMAINS, RADIO_TYPES
from .store import async_get_registry
from .typing import ZhaDeviceType, ZhaGroupType, ZigpyEndpointType, ZigpyGroupType
from .typing import ZhaGroupType, ZigpyEndpointType, ZigpyGroupType
_LOGGER = logging.getLogger(__name__)
@ -308,7 +308,7 @@ class ZHAGateway:
ZHA_GW_MSG,
{
ATTR_TYPE: gateway_message_type,
ZHA_GW_MSG_GROUP_INFO: zha_group.async_get_info(),
ZHA_GW_MSG_GROUP_INFO: zha_group.group_info,
},
)
@ -327,7 +327,7 @@ class ZHAGateway:
zha_device = self._devices.pop(device.ieee, None)
entity_refs = self._device_registry.pop(device.ieee, None)
if zha_device is not None:
device_info = zha_device.async_get_info()
device_info = zha_device.zha_device_info
zha_device.async_cleanup_handles()
async_dispatcher_send(
self._hass, "{}_{}".format(SIGNAL_REMOVE, str(zha_device.ieee))
@ -542,7 +542,7 @@ class ZHAGateway:
)
await self._async_device_joined(zha_device)
device_info = zha_device.async_get_info()
device_info = zha_device.zha_device_info
async_dispatcher_send(
self._hass,
@ -571,11 +571,11 @@ class ZHAGateway:
zha_device.update_available(True)
async def async_create_zigpy_group(
self, name: str, members: List[ZhaDeviceType]
self, name: str, members: List[GroupMember]
) -> ZhaGroupType:
"""Create a new Zigpy Zigbee group."""
# we start with one to fill any gaps from a user removing existing groups
group_id = 1
# we start with two to fill any gaps from a user removing existing groups
group_id = 2
while group_id in self.groups:
group_id += 1
@ -584,14 +584,19 @@ class ZHAGateway:
self.application_controller.groups.add_group(group_id, name)
if members is not None:
tasks = []
for ieee in members:
for member in members:
_LOGGER.debug(
"Adding member with IEEE: %s to group: %s:0x%04x",
ieee,
"Adding member with IEEE: %s and endpoint id: %s to group: %s:0x%04x",
member.ieee,
member.endpoint_id,
name,
group_id,
)
tasks.append(self.devices[ieee].async_add_to_group(group_id))
tasks.append(
self.devices[member.ieee].async_add_endpoint_to_group(
member.endpoint_id, group_id
)
)
await asyncio.gather(*tasks)
return self.groups.get(group_id)
@ -604,7 +609,7 @@ class ZHAGateway:
if group and group.members:
tasks = []
for member in group.members:
tasks.append(member.async_remove_from_group(group_id))
tasks.append(member.async_remove_from_group())
if tasks:
await asyncio.gather(*tasks)
self.application_controller.groups.pop(group_id)

View File

@ -1,19 +1,110 @@
"""Group for Zigbee Home Automation."""
import asyncio
import collections
import logging
from typing import Any, Dict, List
from zigpy.types.named import EUI64
import zigpy.exceptions
from homeassistant.core import callback
from homeassistant.helpers.entity_registry import async_entries_for_device
from homeassistant.helpers.typing import HomeAssistantType
from .helpers import LogMixin
from .typing import ZhaDeviceType, ZhaGatewayType, ZigpyEndpointType, ZigpyGroupType
from .typing import (
ZhaDeviceType,
ZhaGatewayType,
ZhaGroupType,
ZigpyEndpointType,
ZigpyGroupType,
)
_LOGGER = logging.getLogger(__name__)
GroupMember = collections.namedtuple("GroupMember", "ieee endpoint_id")
GroupEntityReference = collections.namedtuple(
"GroupEntityReference", "name original_name entity_id"
)
class ZHAGroupMember(LogMixin):
"""Composite object that represents a device endpoint in a Zigbee group."""
def __init__(
self, zha_group: ZhaGroupType, zha_device: ZhaDeviceType, endpoint_id: int
):
"""Initialize the group member."""
self._zha_group: ZhaGroupType = zha_group
self._zha_device: ZhaDeviceType = zha_device
self._endpoint_id: int = endpoint_id
@property
def group(self) -> ZhaGroupType:
"""Return the group this member belongs to."""
return self._zha_group
@property
def endpoint_id(self) -> int:
"""Return the endpoint id for this group member."""
return self._endpoint_id
@property
def endpoint(self) -> ZigpyEndpointType:
"""Return the endpoint for this group member."""
return self._zha_device.device.endpoints.get(self.endpoint_id)
@property
def device(self) -> ZhaDeviceType:
"""Return the zha device for this group member."""
return self._zha_device
@property
def member_info(self) -> Dict[str, Any]:
"""Get ZHA group info."""
member_info: Dict[str, Any] = {}
member_info["endpoint_id"] = self.endpoint_id
member_info["device"] = self.device.zha_device_info
member_info["entities"] = self.associated_entities
return member_info
@property
def associated_entities(self) -> List[GroupEntityReference]:
"""Return the list of entities that were derived from this endpoint."""
ha_entity_registry = self.device.gateway.ha_entity_registry
zha_device_registry = self.device.gateway.device_registry
return [
GroupEntityReference(
ha_entity_registry.async_get(entity_ref.reference_id).name,
ha_entity_registry.async_get(entity_ref.reference_id).original_name,
entity_ref.reference_id,
)._asdict()
for entity_ref in zha_device_registry.get(self.device.ieee)
if list(entity_ref.cluster_channels.values())[
0
].cluster.endpoint.endpoint_id
== self.endpoint_id
]
async def async_remove_from_group(self) -> None:
"""Remove the device endpoint from the provided zigbee group."""
try:
await self._zha_device.device.endpoints[
self._endpoint_id
].remove_from_group(self._zha_group.group_id)
except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex:
self.debug(
"Failed to remove endpoint: %s for device '%s' from group: 0x%04x ex: %s",
self._endpoint_id,
self._zha_device.ieee,
self._zha_group.group_id,
str(ex),
)
def log(self, level: int, msg: str, *args) -> None:
"""Log a message."""
msg = f"[%s](%s): {msg}"
args = (f"0x{self._zha_group.group_id:04x}", self.endpoint_id) + args
_LOGGER.log(level, msg, *args)
class ZHAGroup(LogMixin):
"""ZHA Zigbee group object."""
@ -45,77 +136,79 @@ class ZHAGroup(LogMixin):
return self._zigpy_group.endpoint
@property
def members(self) -> List[ZhaDeviceType]:
def members(self) -> List[ZHAGroupMember]:
"""Return the ZHA devices that are members of this group."""
return [
self._zha_gateway.devices.get(member_ieee[0])
for member_ieee in self._zigpy_group.members.keys()
if member_ieee[0] in self._zha_gateway.devices
ZHAGroupMember(
self, self._zha_gateway.devices.get(member_ieee), endpoint_id
)
for (member_ieee, endpoint_id) in self._zigpy_group.members.keys()
if member_ieee in self._zha_gateway.devices
]
async def async_add_members(self, member_ieee_addresses: List[EUI64]) -> None:
async def async_add_members(self, members: List[GroupMember]) -> None:
"""Add members to this group."""
if len(member_ieee_addresses) > 1:
if len(members) > 1:
tasks = []
for ieee in member_ieee_addresses:
for member in members:
tasks.append(
self._zha_gateway.devices[ieee].async_add_to_group(self.group_id)
)
await asyncio.gather(*tasks)
else:
await self._zha_gateway.devices[
member_ieee_addresses[0]
].async_add_to_group(self.group_id)
async def async_remove_members(self, member_ieee_addresses: List[EUI64]) -> None:
"""Remove members from this group."""
if len(member_ieee_addresses) > 1:
tasks = []
for ieee in member_ieee_addresses:
tasks.append(
self._zha_gateway.devices[ieee].async_remove_from_group(
self.group_id
self._zha_gateway.devices[member.ieee].async_add_endpoint_to_group(
member.endpoint_id, self.group_id
)
)
await asyncio.gather(*tasks)
else:
await self._zha_gateway.devices[
member_ieee_addresses[0]
].async_remove_from_group(self.group_id)
members[0].ieee
].async_add_endpoint_to_group(members[0].endpoint_id, self.group_id)
async def async_remove_members(self, members: List[GroupMember]) -> None:
"""Remove members from this group."""
if len(members) > 1:
tasks = []
for member in members:
tasks.append(
self._zha_gateway.devices[
member.ieee
].async_remove_endpoint_from_group(
member.endpoint_id, self.group_id
)
)
await asyncio.gather(*tasks)
else:
await self._zha_gateway.devices[
members[0].ieee
].async_remove_endpoint_from_group(members[0].endpoint_id, self.group_id)
@property
def member_entity_ids(self) -> List[str]:
"""Return the ZHA entity ids for all entities for the members of this group."""
all_entity_ids: List[str] = []
for device in self.members:
entities = async_entries_for_device(
self._zha_gateway.ha_entity_registry, device.device_id
)
for entity in entities:
all_entity_ids.append(entity.entity_id)
for member in self.members:
entity_references = member.associated_entities
for entity_reference in entity_references:
all_entity_ids.append(entity_reference["entity_id"])
return all_entity_ids
def get_domain_entity_ids(self, domain) -> List[str]:
"""Return entity ids from the entity domain for this group."""
domain_entity_ids: List[str] = []
for device in self.members:
for member in self.members:
entities = async_entries_for_device(
self._zha_gateway.ha_entity_registry, device.device_id
self._zha_gateway.ha_entity_registry, member.device.device_id
)
domain_entity_ids.extend(
[entity.entity_id for entity in entities if entity.domain == domain]
)
return domain_entity_ids
@callback
def async_get_info(self) -> Dict[str, Any]:
@property
def group_info(self) -> Dict[str, Any]:
"""Get ZHA group info."""
group_info: Dict[str, Any] = {}
group_info["group_id"] = self.group_id
group_info["name"] = self.name
group_info["members"] = [
zha_device.async_get_info() for zha_device in self.members
]
group_info["members"] = [member.member_info for member in self.members]
return group_info
def log(self, level: int, msg: str, *args):

View File

@ -32,7 +32,7 @@ class FakeEndpoint:
self.model = model
self.profile_id = zigpy.profiles.zha.PROFILE_ID
self.device_type = None
self.request = AsyncMock()
self.request = AsyncMock(return_value=[0])
def add_input_cluster(self, cluster_id):
"""Add an input cluster."""
@ -60,6 +60,7 @@ class FakeEndpoint:
FakeEndpoint.add_to_group = zigpy_ep.add_to_group
FakeEndpoint.remove_from_group = zigpy_ep.remove_from_group
def patch_cluster(cluster):

View File

@ -244,21 +244,26 @@ async def test_list_groupable_devices(zha_client, device_groupable):
assert msg["id"] == 10
assert msg["type"] == const.TYPE_RESULT
devices = msg["result"]
assert len(devices) == 1
device_endpoints = msg["result"]
assert len(device_endpoints) == 1
for device in devices:
assert device[ATTR_IEEE] == "01:2d:6f:00:0a:90:69:e8"
assert device[ATTR_MANUFACTURER] is not None
assert device[ATTR_MODEL] is not None
assert device[ATTR_NAME] is not None
assert device[ATTR_QUIRK_APPLIED] is not None
assert device["entities"] is not None
for endpoint in device_endpoints:
assert endpoint["device"][ATTR_IEEE] == "01:2d:6f:00:0a:90:69:e8"
assert endpoint["device"][ATTR_MANUFACTURER] is not None
assert endpoint["device"][ATTR_MODEL] is not None
assert endpoint["device"][ATTR_NAME] is not None
assert endpoint["device"][ATTR_QUIRK_APPLIED] is not None
assert endpoint["device"]["entities"] is not None
assert endpoint["endpoint_id"] is not None
assert endpoint["entities"] is not None
for entity_reference in device["entities"]:
for entity_reference in endpoint["device"]["entities"]:
assert entity_reference[ATTR_NAME] is not None
assert entity_reference["entity_id"] is not None
for entity_reference in endpoint["entities"]:
assert entity_reference["original_name"] is not None
# Make sure there are no groupable devices when the device is unavailable
# Make device unavailable
device_groupable.set_available(False)
@ -269,8 +274,8 @@ async def test_list_groupable_devices(zha_client, device_groupable):
assert msg["id"] == 11
assert msg["type"] == const.TYPE_RESULT
devices = msg["result"]
assert len(devices) == 0
device_endpoints = msg["result"]
assert len(device_endpoints) == 0
async def test_add_group(zha_client):

View File

@ -62,6 +62,7 @@ async def coordinator(hass, zigpy_device_mock, zha_device_joined):
},
ieee="00:15:8d:00:02:32:4f:32",
nwk=0x0000,
node_descriptor=b"\xf8\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff",
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.set_available(True)

View File

@ -1,6 +1,8 @@
"""Test ZHA Gateway."""
import asyncio
import logging
import time
from unittest.mock import patch
import pytest
import zigpy.profiles.zha as zha
@ -8,6 +10,7 @@ import zigpy.zcl.clusters.general as general
import zigpy.zcl.clusters.lighting as lighting
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.zha.core.group import GroupMember
from .common import async_enable_traffic, async_find_group_entity_id, get_zha_gateway
@ -52,6 +55,7 @@ async def coordinator(hass, zigpy_device_mock, zha_device_joined):
},
ieee="00:15:8d:00:02:32:4f:32",
nwk=0x0000,
node_descriptor=b"\xf8\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff",
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.set_available(True)
@ -127,17 +131,16 @@ async def test_gateway_group_methods(hass, device_light_1, device_light_2, coord
device_light_1._zha_gateway = zha_gateway
device_light_2._zha_gateway = zha_gateway
member_ieee_addresses = [device_light_1.ieee, device_light_2.ieee]
members = [GroupMember(device_light_1.ieee, 1), GroupMember(device_light_2.ieee, 1)]
# test creating a group with 2 members
zha_group = await zha_gateway.async_create_zigpy_group(
"Test Group", member_ieee_addresses
)
zha_group = await zha_gateway.async_create_zigpy_group("Test Group", members)
await hass.async_block_till_done()
assert zha_group is not None
assert len(zha_group.members) == 2
for member in zha_group.members:
assert member.ieee in member_ieee_addresses
assert member.device.ieee in member_ieee_addresses
entity_id = async_find_group_entity_id(hass, LIGHT_DOMAIN, zha_group)
assert hass.states.get(entity_id) is not None
@ -157,18 +160,24 @@ async def test_gateway_group_methods(hass, device_light_1, device_light_2, coord
# test creating a group with 1 member
zha_group = await zha_gateway.async_create_zigpy_group(
"Test Group", [device_light_1.ieee]
"Test Group", [GroupMember(device_light_1.ieee, 1)]
)
await hass.async_block_till_done()
assert zha_group is not None
assert len(zha_group.members) == 1
for member in zha_group.members:
assert member.ieee in [device_light_1.ieee]
assert member.device.ieee in [device_light_1.ieee]
# the group entity should not have been cleaned up
assert entity_id not in hass.states.async_entity_ids(LIGHT_DOMAIN)
with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError):
await zha_group.members[0].async_remove_from_group()
assert len(zha_group.members) == 1
for member in zha_group.members:
assert member.device.ieee in [device_light_1.ieee]
async def test_updating_device_store(hass, zigpy_dev_basic, zha_dev_basic):
"""Test saving data after a delay."""

View File

@ -9,6 +9,7 @@ import zigpy.zcl.clusters.lighting as lighting
import zigpy.zcl.foundation as zcl_f
from homeassistant.components.light import DOMAIN, FLASH_LONG, FLASH_SHORT
from homeassistant.components.zha.core.group import GroupMember
from homeassistant.components.zha.light import FLASH_EFFECTS
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE
import homeassistant.util.dt as dt_util
@ -28,8 +29,8 @@ from tests.common import async_fire_time_changed
ON = 1
OFF = 0
IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8"
IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8"
IEEE_GROUPABLE_DEVICE3 = "03:2d:6f:00:0a:90:69:e8"
IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e9"
IEEE_GROUPABLE_DEVICE3 = "03:2d:6f:00:0a:90:69:e7"
LIGHT_ON_OFF = {
1: {
@ -77,13 +78,14 @@ async def coordinator(hass, zigpy_device_mock, zha_device_joined):
zigpy_device = zigpy_device_mock(
{
1: {
"in_clusters": [],
"in_clusters": [general.Groups.cluster_id],
"out_clusters": [],
"device_type": zha.DeviceType.COLOR_DIMMABLE_LIGHT,
}
},
ieee="00:15:8d:00:02:32:4f:32",
nwk=0x0000,
node_descriptor=b"\xf8\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff",
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.set_available(True)
@ -109,6 +111,7 @@ async def device_light_1(hass, zigpy_device_mock, zha_device_joined):
}
},
ieee=IEEE_GROUPABLE_DEVICE,
nwk=0xB79D,
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.set_available(True)
@ -134,6 +137,7 @@ async def device_light_2(hass, zigpy_device_mock, zha_device_joined):
}
},
ieee=IEEE_GROUPABLE_DEVICE2,
nwk=0xC79E,
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.set_available(True)
@ -159,6 +163,7 @@ async def device_light_3(hass, zigpy_device_mock, zha_device_joined):
}
},
ieee=IEEE_GROUPABLE_DEVICE3,
nwk=0xB89F,
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.set_available(True)
@ -289,10 +294,12 @@ async def async_test_on_off_from_light(hass, cluster, entity_id):
"""Test on off functionality from the light."""
# turn on at light
await send_attributes_report(hass, cluster, {1: 0, 0: 1, 2: 3})
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ON
# turn off at light
await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 3})
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_OFF
@ -300,6 +307,7 @@ async def async_test_on_from_light(hass, cluster, entity_id):
"""Test on off functionality from the light."""
# turn on at light
await send_attributes_report(hass, cluster, {1: -1, 0: 1, 2: 2})
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ON
@ -410,6 +418,7 @@ async def async_test_dimmer_from_light(hass, cluster, entity_id, level, expected
await send_attributes_report(
hass, cluster, {1: level + 10, 0: level, 2: level - 10 or 22}
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == expected_state
# hass uses None for brightness of 0 in state attributes
if level == 0:
@ -438,7 +447,23 @@ async def async_test_flash_from_hass(hass, cluster, entity_id, flash):
)
async def async_test_zha_group_light_entity(
@patch(
"zigpy.zcl.clusters.lighting.Color.request",
new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
)
@patch(
"zigpy.zcl.clusters.general.Identify.request",
new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
)
@patch(
"zigpy.zcl.clusters.general.LevelControl.request",
new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
)
@patch(
"zigpy.zcl.clusters.general.OnOff.request",
new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
)
async def test_zha_group_light_entity(
hass, device_light_1, device_light_2, device_light_3, coordinator
):
"""Test the light entity for a ZHA group."""
@ -449,119 +474,180 @@ async def async_test_zha_group_light_entity(
device_light_1._zha_gateway = zha_gateway
device_light_2._zha_gateway = zha_gateway
member_ieee_addresses = [device_light_1.ieee, device_light_2.ieee]
members = [GroupMember(device_light_1.ieee, 1), GroupMember(device_light_2.ieee, 1)]
assert coordinator.is_coordinator
# test creating a group with 2 members
zha_group = await zha_gateway.async_create_zigpy_group(
"Test Group", member_ieee_addresses
)
zha_group = await zha_gateway.async_create_zigpy_group("Test Group", members)
await hass.async_block_till_done()
assert zha_group is not None
assert len(zha_group.members) == 2
for member in zha_group.members:
assert member.ieee in member_ieee_addresses
assert member.device.ieee in member_ieee_addresses
assert member.group == zha_group
assert member.endpoint is not None
entity_id = async_find_group_entity_id(hass, DOMAIN, zha_group)
assert hass.states.get(entity_id) is not None
device_1_entity_id = await find_entity_id(DOMAIN, device_light_1, hass)
device_2_entity_id = await find_entity_id(DOMAIN, device_light_2, hass)
device_3_entity_id = await find_entity_id(DOMAIN, device_light_3, hass)
assert (
device_1_entity_id != device_2_entity_id
and device_1_entity_id != device_3_entity_id
)
assert device_2_entity_id != device_3_entity_id
group_entity_id = async_find_group_entity_id(hass, DOMAIN, zha_group)
assert hass.states.get(group_entity_id) is not None
assert device_1_entity_id in zha_group.member_entity_ids
assert device_2_entity_id in zha_group.member_entity_ids
assert device_3_entity_id not in zha_group.member_entity_ids
group_cluster_on_off = zha_group.endpoint[general.OnOff.cluster_id]
group_cluster_level = zha_group.endpoint[general.LevelControl.cluster_id]
group_cluster_identify = zha_group.endpoint[general.Identify.cluster_id]
dev1_cluster_on_off = device_light_1.endpoints[1].on_off
dev2_cluster_on_off = device_light_2.endpoints[1].on_off
dev3_cluster_on_off = device_light_3.endpoints[1].on_off
dev1_cluster_on_off = device_light_1.device.endpoints[1].on_off
dev2_cluster_on_off = device_light_2.device.endpoints[1].on_off
dev3_cluster_on_off = device_light_3.device.endpoints[1].on_off
dev1_cluster_level = device_light_1.device.endpoints[1].level
# test that the lights were created and that they are unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
assert hass.states.get(group_entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, zha_group.members)
await async_enable_traffic(hass, [device_light_1, device_light_2, device_light_3])
await hass.async_block_till_done()
# test that the lights were created and are off
assert hass.states.get(entity_id).state == STATE_OFF
# test turning the lights on and off from the light
await async_test_on_off_from_light(hass, group_cluster_on_off, entity_id)
assert hass.states.get(group_entity_id).state == STATE_OFF
# test turning the lights on and off from the HA
await async_test_on_off_from_hass(hass, group_cluster_on_off, entity_id)
await async_test_on_off_from_hass(hass, group_cluster_on_off, group_entity_id)
# test short flashing the lights from the HA
await async_test_flash_from_hass(
hass, group_cluster_identify, entity_id, FLASH_SHORT
hass, group_cluster_identify, group_entity_id, FLASH_SHORT
)
# test turning the lights on and off from the light
await async_test_on_off_from_light(hass, dev1_cluster_on_off, group_entity_id)
# test turning the lights on and off from the HA
await async_test_level_on_off_from_hass(
hass, group_cluster_on_off, group_cluster_level, entity_id
hass, group_cluster_on_off, group_cluster_level, group_entity_id
)
# test getting a brightness change from the network
await async_test_on_from_light(hass, group_cluster_on_off, entity_id)
await async_test_on_from_light(hass, dev1_cluster_on_off, group_entity_id)
await async_test_dimmer_from_light(
hass, group_cluster_level, entity_id, 150, STATE_ON
hass, dev1_cluster_level, group_entity_id, 150, STATE_ON
)
# test long flashing the lights from the HA
await async_test_flash_from_hass(
hass, group_cluster_identify, entity_id, FLASH_LONG
hass, group_cluster_identify, group_entity_id, FLASH_LONG
)
assert len(zha_group.members) == 2
# test some of the group logic to make sure we key off states correctly
await dev1_cluster_on_off.on()
await dev2_cluster_on_off.on()
await send_attributes_report(hass, dev1_cluster_on_off, {0: 1})
await send_attributes_report(hass, dev2_cluster_on_off, {0: 1})
await hass.async_block_till_done()
# test that group light is on
assert hass.states.get(entity_id).state == STATE_ON
assert hass.states.get(device_1_entity_id).state == STATE_ON
assert hass.states.get(device_2_entity_id).state == STATE_ON
assert hass.states.get(group_entity_id).state == STATE_ON
await dev1_cluster_on_off.off()
await send_attributes_report(hass, dev1_cluster_on_off, {0: 0})
await hass.async_block_till_done()
# test that group light is still on
assert hass.states.get(entity_id).state == STATE_ON
assert hass.states.get(device_1_entity_id).state == STATE_OFF
assert hass.states.get(device_2_entity_id).state == STATE_ON
assert hass.states.get(group_entity_id).state == STATE_ON
await dev2_cluster_on_off.off()
await send_attributes_report(hass, dev2_cluster_on_off, {0: 0})
await hass.async_block_till_done()
# test that group light is now off
assert hass.states.get(entity_id).state == STATE_OFF
assert hass.states.get(device_1_entity_id).state == STATE_OFF
assert hass.states.get(device_2_entity_id).state == STATE_OFF
assert hass.states.get(group_entity_id).state == STATE_OFF
await dev1_cluster_on_off.on()
await send_attributes_report(hass, dev1_cluster_on_off, {0: 1})
await hass.async_block_till_done()
# test that group light is now back on
assert hass.states.get(entity_id).state == STATE_ON
assert hass.states.get(device_1_entity_id).state == STATE_ON
assert hass.states.get(device_2_entity_id).state == STATE_OFF
assert hass.states.get(group_entity_id).state == STATE_ON
# test that group light is now off
await group_cluster_on_off.off()
assert hass.states.get(entity_id).state == STATE_OFF
# turn it off to test a new member add being tracked
await send_attributes_report(hass, dev1_cluster_on_off, {0: 0})
await hass.async_block_till_done()
assert hass.states.get(device_1_entity_id).state == STATE_OFF
assert hass.states.get(device_2_entity_id).state == STATE_OFF
assert hass.states.get(group_entity_id).state == STATE_OFF
# add a new member and test that his state is also tracked
await zha_group.async_add_members([device_light_3.ieee])
await dev3_cluster_on_off.on()
assert hass.states.get(entity_id).state == STATE_ON
await zha_group.async_add_members([GroupMember(device_light_3.ieee, 1)])
await send_attributes_report(hass, dev3_cluster_on_off, {0: 1})
await hass.async_block_till_done()
assert device_3_entity_id in zha_group.member_entity_ids
assert len(zha_group.members) == 3
assert hass.states.get(device_1_entity_id).state == STATE_OFF
assert hass.states.get(device_2_entity_id).state == STATE_OFF
assert hass.states.get(device_3_entity_id).state == STATE_ON
assert hass.states.get(group_entity_id).state == STATE_ON
# make the group have only 1 member and now there should be no entity
await zha_group.async_remove_members([device_light_2.ieee, device_light_3.ieee])
await zha_group.async_remove_members(
[GroupMember(device_light_2.ieee, 1), GroupMember(device_light_3.ieee, 1)]
)
assert len(zha_group.members) == 1
assert hass.states.get(entity_id).state is None
assert hass.states.get(group_entity_id) is None
assert device_2_entity_id not in zha_group.member_entity_ids
assert device_3_entity_id not in zha_group.member_entity_ids
# make sure the entity registry entry is still there
assert zha_gateway.ha_entity_registry.async_get(entity_id) is not None
assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is not None
# add a member back and ensure that the group entity was created again
await zha_group.async_add_members([device_light_3.ieee])
await dev3_cluster_on_off.on()
assert hass.states.get(entity_id).state == STATE_ON
await zha_group.async_add_members([GroupMember(device_light_3.ieee, 1)])
await send_attributes_report(hass, dev3_cluster_on_off, {0: 1})
await hass.async_block_till_done()
assert len(zha_group.members) == 2
assert hass.states.get(group_entity_id).state == STATE_ON
# add a 3rd member and ensure we still have an entity and we track the new one
await dev1_cluster_on_off.off()
await dev3_cluster_on_off.off()
assert hass.states.get(entity_id).state == STATE_OFF
await send_attributes_report(hass, dev1_cluster_on_off, {0: 0})
await send_attributes_report(hass, dev3_cluster_on_off, {0: 0})
await hass.async_block_till_done()
assert hass.states.get(group_entity_id).state == STATE_OFF
# this will test that _reprobe_group is used correctly
await zha_group.async_add_members([device_light_2.ieee])
await dev2_cluster_on_off.on()
assert hass.states.get(entity_id).state == STATE_ON
await zha_group.async_add_members(
[GroupMember(device_light_2.ieee, 1), GroupMember(coordinator.ieee, 1)]
)
await send_attributes_report(hass, dev2_cluster_on_off, {0: 1})
await hass.async_block_till_done()
assert len(zha_group.members) == 4
assert hass.states.get(group_entity_id).state == STATE_ON
await zha_group.async_remove_members([GroupMember(coordinator.ieee, 1)])
await hass.async_block_till_done()
assert hass.states.get(group_entity_id).state == STATE_ON
assert len(zha_group.members) == 3
# remove the group and ensure that there is no entity and that the entity registry is cleaned up
assert zha_gateway.ha_entity_registry.async_get(entity_id) is not None
assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is not None
await zha_gateway.async_remove_zigpy_group(zha_group.group_id)
assert hass.states.get(entity_id).state is None
assert zha_gateway.ha_entity_registry.async_get(entity_id) is None
assert hass.states.get(group_entity_id) is None
assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is None

View File

@ -53,6 +53,7 @@ async def coordinator(hass, zigpy_device_mock, zha_device_joined):
},
ieee="00:15:8d:00:02:32:4f:32",
nwk=0x0000,
node_descriptor=b"\xf8\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff",
)
zha_device = await zha_device_joined(zigpy_device)
zha_device.set_available(True)