Optimize ZHA ZCL attribute reporting configuration (#55796)

* Refactor ZCL attribute reporting configuration

Configure up to 3 attributes in a single request.

* Use constant for attribute reporting configuration

* Update tests

* Cleanup

* Remove irrelevant for this PR section
This commit is contained in:
Alexei Chetroi 2021-09-05 17:45:08 -04:00 committed by GitHub
parent 4e1e7a4a71
commit aa6cb84b27
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 131 additions and 112 deletions

View file

@ -8,6 +8,7 @@ import logging
from typing import Any
import zigpy.exceptions
from zigpy.zcl.foundation import Status
from homeassistant.const import ATTR_COMMAND
from homeassistant.core import callback
@ -23,6 +24,7 @@ from ..const import (
ATTR_UNIQUE_ID,
ATTR_VALUE,
CHANNEL_ZDO,
REPORT_CONFIG_ATTR_PER_REQ,
SIGNAL_ATTR_UPDATED,
ZHA_CHANNEL_MSG,
ZHA_CHANNEL_MSG_BIND,
@ -87,7 +89,7 @@ class ChannelStatus(Enum):
class ZigbeeChannel(LogMixin):
"""Base channel for a Zigbee cluster."""
REPORT_CONFIG = ()
REPORT_CONFIG: tuple[dict[int | str, tuple[int, int, int | float]]] = ()
BIND: bool = True
def __init__(
@ -101,9 +103,8 @@ class ZigbeeChannel(LogMixin):
self._id = f"{ch_pool.id}:0x{cluster.cluster_id:04x}"
unique_id = ch_pool.unique_id.replace("-", ":")
self._unique_id = f"{unique_id}:0x{cluster.cluster_id:04x}"
self._report_config = self.REPORT_CONFIG
if not hasattr(self, "_value_attribute") and len(self._report_config) > 0:
attr = self._report_config[0].get("attr")
if not hasattr(self, "_value_attribute") and self.REPORT_CONFIG:
attr = self.REPORT_CONFIG[0].get("attr")
if isinstance(attr, str):
self.value_attribute = self.cluster.attridx.get(attr)
else:
@ -195,42 +196,42 @@ class ZigbeeChannel(LogMixin):
if self.cluster.cluster_id >= 0xFC00 and self._ch_pool.manufacturer_code:
kwargs["manufacturer"] = self._ch_pool.manufacturer_code
for report in self._report_config:
attr = report["attr"]
for attr_report in self.REPORT_CONFIG:
attr, config = attr_report["attr"], attr_report["config"]
attr_name = self.cluster.attributes.get(attr, [attr])[0]
min_report_int, max_report_int, reportable_change = report["config"]
event_data[attr_name] = {
"min": min_report_int,
"max": max_report_int,
"min": config[0],
"max": config[1],
"id": attr,
"name": attr_name,
"change": reportable_change,
"change": config[2],
"success": False,
}
to_configure = [*self.REPORT_CONFIG]
chunk, rest = (
to_configure[:REPORT_CONFIG_ATTR_PER_REQ],
to_configure[REPORT_CONFIG_ATTR_PER_REQ:],
)
while chunk:
reports = {rec["attr"]: rec["config"] for rec in chunk}
try:
res = await self.cluster.configure_reporting(
attr, min_report_int, max_report_int, reportable_change, **kwargs
)
self.debug(
"reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'",
attr_name,
self.cluster.ep_attribute,
min_report_int,
max_report_int,
reportable_change,
res,
)
event_data[attr_name]["success"] = (
res[0][0].status == 0 or res[0][0].status == 134
)
res = await self.cluster.configure_reporting_multiple(reports, **kwargs)
self._configure_reporting_status(reports, res[0])
# if we get a response, then it's a success
for attr_stat in event_data.values():
attr_stat["success"] = True
except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex:
self.debug(
"failed to set reporting for '%s' attr on '%s' cluster: %s",
attr_name,
"failed to set reporting on '%s' cluster for: %s",
self.cluster.ep_attribute,
str(ex),
)
event_data[attr_name]["success"] = False
break
chunk, rest = (
rest[:REPORT_CONFIG_ATTR_PER_REQ],
rest[REPORT_CONFIG_ATTR_PER_REQ:],
)
async_dispatcher_send(
self._ch_pool.hass,
@ -245,6 +246,46 @@ class ZigbeeChannel(LogMixin):
},
)
def _configure_reporting_status(
self, attrs: dict[int | str, tuple], res: list | tuple
) -> None:
"""Parse configure reporting result."""
if not isinstance(res, list):
# assume default response
self.debug(
"attr reporting for '%s' on '%s': %s",
attrs,
self.name,
res,
)
return
if res[0].status == Status.SUCCESS and len(res) == 1:
self.debug(
"Successfully configured reporting for '%s' on '%s' cluster: %s",
attrs,
self.name,
res,
)
return
failed = [
self.cluster.attributes.get(r.attrid, [r.attrid])[0]
for r in res
if r.status != Status.SUCCESS
]
attrs = {self.cluster.attributes.get(r, [r])[0] for r in attrs}
self.debug(
"Successfully configured reporting for '%s' on '%s' cluster",
attrs - set(failed),
self.name,
)
self.debug(
"Failed to configure reporting for '%s' on '%s' cluster: %s",
failed,
self.name,
res,
)
async def async_configure(self) -> None:
"""Set cluster binding and attribute reporting."""
if not self._ch_pool.skip_configuration:
@ -267,7 +308,7 @@ class ZigbeeChannel(LogMixin):
return
self.debug("initializing channel: from_cache: %s", from_cache)
attributes = [cfg["attr"] for cfg in self._report_config]
attributes = [cfg["attr"] for cfg in self.REPORT_CONFIG]
if attributes:
await self.get_attributes(attributes, from_cache=from_cache)

View file

@ -6,7 +6,6 @@ https://home-assistant.io/integrations/zha/
"""
from __future__ import annotations
import asyncio
from collections import namedtuple
from typing import Any
@ -85,6 +84,20 @@ class Pump(ZigbeeChannel):
class ThermostatChannel(ZigbeeChannel):
"""Thermostat channel."""
REPORT_CONFIG = (
{"attr": "local_temp", "config": REPORT_CONFIG_CLIMATE},
{"attr": "occupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE},
{"attr": "occupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE},
{"attr": "unoccupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE},
{"attr": "unoccupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE},
{"attr": "running_mode", "config": REPORT_CONFIG_CLIMATE},
{"attr": "running_state", "config": REPORT_CONFIG_CLIMATE_DEMAND},
{"attr": "system_mode", "config": REPORT_CONFIG_CLIMATE},
{"attr": "occupancy", "config": REPORT_CONFIG_CLIMATE_DISCRETE},
{"attr": "pi_cooling_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND},
{"attr": "pi_heating_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND},
)
def __init__(
self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType
) -> None:
@ -132,19 +145,6 @@ class ThermostatChannel(ZigbeeChannel):
self._system_mode = None
self._unoccupied_cooling_setpoint = None
self._unoccupied_heating_setpoint = None
self._report_config = [
{"attr": "local_temp", "config": REPORT_CONFIG_CLIMATE},
{"attr": "occupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE},
{"attr": "occupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE},
{"attr": "unoccupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE},
{"attr": "unoccupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE},
{"attr": "running_mode", "config": REPORT_CONFIG_CLIMATE},
{"attr": "running_state", "config": REPORT_CONFIG_CLIMATE_DEMAND},
{"attr": "system_mode", "config": REPORT_CONFIG_CLIMATE},
{"attr": "occupancy", "config": REPORT_CONFIG_CLIMATE_DISCRETE},
{"attr": "pi_cooling_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND},
{"attr": "pi_heating_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND},
]
@property
def abs_max_cool_setpoint_limit(self) -> int:
@ -285,71 +285,6 @@ class ThermostatChannel(ZigbeeChannel):
chunk, attrs = attrs[:4], attrs[4:]
async def configure_reporting(self):
"""Configure attribute reporting for a cluster.
This also swallows DeliveryError exceptions that are thrown when
devices are unreachable.
"""
kwargs = {}
if self.cluster.cluster_id >= 0xFC00 and self._ch_pool.manufacturer_code:
kwargs["manufacturer"] = self._ch_pool.manufacturer_code
chunk, rest = self._report_config[:4], self._report_config[4:]
while chunk:
attrs = {record["attr"]: record["config"] for record in chunk}
try:
res = await self.cluster.configure_reporting_multiple(attrs, **kwargs)
self._configure_reporting_status(attrs, res[0])
except (ZigbeeException, asyncio.TimeoutError) as ex:
self.debug(
"failed to set reporting on '%s' cluster for: %s",
self.cluster.ep_attribute,
str(ex),
)
break
chunk, rest = rest[:4], rest[4:]
def _configure_reporting_status(
self, attrs: dict[int | str, tuple], res: list | tuple
) -> None:
"""Parse configure reporting result."""
if not isinstance(res, list):
# assume default response
self.debug(
"attr reporting for '%s' on '%s': %s",
attrs,
self.name,
res,
)
return
if res[0].status == Status.SUCCESS and len(res) == 1:
self.debug(
"Successfully configured reporting for '%s' on '%s' cluster: %s",
attrs,
self.name,
res,
)
return
failed = [
self.cluster.attributes.get(r.attrid, [r.attrid])[0]
for r in res
if r.status != Status.SUCCESS
]
attrs = {self.cluster.attributes.get(r, [r])[0] for r in attrs}
self.debug(
"Successfully configured reporting for '%s' on '%s' cluster",
attrs - set(failed),
self.name,
)
self.debug(
"Failed to configure reporting for '%s' on '%s' cluster: %s",
failed,
self.name,
res,
)
@retryable_req(delays=(1, 1, 3))
async def async_initialize_channel_specific(self, from_cache: bool) -> None:
"""Initialize channel."""

View file

@ -287,6 +287,7 @@ class RadioType(enum.Enum):
return self._desc
REPORT_CONFIG_ATTR_PER_REQ = 3
REPORT_CONFIG_MAX_INT = 900
REPORT_CONFIG_MAX_INT_BATTERY_SAVE = 10800
REPORT_CONFIG_MIN_INT = 30

View file

@ -1,5 +1,6 @@
"""Common test objects."""
import asyncio
import math
import time
from unittest.mock import AsyncMock, Mock
@ -99,6 +100,9 @@ def patch_cluster(cluster):
[zcl_f.ConfigureReportingResponseRecord(zcl_f.Status.SUCCESS, 0x00, 0xAABB)]
]
)
cluster.configure_reporting_multiple = AsyncMock(
return_value=zcl_f.ConfigureReportingResponse.deserialize(b"\x00")[0]
)
cluster.deserialize = Mock()
cluster.handle_cluster_request = Mock()
cluster.read_attributes = AsyncMock(wraps=cluster.read_attributes)
@ -227,6 +231,7 @@ def reset_clusters(clusters):
for cluster in clusters:
cluster.bind.reset_mock()
cluster.configure_reporting.reset_mock()
cluster.configure_reporting_multiple.reset_mock()
cluster.write_attributes.reset_mock()
@ -240,8 +245,21 @@ async def async_test_rejoin(hass, zigpy_device, clusters, report_counts, ep_id=1
for cluster, reports in zip(clusters, report_counts):
assert cluster.bind.call_count == 1
assert cluster.bind.await_count == 1
if reports:
assert cluster.configure_reporting.call_count == 0
assert cluster.configure_reporting.await_count == 0
assert cluster.configure_reporting_multiple.call_count == math.ceil(
reports / zha_const.REPORT_CONFIG_ATTR_PER_REQ
)
assert cluster.configure_reporting_multiple.await_count == math.ceil(
reports / zha_const.REPORT_CONFIG_ATTR_PER_REQ
)
else:
# no reports at all
assert cluster.configure_reporting.call_count == reports
assert cluster.configure_reporting.await_count == reports
assert cluster.configure_reporting_multiple.call_count == reports
assert cluster.configure_reporting_multiple.await_count == reports
async def async_wait_for_updates(hass):

View file

@ -1,5 +1,6 @@
"""Test ZHA Core channels."""
import asyncio
import math
from unittest import mock
from unittest.mock import AsyncMock, patch
@ -123,6 +124,23 @@ async def poll_control_device(zha_device_restored, zigpy_device_mock):
(0x0020, 1, {}),
(0x0021, 0, {}),
(0x0101, 1, {"lock_state"}),
(
0x0201,
1,
{
"local_temp",
"occupied_cooling_setpoint",
"occupied_heating_setpoint",
"unoccupied_cooling_setpoint",
"unoccupied_heating_setpoint",
"running_mode",
"running_state",
"system_mode",
"occupancy",
"pi_cooling_demand",
"pi_heating_demand",
},
),
(0x0202, 1, {"fan_mode"}),
(0x0300, 1, {"current_x", "current_y", "color_temperature"}),
(0x0400, 1, {"measured_value"}),
@ -156,8 +174,14 @@ async def test_in_channel_config(
await channel.async_configure()
assert cluster.bind.call_count == bind_count
assert cluster.configure_reporting.call_count == len(attrs)
reported_attrs = {attr[0][0] for attr in cluster.configure_reporting.call_args_list}
assert cluster.configure_reporting.call_count == 0
assert cluster.configure_reporting_multiple.call_count == math.ceil(len(attrs) / 3)
reported_attrs = {
a
for a in attrs
for attr in cluster.configure_reporting_multiple.call_args_list
for attrs in attr[0][0]
}
assert set(attrs) == reported_attrs