Add ZHA group API (#29641)

* add skeleton to retrieve zigbee groups
* get single group
* add a group
* return group members with group
* add comment
* fix group members
* add function to add device to group
* add group members
* add remove from group method
* add api to remove members from group
* add remove groups method
* clean up group add and remove
* fix remove group
* fix remove groups
* add api to get only groupable devices
* change var init
* add tests
* address review comment
This commit is contained in:
David F. Mulcahey 2019-12-09 14:50:04 -05:00 committed by Alexei Chetroi
parent 7f948594eb
commit 1222aa8c56
6 changed files with 461 additions and 3 deletions

View File

@ -23,6 +23,7 @@ from .core.const import (
ATTR_ENDPOINT_ID, ATTR_ENDPOINT_ID,
ATTR_LEVEL, ATTR_LEVEL,
ATTR_MANUFACTURER, ATTR_MANUFACTURER,
ATTR_MEMBERS,
ATTR_NAME, ATTR_NAME,
ATTR_VALUE, ATTR_VALUE,
ATTR_WARNING_DEVICE_DURATION, ATTR_WARNING_DEVICE_DURATION,
@ -39,6 +40,9 @@ from .core.const import (
DATA_ZHA, DATA_ZHA,
DATA_ZHA_GATEWAY, DATA_ZHA_GATEWAY,
DOMAIN, DOMAIN,
GROUP_ID,
GROUP_IDS,
GROUP_NAME,
MFG_CLUSTER_ID_START, MFG_CLUSTER_ID_START,
WARNING_DEVICE_MODE_EMERGENCY, WARNING_DEVICE_MODE_EMERGENCY,
WARNING_DEVICE_SOUND_HIGH, WARNING_DEVICE_SOUND_HIGH,
@ -211,6 +215,34 @@ async def websocket_get_devices(hass, connection, msg):
connection.send_result(msg[ID], devices) connection.send_result(msg[ID], devices)
@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices/groupable"})
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]
ha_device_registry = await async_get_registry(hass)
devices = []
for device in zha_gateway.devices.values():
if device.is_groupable:
devices.append(
async_get_device_info(
hass, device, ha_device_registry=ha_device_registry
)
)
connection.send_result(msg[ID], devices)
@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command({vol.Required(TYPE): "zha/groups"})
async def websocket_get_groups(hass, connection, msg):
"""Get ZHA groups."""
groups = await get_groups(hass)
connection.send_result(msg[ID], groups)
@websocket_api.require_admin @websocket_api.require_admin
@websocket_api.async_response @websocket_api.async_response
@websocket_api.websocket_command( @websocket_api.websocket_command(
@ -236,6 +268,161 @@ async def websocket_get_device(hass, connection, msg):
connection.send_result(msg[ID], device) connection.send_result(msg[ID], device)
@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{vol.Required(TYPE): "zha/group", vol.Required(GROUP_ID): cv.positive_int}
)
async def websocket_get_group(hass, connection, msg):
"""Get ZHA group."""
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
ha_device_registry = await async_get_registry(hass)
group_id = msg[GROUP_ID]
group = None
if group_id in zha_gateway.application_controller.groups:
group = async_get_group_info(
hass,
zha_gateway,
zha_gateway.application_controller.groups[group_id],
ha_device_registry,
)
if not group:
connection.send_message(
websocket_api.error_message(
msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found"
)
)
return
connection.send_result(msg[ID], group)
@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]),
}
)
async def websocket_add_group(hass, connection, msg):
"""Add a new ZHA group."""
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
ha_device_registry = await async_get_registry(hass)
group_id = len(zha_gateway.application_controller.groups) + 1
group_name = msg[GROUP_NAME]
zigpy_group = async_get_group_by_name(zha_gateway, group_name)
ret_group = None
members = msg.get(ATTR_MEMBERS)
# guard against group already existing
if zigpy_group is None:
zigpy_group = zha_gateway.application_controller.groups.add_group(
group_id, group_name
)
if members is not None:
tasks = []
for ieee in members:
tasks.append(zha_gateway.devices[ieee].async_add_to_group(group_id))
await asyncio.gather(*tasks)
ret_group = async_get_group_info(hass, zha_gateway, zigpy_group, ha_device_registry)
connection.send_result(msg[ID], ret_group)
@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zha/group/remove",
vol.Required(GROUP_IDS): vol.All(cv.ensure_list, [cv.positive_int]),
}
)
async def websocket_remove_groups(hass, connection, msg):
"""Remove the specified ZHA groups."""
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
groups = zha_gateway.application_controller.groups
group_ids = msg[GROUP_IDS]
if len(group_ids) > 1:
tasks = []
for group_id in group_ids:
tasks.append(remove_group(groups[group_id], zha_gateway))
await asyncio.gather(*tasks)
else:
await remove_group(groups[group_ids[0]], zha_gateway)
ret_groups = await get_groups(hass)
connection.send_result(msg[ID], ret_groups)
@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{
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]),
}
)
async def websocket_add_group_members(hass, connection, msg):
"""Add members to a ZHA group."""
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
ha_device_registry = await async_get_registry(hass)
group_id = msg[GROUP_ID]
members = msg[ATTR_MEMBERS]
zigpy_group = None
if group_id in zha_gateway.application_controller.groups:
zigpy_group = zha_gateway.application_controller.groups[group_id]
tasks = []
for ieee in members:
tasks.append(zha_gateway.devices[ieee].async_add_to_group(group_id))
await asyncio.gather(*tasks)
if not zigpy_group:
connection.send_message(
websocket_api.error_message(
msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found"
)
)
return
ret_group = async_get_group_info(hass, zha_gateway, zigpy_group, ha_device_registry)
connection.send_result(msg[ID], ret_group)
@websocket_api.require_admin
@websocket_api.async_response
@websocket_api.websocket_command(
{
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]),
}
)
async def websocket_remove_group_members(hass, connection, msg):
"""Remove members from a ZHA group."""
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
ha_device_registry = await async_get_registry(hass)
group_id = msg[GROUP_ID]
members = msg[ATTR_MEMBERS]
zigpy_group = None
if group_id in zha_gateway.application_controller.groups:
zigpy_group = zha_gateway.application_controller.groups[group_id]
tasks = []
for ieee in members:
tasks.append(zha_gateway.devices[ieee].async_remove_from_group(group_id))
await asyncio.gather(*tasks)
if not zigpy_group:
connection.send_message(
websocket_api.error_message(
msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found"
)
)
return
ret_group = async_get_group_info(hass, zha_gateway, zigpy_group, ha_device_registry)
connection.send_result(msg[ID], ret_group)
@callback @callback
def async_get_device_info(hass, device, ha_device_registry=None): def async_get_device_info(hass, device, ha_device_registry=None):
"""Get ZHA device.""" """Get ZHA device."""
@ -261,6 +448,62 @@ def async_get_device_info(hass, device, ha_device_registry=None):
return ret_device return ret_device
async def get_groups(hass,):
"""Get ZHA Groups."""
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
ha_device_registry = await async_get_registry(hass)
groups = []
for group in zha_gateway.application_controller.groups.values():
groups.append(
async_get_group_info(hass, zha_gateway, group, ha_device_registry)
)
return groups
async def remove_group(group, zha_gateway):
"""Remove ZHA Group."""
if group.members:
tasks = []
for member_ieee in group.members.keys():
if member_ieee[0] in zha_gateway.devices:
tasks.append(
zha_gateway.devices[member_ieee[0]].async_remove_from_group(
group.group_id
)
)
await asyncio.gather(*tasks)
else:
zha_gateway.application_controller.groups.pop(group.group_id)
@callback
def async_get_group_info(hass, zha_gateway, group, ha_device_registry):
"""Get ZHA group."""
ret_group = {}
ret_group["group_id"] = group.group_id
ret_group["name"] = group.name
ret_group["members"] = [
async_get_device_info(
hass,
zha_gateway.get_device(member_ieee[0]),
ha_device_registry=ha_device_registry,
)
for member_ieee in group.members.keys()
if member_ieee[0] in zha_gateway.devices
]
return ret_group
@callback
def async_get_group_by_name(zha_gateway, group_name):
"""Get ZHA group by name."""
for group in zha_gateway.application_controller.groups.values():
if group.name == group_name:
return group
return None
@websocket_api.require_admin @websocket_api.require_admin
@websocket_api.async_response @websocket_api.async_response
@websocket_api.websocket_command( @websocket_api.websocket_command(
@ -785,7 +1028,14 @@ def async_load_api(hass):
websocket_api.async_register_command(hass, websocket_permit_devices) websocket_api.async_register_command(hass, websocket_permit_devices)
websocket_api.async_register_command(hass, websocket_get_devices) websocket_api.async_register_command(hass, websocket_get_devices)
websocket_api.async_register_command(hass, websocket_get_groupable_devices)
websocket_api.async_register_command(hass, websocket_get_groups)
websocket_api.async_register_command(hass, websocket_get_device) websocket_api.async_register_command(hass, websocket_get_device)
websocket_api.async_register_command(hass, websocket_get_group)
websocket_api.async_register_command(hass, websocket_add_group)
websocket_api.async_register_command(hass, websocket_remove_groups)
websocket_api.async_register_command(hass, websocket_add_group_members)
websocket_api.async_register_command(hass, websocket_remove_group_members)
websocket_api.async_register_command(hass, websocket_reconfigure_node) websocket_api.async_register_command(hass, websocket_reconfigure_node)
websocket_api.async_register_command(hass, websocket_device_clusters) websocket_api.async_register_command(hass, websocket_device_clusters)
websocket_api.async_register_command(hass, websocket_device_cluster_attributes) websocket_api.async_register_command(hass, websocket_device_cluster_attributes)

View File

@ -24,6 +24,7 @@ ATTR_LEVEL = "level"
ATTR_LQI = "lqi" ATTR_LQI = "lqi"
ATTR_MANUFACTURER = "manufacturer" ATTR_MANUFACTURER = "manufacturer"
ATTR_MANUFACTURER_CODE = "manufacturer_code" ATTR_MANUFACTURER_CODE = "manufacturer_code"
ATTR_MEMBERS = "members"
ATTR_MODEL = "model" ATTR_MODEL = "model"
ATTR_NAME = "name" ATTR_NAME = "name"
ATTR_NWK = "nwk" ATTR_NWK = "nwk"
@ -105,6 +106,10 @@ DISCOVERY_KEY = "zha_discovery_info"
DOMAIN = "zha" DOMAIN = "zha"
GROUP_ID = "group_id"
GROUP_IDS = "group_ids"
GROUP_NAME = "group_name"
MFG_CLUSTER_ID_START = 0xFC00 MFG_CLUSTER_ID_START = 0xFC00
POWER_MAINS_POWERED = "Mains" POWER_MAINS_POWERED = "Mains"

View File

@ -13,6 +13,7 @@ import time
import zigpy.exceptions import zigpy.exceptions
from zigpy.profiles import zha, zll from zigpy.profiles import zha, zll
import zigpy.quirks import zigpy.quirks
from zigpy.zcl.clusters.general import Groups
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.dispatcher import (
@ -179,6 +180,17 @@ class ZHADevice(LogMixin):
"""Return true if this device is an end device.""" """Return true if this device is an end device."""
return self._zigpy_device.node_desc.is_end_device return self._zigpy_device.node_desc.is_end_device
@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
@property @property
def gateway(self): def gateway(self):
"""Return the gateway for this device.""" """Return the gateway for this device."""
@ -506,6 +518,14 @@ class ZHADevice(LogMixin):
) )
return response return response
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)
async def async_remove_from_group(self, group_id):
"""Remove this device from the provided zigbee group."""
await self._zigpy_device.remove_from_group(group_id)
def log(self, level, msg, *args): def log(self, level, msg, *args):
"""Log a message.""" """Log a message."""
msg = "[%s](%s): " + msg msg = "[%s](%s): " + msg

