Repository: zigpy/zha-device-handlers

Issue:
[BUG] Upgrading Home Assistant to 2022.4.4 breaks Elko Super TR thermostat.
**Describe the bug**

Upgrading Home Assistant to 2022.4.4 breaks Elko Super TR thermostat.

**To Reproduce**

Upgrade Home Assistant from an earlier version to 2022.4.4 no longer allows control of the thermostat. Commands fail with "Failed to call service climate/set_hvac_mode. 'system_mode'" 

**Expected behavior**

Pressing the on button on the default thermostat card should enable the heater. Fails with error message.

<details>
<summary>Device signature </summary>

```
{
  "node_descriptor": "NodeDescriptor(logical_type=<LogicalType.Router: 1>, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.AllocateAddress|RxOnWhenIdle|MainsPowered|FullFunctionDevice: 142>, manufacturer_code=4919, maximum_buffer_size=82, maximum_incoming_transfer_size=82, server_mask=11264, maximum_outgoing_transfer_size=82, descriptor_capability_field=<DescriptorCapability.NONE: 0>, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=False, *is_full_function_device=True, *is_mains_powered=True, *is_receiver_on_when_idle=True, *is_router=True, *is_security_capable=False)",
  "endpoints": {
    "1": {
      "profile_id": 260,
      "device_type": "0x0301",
      "in_clusters": [
        "0x0000",
        "0x0003",
        "0x0004",
        "0x0005",
        "0x0201",
        "0x0204",
        "0x0b04"
      ],
      "out_clusters": [
        "0x0003",
        "0x0019"
      ]
    }
  },
  "manufacturer": "ELKO",
  "model": "Super TR",
  "class": "zhaquirks.elko.smart_super_thermostat.ElkoSuperTRThermostat"
}
```



</details>


<details>
<summary>Additional logs</summary>

```File "/usr/src/homeassistant/homeassistant/components/zha/climate.py", line 425, in async_set_hvac_mode
if await self._thrm.async_set_operation_mode(HVAC_MODE_2_SYSTEM[hvac_mode]):
File "/usr/src/homeassistant/homeassistant/components/zha/core/channels/hvac.py", line 243, in async_set_operation_mode
if not await self.write_attributes({"system_mode": mode}):
File "/usr/src/homeassistant/homeassistant/components/zha/core/channels/hvac.py", line 292, in write_attributes
res = await self.cluster.write_attributes(data, **kwargs)
File "/usr/local/lib/python3.9/site-packages/zhaquirks/elko/smart_super_thermostat.py", line 110, in write_attributes
return await super().write_attributes(attributes, manufacturer=manufacturer)
File "/usr/local/lib/python3.9/site-packages/zigpy/zcl/__init__.py", line 535, in write_attributes
attrs = self._write_attr_records(attributes)
File "/usr/local/lib/python3.9/site-packages/zigpy/zcl/__init__.py", line 502, in _write_attr_records
attr_def = self.find_attribute(attrid)
File "/usr/local/lib/python3.9/site-packages/zigpy/zcl/__init__.py", line 186, in find_attribute
return self.attributes_by_name[name_or_id]
KeyError: 'system_mode'
```

</details>




Discussion:

