Skip to content

Implement quirks v2 reporting config, optimize discovery #363

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 55 additions & 3 deletions tests/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
from functools import partial
import math
from typing import Any, Optional
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from zhaquirks.danfoss import thermostat as danfoss_thermostat
from zigpy.device import Device as ZigpyDevice
import zigpy.profiles.zha
from zigpy.quirks import CustomCluster, get_device
from zigpy.quirks.v2 import CustomDeviceV2, QuirkBuilder
from zigpy.quirks.v2 import CustomDeviceV2, QuirkBuilder, ReportingConfig
from zigpy.quirks.v2.homeassistant.sensor import (
SensorDeviceClass as SensorDeviceClassV2,
)
Expand All @@ -33,12 +33,14 @@
send_attributes_report,
)
from zha.application import Platform
from zha.application.const import ZHA_CLUSTER_HANDLER_READS_PER_REQ
from zha.application.const import ZCL_INIT_ATTRS, ZHA_CLUSTER_HANDLER_READS_PER_REQ
from zha.application.gateway import Gateway
from zha.application.platforms import PlatformEntity, sensor
from zha.application.platforms.sensor import DanfossSoftwareErrorCode, UnitOfMass
from zha.application.platforms.sensor.const import SensorDeviceClass, SensorStateClass
from zha.units import PERCENTAGE, UnitOfEnergy, UnitOfPressure, UnitOfVolume
from zha.zigbee.cluster_handlers import AttrReportConfig
from zha.zigbee.cluster_handlers.manufacturerspecific import OppleRemoteClusterHandler
from zha.zigbee.device import Device

