Skip to content
20 changes: 10 additions & 10 deletions tests/data/devices/bosch-rbsh-mms-zb-eu.json
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,7 @@
},
"id": "1:0xfca0",
"unique_id": "ab:cd:ef:12:75:e4:96:a5:1:0xfca0",
"status": "CREATED",
"status": "INITIALIZED",
"value_attribute": null
}
],
Expand Down Expand Up @@ -882,7 +882,7 @@
},
"id": "1:0xfca0",
"unique_id": "ab:cd:ef:12:75:e4:96:a5:1:0xfca0",
"status": "CREATED",
"status": "INITIALIZED",
"value_attribute": null
}
],
Expand Down Expand Up @@ -928,7 +928,7 @@
},
"id": "1:0xfca0",
"unique_id": "ab:cd:ef:12:75:e4:96:a5:1:0xfca0",
"status": "CREATED",
"status": "INITIALIZED",
"value_attribute": null
}
],
Expand Down Expand Up @@ -974,7 +974,7 @@
},
"id": "1:0xfca0",
"unique_id": "ab:cd:ef:12:75:e4:96:a5:1:0xfca0",
"status": "CREATED",
"status": "INITIALIZED",
"value_attribute": null
}
],
Expand Down Expand Up @@ -1022,7 +1022,7 @@
},
"id": "1:0xfca0",
"unique_id": "ab:cd:ef:12:75:e4:96:a5:1:0xfca0",
"status": "CREATED",
"status": "INITIALIZED",
"value_attribute": null
}
],
Expand Down Expand Up @@ -1069,7 +1069,7 @@
},
"id": "1:0xfca0",
"unique_id": "ab:cd:ef:12:75:e4:96:a5:1:0xfca0",
"status": "CREATED",
"status": "INITIALIZED",
"value_attribute": null
}
],
Expand Down Expand Up @@ -1494,7 +1494,7 @@
},
"id": "1:0xfca0",
"unique_id": "ab:cd:ef:12:75:e4:96:a5:1:0xfca0",
"status": "CREATED",
"status": "INITIALIZED",
"value_attribute": null
}
],
Expand Down Expand Up @@ -1586,7 +1586,7 @@
},
"id": "1:0xfca0",
"unique_id": "ab:cd:ef:12:75:e4:96:a5:1:0xfca0",
"status": "CREATED",
"status": "INITIALIZED",
"value_attribute": null
}
],
Expand Down Expand Up @@ -1633,7 +1633,7 @@
},
"id": "2:0xfca0",
"unique_id": "ab:cd:ef:12:75:e4:96:a5:2:0xfca0",
"status": "CREATED",
"status": "INITIALIZED",
"value_attribute": null
}
],
Expand Down Expand Up @@ -1680,7 +1680,7 @@
},
"id": "3:0xfca0",
"unique_id": "ab:cd:ef:12:75:e4:96:a5:3:0xfca0",
"status": "CREATED",
"status": "INITIALIZED",
"value_attribute": null
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@
},
"id": "1:0xfc81",
"unique_id": "8c:6f:b9:ff:fe:26:3b:4a:1:0xfc81",
"status": "CREATED",
"status": "INITIALIZED",
"value_attribute": null
}
],
Expand Down Expand Up @@ -693,7 +693,7 @@
},
"id": "1:0xfc81",
"unique_id": "8c:6f:b9:ff:fe:26:3b:4a:1:0xfc81",
"status": "CREATED",
"status": "INITIALIZED",
"value_attribute": null
}
],
Expand Down
2 changes: 1 addition & 1 deletion tests/data/devices/third-reality-inc-3rsp02028bz.json
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@
},
"id": "1:0xff03",
"unique_id": "28:2c:02:bf:ff:eb:4f:2f:1:0xff03",
"status": "CREATED",
"status": "INITIALIZED",
"value_attribute": null
}
],
Expand Down
89 changes: 89 additions & 0 deletions tests/test_button.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
PROFILE_ID,
)
from zhaquirks.tuya.tuya_valve import ParksideTuyaValveManufCluster
import zigpy
from zigpy.exceptions import ZigbeeException
from zigpy.profiles import zha
from zigpy.quirks import CustomCluster, CustomDevice, DeviceRegistry
Expand All @@ -34,6 +35,7 @@
update_attribute_cache,
)
from zha.application import Platform
from zha.application.const import ZCL_INIT_ATTRS
from zha.application.gateway import Gateway
from zha.application.platforms import EntityCategory, PlatformEntity
from zha.application.platforms.button import (
Expand All @@ -43,6 +45,7 @@
)
from zha.application.platforms.button.const import ButtonDeviceClass
from zha.exceptions import ZHAException
from zha.zigbee.cluster_handlers.manufacturerspecific import OppleRemoteClusterHandler
from zha.zigbee.device import Device