Resolving patch:
diff --git a/zhaquirks/tuya/mcu/__init__.py b/zhaquirks/tuya/mcu/__init__.py
--- a/zhaquirks/tuya/mcu/__init__.py
+++ b/zhaquirks/tuya/mcu/__init__.py
@@ -10,7 +10,6 @@
 
 from zhaquirks import Bus, DoublingPowerConfigurationCluster
 from zhaquirks.tuya import (
-    ATTR_ON_OFF,
     TUYA_MCU_COMMAND,
     TUYA_MCU_VERSION_RSP,
     TUYA_SET_DATA,
@@ -270,10 +269,6 @@ def handle_mcu_version_response(self, payload: MCUVersion) -> foundation.Status:
 class TuyaOnOff(OnOff, TuyaLocalCluster):
     """Tuya MCU OnOff cluster."""
 
-    attributes = {
-        ATTR_ON_OFF: ("on_off", t.Bool),
-    }
-
     async def command(
         self,
         command_id: Union[foundation.GeneralCommand, int, t.uint8_t],
@@ -316,8 +311,6 @@ async def command(
 class TuyaOnOffNM(NoManufacturerCluster, TuyaOnOff):
     """Tuya OnOff cluster with NoManufacturerID."""
 
-    pass
-
 
 class TuyaOnOffManufCluster(TuyaMCUCluster):
     """Tuya with On/Off data points."""
@@ -373,10 +366,13 @@ class TuyaOnOffManufCluster(TuyaMCUCluster):
 class MoesSwitchManufCluster(TuyaOnOffManufCluster):
     """On/Off Tuya cluster with extra device attributes."""
 
-    attributes = {
-        0x8001: ("backlight_mode", MoesBacklight),
-        0x8002: ("power_on_state", PowerOnState),
-    }
+    attributes = TuyaOnOffManufCluster.attributes.copy()
+    attributes.update(
+        {
+            0x8001: ("backlight_mode", MoesBacklight),
+            0x8002: ("power_on_state", PowerOnState),
+        }
+    )
 
     dp_to_attribute: Dict[
         int, DPToAttributeMapping
@@ -410,7 +406,8 @@ class MoesSwitchManufCluster(TuyaOnOffManufCluster):
 class TuyaLevelControl(LevelControl, TuyaLocalCluster):
     """Tuya MCU Level cluster for dimmable device."""
 
-    attributes = {0x0000: ("current_level", t.uint8_t)}
+    attributes = LevelControl.attributes.copy()
+    attributes.update({0x0000: ("current_level", t.uint8_t)})
 
     async def command(
         self,


Test patch:
diff --git a/tests/test_quirks.py b/tests/test_quirks.py
--- a/tests/test_quirks.py
+++ b/tests/test_quirks.py
@@ -62,6 +62,8 @@
 
 del quirk, model_quirk_list, manufacturer
 
+ALL_ZIGPY_CLUSTERS = frozenset(zcl.clusters.CLUSTERS_BY_NAME.values())
+
 
 SIGNATURE_ALLOWED = {
     ENDPOINTS,
@@ -658,3 +660,47 @@ def test_quirk_device_automation_triggers_unique(quirk):
                 [f" * {event} <- {trigger}" for trigger, event in triggers_and_events]
             )
             fail_func(f"Triggers are not unique for {quirk}:\n{triggers_text}")
+
+
+@pytest.mark.parametrize(
+    "quirk",
+    [
+        quirk_cls
+        for quirk_cls in ALL_QUIRK_CLASSES
+        if quirk_cls
+        not in (
+            zhaquirks.xbee.xbee_io.XBeeSensor,
+            zhaquirks.xbee.xbee3_io.XBee3Sensor,
+        )
+    ],
+)
+def test_attributes_updated_not_replaced(quirk: CustomDevice) -> None:
+    """Verify no quirks subclass a ZCL cluster but delete its attributes list."""
+
+    for ep_id, ep_data in quirk.replacement[ENDPOINTS].items():
+        for cluster in ep_data.get(INPUT_CLUSTERS, []) + ep_data.get(
+            OUTPUT_CLUSTERS, []
+        ):
+            if isinstance(cluster, int) or not issubclass(cluster, zcl.Cluster):
+                continue
+            elif cluster in ALL_ZIGPY_CLUSTERS:
+                continue
+
+            assert issubclass(cluster, zigpy.quirks.CustomCluster)
+
+            base_clusters = set(cluster.__mro__) & ALL_ZIGPY_CLUSTERS
+
+            # Completely custom cluster
+            if len(base_clusters) == 0:
+                continue
+            elif len(base_clusters) > 1:
+                pytest.fail(f"Cluster has more than one zigpy base class: {cluster}")
+
+            base_cluster = list(base_clusters)[0]
+
+            # Ensure the attribute IDs are extended
+            if not set(base_cluster.attributes) <= set(cluster.attributes):
+                pytest.fail(
+                    f"Cluster {cluster} deletes parent class's attributes instead of"
+                    f" extending them: {base_cluster}"
+                )