EMAttrs = homeautomation.ElectricalMeasurement.AttributeDefs
Expand Down Expand Up @@ -1351,6 +1353,9 @@ def __init__(self, *args, **kwargs) -> None:
unit=UnitOfMass.GRAMS,
translation_key="last_feeding_size",
fallback_name="Last feeding size",
reporting_config=ReportingConfig(
min_interval=0, max_interval=60, reportable_change=1
),
)
.sensor(
"power",
Expand Down Expand Up @@ -1450,6 +1455,53 @@ async def test_state_class(
assert "Quirks provided an invalid state class: energy" in caplog.text


async def test_cluster_handler_quirks_attributes(zha_gateway: Gateway) -> None:
"""Test quirks sensor setting up ZCL_INIT_ATTRS and REPORT_CONFIG correctly."""

# Suppress normal endpoint probing, as this will claim the Opple cluster handler
# already due to it being in the "CLUSTER_HANDLER_ONLY_CLUSTERS" registry.
# We want to test the handler also gets claimed via quirks v2 reporting config.
with patch("zha.application.discovery.EndpointProbe.discover_entities"):
zha_device, cluster = await zigpy_device_aqara_sensor_v2_mock(zha_gateway)
assert isinstance(zha_device.device, CustomDeviceV2)

# get cluster handler of OppleCluster
opple_ch = zha_device.endpoints[1].all_cluster_handlers["1:0xfcc0"]
assert isinstance(opple_ch, OppleRemoteClusterHandler)

# make sure the cluster handler was claimed due to reporting config, so ZHA binds it
assert opple_ch in zha_device.endpoints[1].claimed_cluster_handlers.values()

# check ZCL_INIT_ATTRS contains sensor attributes that are not in REPORT_CONFIG
assert opple_ch.ZCL_INIT_ATTRS == {
"energy": True,
"energy_delivered": True,
"energy_invalid_state_class": True,
"power": True,
}
# check that ZCL_INIT_ATTRS is an instance variable and not a class variable now
assert opple_ch.ZCL_INIT_ATTRS is opple_ch.__dict__[ZCL_INIT_ATTRS]
assert opple_ch.ZCL_INIT_ATTRS is not OppleRemoteClusterHandler.ZCL_INIT_ATTRS

# double check we didn't modify the class variable
assert OppleRemoteClusterHandler.ZCL_INIT_ATTRS == {}

# check if REPORT_CONFIG is set correctly
assert (
(
AttrReportConfig(
attr="last_feeding_size",
config=(0, 60, 1),
),
)
) == opple_ch.REPORT_CONFIG

# this cannot be wrong, as REPORT_CONFIG is an immutable tuple and not a list/dict,
# but let's check it anyway in case the type changes in the future
assert opple_ch.REPORT_CONFIG is not OppleRemoteClusterHandler.REPORT_CONFIG
assert OppleRemoteClusterHandler.REPORT_CONFIG == ()


async def test_device_counter_sensors(zha_gateway: Gateway) -> None:
"""Test coordinator counter sensor."""

Expand Down
1 change: 1 addition & 0 deletions zha/application/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
ENTITY_METADATA = "entity_metadata"

ZCL_INIT_ATTRS = "ZCL_INIT_ATTRS"
REPORT_CONFIG = "REPORT_CONFIG"

_ControllerClsType = type[zigpy.application.ControllerApplication]

Expand Down
45 changes: 33 additions & 12 deletions zha/application/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from collections import Counter
from dataclasses import astuple
import logging
from typing import TYPE_CHECKING, cast

Expand Down Expand Up @@ -51,6 +52,7 @@

# importing cluster handlers updates registries
from zha.zigbee.cluster_handlers import ( # noqa: F401 pylint: disable=unused-import
AttrReportConfig,
ClusterHandler,
closures,
general,
Expand Down Expand Up @@ -298,18 +300,37 @@ def discover_quirks_v2_entities(self, device: Device) -> None:
entity_metadata.device_class.value, entity_class
)

# automatically add the attribute to ZCL_INIT_ATTRS for the cluster
# handler if it is not already in the list
if (
hasattr(entity_metadata, "attribute_name")
and entity_metadata.attribute_name
not in cluster_handler.ZCL_INIT_ATTRS
):
init_attrs = cluster_handler.ZCL_INIT_ATTRS.copy()
init_attrs[entity_metadata.attribute_name] = (
entity_metadata.attribute_initialized_from_cache
)
cluster_handler.__dict__[zha_const.ZCL_INIT_ATTRS] = init_attrs
# process the entity metadata for ZCL_INIT_ATTRS and REPORT_CONFIG
if attr_name := getattr(entity_metadata, "attribute_name", None):
# if the entity has a reporting config, add it to the cluster handler
if rep_conf := getattr(entity_metadata, "reporting_config", None):
# if attr is already in REPORT_CONFIG, remove it first
cluster_handler.REPORT_CONFIG = tuple(
filter(
lambda cfg: cfg["attr"] != attr_name,
cluster_handler.REPORT_CONFIG,
)
)
# tuples are immutable and we re-set the REPORT_CONFIG here,
# so no need to check for an instance variable
cluster_handler.REPORT_CONFIG += (
AttrReportConfig(attr=attr_name, config=astuple(rep_conf)),
)
# claim the cluster handler, so ZHA configures and binds it
endpoint.claim_cluster_handlers([cluster_handler])

# not in REPORT_CONFIG, add to ZCL_INIT_ATTRS if it not already in
elif attr_name not in cluster_handler.ZCL_INIT_ATTRS:
# copy existing ZCL_INIT_ATTRS into instance variable once,
# so we don't modify other instances of the same cluster handler
if zha_const.ZCL_INIT_ATTRS not in cluster_handler.__dict__:
cluster_handler.ZCL_INIT_ATTRS = (
cluster_handler.ZCL_INIT_ATTRS.copy()
)
# add the attribute to the guaranteed instance variable
cluster_handler.ZCL_INIT_ATTRS[attr_name] = (
entity_metadata.attribute_initialized_from_cache
)

endpoint.async_new_entity(
platform=platform,
Expand Down
2 changes: 1 addition & 1 deletion zha/zigbee/cluster_handlers/manufacturerspecific.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None:
class OppleRemoteClusterHandler(ClusterHandler):
"""Opple cluster handler."""

REPORT_CONFIG = ()
REPORT_CONFIG: tuple[AttrReportConfig, ...] = ()

def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None:
"""Initialize Opple cluster handler."""
Expand Down
Loading