Add support for Develco/frient Smart Plugs (SPLZB-131/132/134/137/141/142/144/147), Smart Cables (SMRZB-143/153) and Smart DIN Relays (SMRZB-332/342)#4871
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## dev #4871 +/- ##
==========================================
+ Coverage 93.04% 93.06% +0.02%
==========================================
Files 397 397
Lines 13248 13290 +42
==========================================
+ Hits 12327 12369 +42
Misses 921 921 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR extends ZHA-Quirks support for multiple Develco/frient smart plugs/cables/DIN relays by adding vendor-specific delayed on/off controls and exposing additional diagnostic entities via a custom OnOff cluster replacement.
Changes:
- Add a
VendorOnOffcustom cluster that maps writes to vendor “safe mode” commands and seeds default attribute values. - Expand model/manufacturer coverage for SPLZB/SMRZB device variants and add new HA entities (delay numbers + diagnostic binary sensor).
- Add unit tests validating cluster behavior, request construction, and v2 entity metadata/disabled-default-entity registration.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
zhaquirks/develco/power_plug.py |
Adds custom OnOff cluster and v2 quirk builder metadata/entities for the supported device family. |
tests/test_develco.py |
Adds tests for the new custom cluster behavior and v2 registry/entity metadata expectations. |
| from zigpy.zcl.clusters.general import DeviceTemperature, OnOff | ||
| from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement | ||
| from zigpy.zcl.foundation import ZCLAttributeDef |
There was a problem hiding this comment.
Metering is imported but not used in this module, which will fail linting (ruff/pyflakes F401). Remove the unused import, or add the metering-related logic/entities that were intended to use it.
| mode_value = None | ||
|
|
||
| if self.AttributeDefs.mode_on_value.id in attributes_copy: | ||
| mode_value = attributes_copy.pop(self.AttributeDefs.mode_on_value.id) | ||
| await self._send_safe_mode(0x01, mode_value) | ||
| self._update_attribute(self.AttributeDefs.mode_on_value.id, mode_value) | ||
| elif self.AttributeDefs.mode_off_value.id in attributes_copy: | ||
| mode_value = attributes_copy.pop(self.AttributeDefs.mode_off_value.id) | ||
| await self._send_safe_mode(0x00, mode_value) | ||
| self._update_attribute(self.AttributeDefs.mode_off_value.id, mode_value) | ||
| elif self.AttributeDefs.mode_on_value.name in attributes_copy: | ||
| mode_value = attributes_copy.pop(self.AttributeDefs.mode_on_value.name) | ||
| await self._send_safe_mode(0x01, mode_value) | ||
| self._update_attribute(self.AttributeDefs.mode_on_value.id, mode_value) | ||
| elif self.AttributeDefs.mode_off_value.name in attributes_copy: | ||
| mode_value = attributes_copy.pop(self.AttributeDefs.mode_off_value.name) | ||
| await self._send_safe_mode(0x00, mode_value) | ||
| self._update_attribute(self.AttributeDefs.mode_off_value.id, mode_value) | ||
|
|
||
| if attributes_copy: | ||
| return await super().write_attributes(attributes_copy, **kwargs) | ||
|
|
||
| return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] | ||
|
|
||
|
|
There was a problem hiding this comment.
write_attributes() only handles one of the vendor delay attributes because of the if/elif chain. If a caller writes both mode_on_value and mode_off_value in the same call, the second attribute will fall through to super().write_attributes(...) and be sent as a real Zigbee write (likely unsupported). Consider normalizing keys first and processing both vendor attributes (and their ZCLAttributeDef key form) before delegating the remaining attributes to super().
| mode_value = None | |
| if self.AttributeDefs.mode_on_value.id in attributes_copy: | |
| mode_value = attributes_copy.pop(self.AttributeDefs.mode_on_value.id) | |
| await self._send_safe_mode(0x01, mode_value) | |
| self._update_attribute(self.AttributeDefs.mode_on_value.id, mode_value) | |
| elif self.AttributeDefs.mode_off_value.id in attributes_copy: | |
| mode_value = attributes_copy.pop(self.AttributeDefs.mode_off_value.id) | |
| await self._send_safe_mode(0x00, mode_value) | |
| self._update_attribute(self.AttributeDefs.mode_off_value.id, mode_value) | |
| elif self.AttributeDefs.mode_on_value.name in attributes_copy: | |
| mode_value = attributes_copy.pop(self.AttributeDefs.mode_on_value.name) | |
| await self._send_safe_mode(0x01, mode_value) | |
| self._update_attribute(self.AttributeDefs.mode_on_value.id, mode_value) | |
| elif self.AttributeDefs.mode_off_value.name in attributes_copy: | |
| mode_value = attributes_copy.pop(self.AttributeDefs.mode_off_value.name) | |
| await self._send_safe_mode(0x00, mode_value) | |
| self._update_attribute(self.AttributeDefs.mode_off_value.id, mode_value) | |
| if attributes_copy: | |
| return await super().write_attributes(attributes_copy, **kwargs) | |
| return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] | |
| handled_vendor_attr = False | |
| # Normalize keys and handle all occurrences of the vendor attributes, | |
| # regardless of whether they are addressed by ID, name, or ZCLAttributeDef. | |
| for key in list(attributes_copy.keys()): | |
| attr_def: ZCLAttributeDef | None = None | |
| if isinstance(key, ZCLAttributeDef): | |
| attr_def = key | |
| elif key == self.AttributeDefs.mode_on_value.id or key == self.AttributeDefs.mode_on_value.name: | |
| attr_def = self.AttributeDefs.mode_on_value | |
| elif key == self.AttributeDefs.mode_off_value.id or key == self.AttributeDefs.mode_off_value.name: | |
| attr_def = self.AttributeDefs.mode_off_value | |
| if attr_def is None: | |
| continue | |
| if ( | |
| attr_def is self.AttributeDefs.mode_on_value | |
| or attr_def.id == self.AttributeDefs.mode_on_value.id | |
| ): | |
| mode_value = attributes_copy.pop(key) | |
| await self._send_safe_mode(0x01, mode_value) | |
| self._update_attribute(self.AttributeDefs.mode_on_value.id, mode_value) | |
| handled_vendor_attr = True | |
| elif ( | |
| attr_def is self.AttributeDefs.mode_off_value | |
| or attr_def.id == self.AttributeDefs.mode_off_value.id | |
| ): | |
| mode_value = attributes_copy.pop(key) | |
| await self._send_safe_mode(0x00, mode_value) | |
| self._update_attribute(self.AttributeDefs.mode_off_value.id, mode_value) | |
| handled_vendor_attr = True | |
| if attributes_copy: | |
| return await super().write_attributes(attributes_copy, **kwargs) | |
| if handled_vendor_attr: | |
| # Only vendor attributes were written; report success without | |
| # issuing any underlying ZCL write. | |
| return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] | |
| # No attributes to handle; fall back to the base implementation. | |
| return await super().write_attributes(attributes_copy, **kwargs) |
| mode_on_value: Final = ZCLAttributeDef(id=0x8102, type=t.uint8_t, access="w") | ||
| mode_off_value: Final = ZCLAttributeDef(id=0x8103, type=t.uint8_t, access="w") | ||
| return_to_state: Final = ZCLAttributeDef( | ||
| id=0x8101, type=t.Bool, access="r", manufacturer_code=MANUFACTURER_CODE |
There was a problem hiding this comment.
return_to_state is defined with access="r", but the quirk configures attribute reporting for it via ReportingConfig. If this attribute is meant to be reportable, mark it as "rp" (or "rwp" if writable); otherwise remove the reporting configuration to avoid attempting to configure reporting on a non-reportable attribute.
| id=0x8101, type=t.Bool, access="r", manufacturer_code=MANUFACTURER_CODE | |
| id=0x8101, type=t.Bool, access="rp", manufacturer_code=MANUFACTURER_CODE |
| .prevent_default_entity_creation( | ||
| endpoint_id=2, | ||
| cluster_id=DeviceTemperature.cluster_id, | ||
| function=lambda entity: entity.__class__.__name__ == "DeviceTemperature", |
There was a problem hiding this comment.
Using entity.__class__.__name__ == "DeviceTemperature" to identify the default temperature entity is brittle (class names can change upstream). If the intent is to remove the default temperature entity entirely on endpoint 2, prefer calling prevent_default_entity_creation(endpoint_id=2, cluster_id=DeviceTemperature.cluster_id) without a predicate, or match on a stable property like translation_key/unique_id if you only want to suppress a specific one.
| function=lambda entity: entity.__class__.__name__ == "DeviceTemperature", |
Proposed change
The quirk has been made to extend functionality of the following frient/Develco Smart Plugs, Smart Cables and Smart DIN Relays:
The quirk contains following changes:
Additional information
Device diagnostics
zha-01KJYXWD9VV3FMNDJEFN115MC4-frient A_S SMRZB-153-59b661ecb0fc7ef52e3630f969e79742.json
zha-01KJYXWD9VV3FMNDJEFN115MC4-frient A_S SMRZB-342-c9ca9838109ed505f89b8687390fccfc.json
zha-01KJYXWD9VV3FMNDJEFN115MC4-frient A_S SPLZB-132-54d2a1249af9626f191030cccc09a47d.json
Checklist
pre-commitchecks pass / the code has been formatted using Black