From 8279efc16473e047b5d6952ab6e45fb6b8d51148 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 4 May 2020 15:19:53 -0400 Subject: [PATCH] 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 --- homeassistant/components/zha/api.py | 73 +++++-- homeassistant/components/zha/core/device.py | 61 +++++- .../components/zha/core/discovery.py | 14 +- homeassistant/components/zha/core/gateway.py | 31 +-- homeassistant/components/zha/core/group.py | 175 ++++++++++++---- tests/components/zha/common.py | 3 +- tests/components/zha/test_api.py | 29 +-- tests/components/zha/test_fan.py | 1 + tests/components/zha/test_gateway.py | 21 +- tests/components/zha/test_light.py | 196 +++++++++++++----- tests/components/zha/test_switch.py | 1 + 11 files changed, 438 insertions(+), 167 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 232a5666300..1ba9ada5413 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -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) ] diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index b4947d121e4..fcbf518a9db 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -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( diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 4540c9158de..f72ac2161ec 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -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( [ diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index e97e2185dc5..b8efdf873b1 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -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) diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py index 4fc86012d1a..2961f335989 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -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): diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 4efc6538d7c..b16ae1d488e 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -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): diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index b67a39cd3ab..88fd1e8437f 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -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): diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index b110656c1dd..91819e6f457 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -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) diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index c5ae9142ff0..379e4d56492 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -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.""" diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index a1a081d7e8b..09c6d97808c 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -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 diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index ed5d228ab88..7bdf2ccc4d2 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -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)