View File

@ -93,6 +93,8 @@ class FakeDevice:
self.manufacturer = manufacturer self.manufacturer = manufacturer
self.model = model self.model = model
self.node_desc = zigpy.zdo.types.NodeDescriptor() self.node_desc = zigpy.zdo.types.NodeDescriptor()
self.add_to_group = CoroutineMock()
self.remove_from_group = CoroutineMock()
def make_device( def make_device(

View File

@ -1,7 +1,10 @@
"""Test configuration for the ZHA component.""" """Test configuration for the ZHA component."""
from unittest import mock
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
import zigpy
from zigpy.application import ControllerApplication
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.zha.core.const import COMPONENTS, DATA_ZHA, DOMAIN from homeassistant.components.zha.core.const import COMPONENTS, DATA_ZHA, DOMAIN
@ -12,6 +15,9 @@ from homeassistant.helpers.device_registry import async_get_registry as get_dev_
from .common import async_setup_entry from .common import async_setup_entry
FIXTURE_GRP_ID = 0x1001
FIXTURE_GRP_NAME = "fixture group"
@pytest.fixture(name="config_entry") @pytest.fixture(name="config_entry")
def config_entry_fixture(hass): def config_entry_fixture(hass):
@ -43,6 +49,11 @@ async def zha_gateway_fixture(hass, config_entry):
gateway = ZHAGateway(hass, {}, config_entry) gateway = ZHAGateway(hass, {}, config_entry)
gateway.zha_storage = zha_storage gateway.zha_storage = zha_storage
gateway.ha_device_registry = dev_reg gateway.ha_device_registry = dev_reg
gateway.application_controller = mock.MagicMock(spec_set=ControllerApplication)
groups = zigpy.group.Groups(gateway.application_controller)
groups.listener_event = mock.MagicMock()
groups.add_group(FIXTURE_GRP_ID, FIXTURE_GRP_NAME, suppress_event=True)
gateway.application_controller.groups = groups
return gateway return gateway

View File

@ -1,7 +1,10 @@
"""Test ZHA API.""" """Test ZHA API."""
import pytest import pytest
import zigpy
import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.general as general
from homeassistant.components.light import DOMAIN as light_domain
from homeassistant.components.switch import DOMAIN from homeassistant.components.switch import DOMAIN
from homeassistant.components.websocket_api import const from homeassistant.components.websocket_api import const
from homeassistant.components.zha.api import ID, TYPE, async_load_api from homeassistant.components.zha.api import ID, TYPE, async_load_api
@ -15,9 +18,13 @@ from homeassistant.components.zha.core.const import (
ATTR_NAME, ATTR_NAME,
ATTR_QUIRK_APPLIED, ATTR_QUIRK_APPLIED,
CLUSTER_TYPE_IN, CLUSTER_TYPE_IN,
GROUP_ID,
GROUP_IDS,
GROUP_NAME,
) )
from .common import async_init_zigpy_device from .common import async_init_zigpy_device
from .conftest import FIXTURE_GRP_ID, FIXTURE_GRP_NAME
@pytest.fixture @pytest.fixture
@ -36,9 +43,22 @@ async def zha_client(hass, config_entry, zha_gateway, hass_ws_client):
zha_gateway, zha_gateway,
) )
await async_init_zigpy_device(
hass,
[general.OnOff.cluster_id, general.Basic.cluster_id, general.Groups.cluster_id],
[],
zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT,
zha_gateway,
manufacturer="FakeGroupManufacturer",
model="FakeGroupModel",
ieee="01:2d:6f:00:0a:90:69:e8",
)
# load up switch domain # load up switch domain
await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN)
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.config_entries.async_forward_entry_setup(config_entry, light_domain)
await hass.async_block_till_done()
return await hass_ws_client(hass) return await hass_ws_client(hass)
@ -114,15 +134,17 @@ async def test_device_cluster_commands(hass, config_entry, zha_gateway, zha_clie
async def test_list_devices(hass, config_entry, zha_gateway, zha_client): async def test_list_devices(hass, config_entry, zha_gateway, zha_client):
"""Test getting entity cluster commands.""" """Test getting zha devices."""
await zha_client.send_json({ID: 5, TYPE: "zha/devices"}) await zha_client.send_json({ID: 5, TYPE: "zha/devices"})
msg = await zha_client.receive_json() msg = await zha_client.receive_json()
devices = msg["result"] devices = msg["result"]
assert len(devices) == 1 assert len(devices) == 2
msg_id = 100
for device in devices: for device in devices:
msg_id += 1
assert device[ATTR_IEEE] is not None assert device[ATTR_IEEE] is not None
assert device[ATTR_MANUFACTURER] is not None assert device[ATTR_MANUFACTURER] is not None
assert device[ATTR_MODEL] is not None assert device[ATTR_MODEL] is not None
@ -135,7 +157,7 @@ async def test_list_devices(hass, config_entry, zha_gateway, zha_client):
assert entity_reference["entity_id"] is not None assert entity_reference["entity_id"] is not None
await zha_client.send_json( await zha_client.send_json(
{ID: 6, TYPE: "zha/device", ATTR_IEEE: device[ATTR_IEEE]} {ID: msg_id, TYPE: "zha/device", ATTR_IEEE: device[ATTR_IEEE]}
) )
msg = await zha_client.receive_json() msg = await zha_client.receive_json()
device2 = msg["result"] device2 = msg["result"]
@ -152,3 +174,151 @@ async def test_device_not_found(hass, config_entry, zha_gateway, zha_client):
assert msg["type"] == const.TYPE_RESULT assert msg["type"] == const.TYPE_RESULT
assert not msg["success"] assert not msg["success"]
assert msg["error"]["code"] == const.ERR_NOT_FOUND assert msg["error"]["code"] == const.ERR_NOT_FOUND
async def test_list_groups(hass, config_entry, zha_gateway, zha_client):
"""Test getting zha zigbee groups."""
await zha_client.send_json({ID: 7, TYPE: "zha/groups"})
msg = await zha_client.receive_json()
assert msg["id"] == 7
assert msg["type"] == const.TYPE_RESULT
groups = msg["result"]
assert len(groups) == 1
for group in groups:
assert group["group_id"] == FIXTURE_GRP_ID
assert group["name"] == FIXTURE_GRP_NAME
assert group["members"] == []
async def test_get_group(hass, config_entry, zha_gateway, zha_client):
"""Test getting a specific zha zigbee group."""
await zha_client.send_json({ID: 8, TYPE: "zha/group", GROUP_ID: FIXTURE_GRP_ID})
msg = await zha_client.receive_json()
assert msg["id"] == 8
assert msg["type"] == const.TYPE_RESULT
group = msg["result"]
assert group is not None
assert group["group_id"] == FIXTURE_GRP_ID
assert group["name"] == FIXTURE_GRP_NAME
assert group["members"] == []
async def test_get_group_not_found(hass, config_entry, zha_gateway, zha_client):
"""Test not found response from get group API."""
await zha_client.send_json({ID: 9, TYPE: "zha/group", GROUP_ID: 1234567})
msg = await zha_client.receive_json()
assert msg["id"] == 9
assert msg["type"] == const.TYPE_RESULT
assert not msg["success"]
assert msg["error"]["code"] == const.ERR_NOT_FOUND
async def test_list_groupable_devices(hass, config_entry, zha_gateway, zha_client):
"""Test getting zha devices that have a group cluster."""
# Make device available
zha_gateway.devices[
zigpy.types.EUI64.convert("01:2d:6f:00:0a:90:69:e8")
].set_available(True)
await zha_client.send_json({ID: 10, TYPE: "zha/devices/groupable"})
msg = await zha_client.receive_json()
assert msg["id"] == 10
assert msg["type"] == const.TYPE_RESULT
devices = msg["result"]
assert len(devices) == 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 entity_reference in device["entities"]:
assert entity_reference[ATTR_NAME] is not None
assert entity_reference["entity_id"] is not None
# Make sure there are no groupable devices when the device is unavailable
# Make device unavailable
zha_gateway.devices[
zigpy.types.EUI64.convert("01:2d:6f:00:0a:90:69:e8")
].set_available(False)
await zha_client.send_json({ID: 11, TYPE: "zha/devices/groupable"})
msg = await zha_client.receive_json()
assert msg["id"] == 11
assert msg["type"] == const.TYPE_RESULT
devices = msg["result"]
assert len(devices) == 0
async def test_add_group(hass, config_entry, zha_gateway, zha_client):
"""Test adding and getting a new zha zigbee group."""
await zha_client.send_json({ID: 12, TYPE: "zha/group/add", GROUP_NAME: "new_group"})
msg = await zha_client.receive_json()
assert msg["id"] == 12
assert msg["type"] == const.TYPE_RESULT
added_group = msg["result"]
assert added_group["name"] == "new_group"
assert added_group["members"] == []
await zha_client.send_json({ID: 13, TYPE: "zha/groups"})
msg = await zha_client.receive_json()
assert msg["id"] == 13
assert msg["type"] == const.TYPE_RESULT
groups = msg["result"]
assert len(groups) == 2
for group in groups:
assert group["name"] == FIXTURE_GRP_NAME or group["name"] == "new_group"
async def test_remove_group(hass, config_entry, zha_gateway, zha_client):
"""Test removing a new zha zigbee group."""
await zha_client.send_json({ID: 14, TYPE: "zha/groups"})
msg = await zha_client.receive_json()
assert msg["id"] == 14
assert msg["type"] == const.TYPE_RESULT
groups = msg["result"]
assert len(groups) == 1
await zha_client.send_json(
{ID: 15, TYPE: "zha/group/remove", GROUP_IDS: [FIXTURE_GRP_ID]}
)
msg = await zha_client.receive_json()
assert msg["id"] == 15
assert msg["type"] == const.TYPE_RESULT
groups_remaining = msg["result"]
assert len(groups_remaining) == 0
await zha_client.send_json({ID: 16, TYPE: "zha/groups"})
msg = await zha_client.receive_json()
assert msg["id"] == 16
assert msg["type"] == const.TYPE_RESULT
groups = msg["result"]
assert len(groups) == 0