ZIGPY_DEVICE = {
Expand Down Expand Up @@ -354,3 +357,89 @@ async def test_quirks_write_attr_buttons_uid(zha_gateway: Gateway) -> None:
assert entity_btn_2.translation_key == "btn_2"
assert entity_btn_2._unique_id_suffix == "btn_2"
assert entity_btn_2._attribute_value == 2


class OppleCluster(CustomCluster, ManufacturerSpecificCluster):
"""Aqara manufacturer specific cluster."""

cluster_id = 0xFCC0
ep_attribute = "opple_cluster"

class ServerCommandDefs(zcl_f.BaseCommandDefs):
"""Server command definitions."""

self_test: Final = zcl_f.ZCLCommandDef(
id=0x00, schema={"identify_time": t.uint16_t}
)


async def test_cluster_handler_quirks_unnecessary_claiming(
zha_gateway: Gateway,
) -> None:
"""Test quirks button doesn't claim cluster handlers unnecessarily."""

registry = DeviceRegistry()
(
QuirkBuilder(
"Fake_Manufacturer_sensor_2", "Fake_Model_sensor_2", registry=registry
)
.replaces(OppleCluster)
.command_button(
FakeManufacturerCluster.ServerCommandDefs.self_test.name,
OppleCluster.cluster_id,
command_args=(5,),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny nitpick:

Suggested change
command_args=(5,),
command_kwargs={"identify_time": 5},

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Copied that from some other test, but I'll change that.
We'll probably want to adjust this in a lot of existing tests as well at a later date, including (completely) using kwargs for creating quirks v2 entities.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FakeManufacturerCluster.ServerCommandDefs.self_test.name should also be changed to OppleCluster[...]. It's the same command name and we never execute this, since we just need it for testing the quirks v2 metadata processing in the device discovery, but it should still be adjusted.

translation_key="self_test",
fallback_name="Self test",
)
.add_to_registry()
)

zigpy_device = create_mock_zigpy_device(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another nitpick, feel free to ignore if it's too much trouble. Is there any way to test using JSON diagnostics from a real device? I'd like to phase out use of create_mock_zigpy_device and other synthetic testing setups in favor of using real devices.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I explicitly used create_mock_zigpy_device, as that's what the similar tests in test_sensor used and especially here, we really want to make sure that we get this exact device with that exact cluster setup.

I feel like we always run into a bit of a risk with using JSON diagnostics in tests, as it's not clear how endpoints and clusters are layed out by just looking at the tests. You have to look at the (huge) JSON file. It's also harder to get a device exactly in the way you want it, without adding unnecessary endpoints/clusters/quirk interference.

Some parts of the device/diagnostics can change over time as well (i.e. ZHA or quirk doing something different, firmware update, ...), when we may not want that for all tests where the diagnostics are used in.

zha_gateway,
{
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
OppleCluster.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.OCCUPANCY_SENSOR,
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
}
},
manufacturer="Fake_Manufacturer_sensor_2",
model="Fake_Model_sensor_2",
)
zigpy_device = registry.get_device(zigpy_device)

# 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 attributes init.
with patch("zha.application.discovery.EndpointProbe.discover_entities"):
zha_device = await join_zigpy_device(zha_gateway, zigpy_device)
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 not claimed,
# as no reporting is configured and no attributes are to be read
assert opple_ch not in zha_device.endpoints[1].claimed_cluster_handlers.values()

# check that BIND is left at default of True, though ZHA will ignore it
assert opple_ch.BIND is True

# check ZCL_INIT_ATTRS is empty
assert opple_ch.ZCL_INIT_ATTRS == {}

# check that no ZCL_INIT_ATTRS instance variable was created
assert opple_ch.__dict__.get(ZCL_INIT_ATTRS) is None
assert opple_ch.ZCL_INIT_ATTRS is OppleRemoteClusterHandler.ZCL_INIT_ATTRS

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

# check if REPORT_CONFIG is empty, both instance and class variable
assert opple_ch.REPORT_CONFIG == ()
assert OppleRemoteClusterHandler.REPORT_CONFIG == ()
75 changes: 74 additions & 1 deletion tests/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1598,7 +1598,7 @@ 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:
async def test_cluster_handler_quirks_attribute_reporting(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
Expand All @@ -1615,6 +1615,9 @@ async def test_cluster_handler_quirks_attributes(zha_gateway: Gateway) -> None:
# 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 that BIND is not set to False, as reporting is configured
assert opple_ch.BIND is True

# check ZCL_INIT_ATTRS contains sensor attributes that are not in REPORT_CONFIG
assert opple_ch.ZCL_INIT_ATTRS == {
"energy": True,
Expand Down Expand Up @@ -1645,6 +1648,76 @@ async def test_cluster_handler_quirks_attributes(zha_gateway: Gateway) -> None:
assert OppleRemoteClusterHandler.REPORT_CONFIG == ()


async def test_cluster_handler_quirks_attribute_reading(zha_gateway: Gateway) -> None:
"""Test quirks sensor setting up ZCL_INIT_ATTRS, claiming cluster handler."""

registry = DeviceRegistry()
(
QuirkBuilder(
"Fake_Manufacturer_sensor_2", "Fake_Model_sensor_2", registry=registry
)
.replaces(OppleCluster)
.sensor(
"last_feeding_size",
OppleCluster.cluster_id,
translation_key="last_feeding_size",
fallback_name="Last feeding size",
)
.add_to_registry()
)

zigpy_device = create_mock_zigpy_device(
zha_gateway,
{
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
OppleCluster.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.OCCUPANCY_SENSOR,
SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID,
}
},
manufacturer="Fake_Manufacturer_sensor_2",
model="Fake_Model_sensor_2",
)
zigpy_device = registry.get_device(zigpy_device)

# 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 attributes init.
with patch("zha.application.discovery.EndpointProbe.discover_entities"):
zha_device = await join_zigpy_device(zha_gateway, zigpy_device)
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 attributes to be initialized
# otherwise, ZHA won't configure the cluster handler, so attributes are not read
assert opple_ch in zha_device.endpoints[1].claimed_cluster_handlers.values()

# check that BIND is set to False, as no reporting is configured
assert opple_ch.BIND is False

# check ZCL_INIT_ATTRS contains sensor attributes that are not in REPORT_CONFIG
assert opple_ch.ZCL_INIT_ATTRS == {
"last_feeding_size": 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 empty, both instance and class variable
assert opple_ch.REPORT_CONFIG == ()
assert OppleRemoteClusterHandler.REPORT_CONFIG == ()


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

Expand Down
24 changes: 22 additions & 2 deletions zha/application/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ def discover_quirks_v2_entities(self, device: Device) -> Iterator[PlatformEntity

assert cluster_handler

# flags to determine if we need to claim/bind the cluster handler
attribute_initialization_found: bool = False
reporting_found: bool = False

for entity_metadata in entity_metadata_list:
platform = Platform(entity_metadata.entity_platform.value)
metadata_type = type(entity_metadata)
Expand All @@ -254,6 +258,7 @@ def discover_quirks_v2_entities(self, device: Device) -> Iterator[PlatformEntity

# process the entity metadata for ZCL_INIT_ATTRS and REPORT_CONFIG
if attr_name := getattr(entity_metadata, "attribute_name", None):
# TODO: ignore "attribute write buttons"? currently, we claim ch
# 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
Expand All @@ -268,8 +273,8 @@ def discover_quirks_v2_entities(self, device: Device) -> Iterator[PlatformEntity
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])
# mark cluster handler for claiming and binding later
reporting_found = True

# not in REPORT_CONFIG, add to ZCL_INIT_ATTRS if it not already in
elif attr_name not in cluster_handler.ZCL_INIT_ATTRS:
Expand All @@ -283,6 +288,8 @@ def discover_quirks_v2_entities(self, device: Device) -> Iterator[PlatformEntity
cluster_handler.ZCL_INIT_ATTRS[attr_name] = (
entity_metadata.attribute_initialized_from_cache
)
# mark cluster handler for claiming later, but not binding
attribute_initialization_found = True

yield entity_class(
cluster_handlers=[cluster_handler],
Expand All @@ -299,6 +306,19 @@ def discover_quirks_v2_entities(self, device: Device) -> Iterator[PlatformEntity
[cluster_handler.name],
)

# if the cluster handler is unclaimed, claim it and set BIND accordingly,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder: should we move this to happen before the yield? That way, the cluster handler is fully set up before the entity initialization happens.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't really do that in a nice way, since we need to iterate over all quirks v2 entity metadata for a specific cluster handler before we can make the final decision on whether we can set BIND to False.

This is all still done "pre initialization", since they're just added to Device._pending_entities in discovery here:

zha/zha/zigbee/device.py

Lines 939 to 950 in b172549

new_entities = discovery.DEVICE_PROBE.discover_device_entities(self)
# Discover all applicable entities
for entity in new_entities:
if self._is_entity_removed_by_quirk(entity):
continue
# Apply any metadata changes from quirks v2
self._apply_entity_metadata_changes(entity)
entity.on_add()
self._pending_entities.append(entity)

Nothing in entity.on_add() can also ever rely on the cluster handler being claimed or not.
The actual cluster handler initialization is done way later, where it then matters if a cluster handler is claimed or not. I think it's fine to keep it like this tbh.

self._discover_new_entities() does everything we do here. Only afterwards is endpoint.async_initialize called:

zha/zha/zigbee/device.py

Lines 956 to 969 in b172549

self._discover_new_entities()
await self._zdo_handler.async_initialize(from_cache)
self._zdo_handler.debug("'async_initialize' stage succeeded")
# We intentionally do not use `gather` here! This is so that if, for example,
# three `device.async_initialize()`s are spawned, only three concurrent requests
# will ever be in flight at once. Startup concurrency is managed at the device
# level.
for endpoint in self._endpoints.values():
try:
await endpoint.async_initialize(from_cache)
except Exception: # pylint: disable=broad-exception-caught
self.debug("Failed to initialize endpoint", exc_info=True)

# so ZHA configures the cluster handler: reporting + reads attributes
if (attribute_initialization_found or reporting_found) and (
cluster_handler not in endpoint.claimed_cluster_handlers.values()
Copy link
Contributor

@puddly puddly Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any way for the cluster handler to already be claimed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cluster handler can already be claimed by entities from EndpointProbe. Since those ZHA entities may rely on the default of BIND = True, I've added the cluster_handler not in endpoint.claimed_cluster_handlers.values() check to make sure we only ever set BIND = False for cluster handlers only claimed for quirks v2 entities.

This is also checked by the tests.

):
endpoint.claim_cluster_handlers([cluster_handler])
# BIND is True by default, so only set to False if no reporting found.
# We can safely do this, since quirks v2 entities are initialized last,
# so if the cluster handler wasn't claimed by EndpointProbe so far,
# only v2 entities need it.
if not reporting_found:
cluster_handler.BIND = False

@ignore_exceptions_during_iteration
def discover_coordinator_device_entities(
self, device: Device
Expand Down
Loading