Repository: zigpy/zha-device-handlers
Pull request #4088 (2025-05-24 20:59:51)

Bump zigpy to 0.80.0
## Proposed change
Bump zigpy to [0.80.0](https://github.com/zigpy/zigpy/releases/tag/0.80.0) and adjust a bunch of quirks code to work properly.


## Additional information
0.79.0 includes `suggested_display_precision`, which we need for some v2 quirks.


## Checklist

- [ ] The changes are tested and work correctly
- [ ] `pre-commit` checks pass / the code has been formatted using Black
- [ ] Tests have been added to verify that the new code works


TheJulianJES: Tuya quirks seem to be broken by this PR:
- https://github.com/zigpy/zigpy/pull/1596

As mentioned, it's a breaking change apparently 😄 
I wonder if we actually broke some Tuya quirks since HA 2025.5.0...
TheJulianJES: Ugh, the main issue with the Tuya stuff was this: [`6ce82c9` (#4088)](https://github.com/zigpy/zha-device-handlers/pull/4088/commits/6ce82c90f7b7392fffffcf2aebc333b1b463cf72)

Since we're creating a new object (in the new deserialization) and need to pass the args here, this obviously removed them when creating a new one. I looked over this like ten times and was wondering why it didn't work, especially since most changes are for the `IntStruct`.

The issues left are XBee tests erroring out (and hanging) and some weird Tuya test data.
puddly: Thanks for taking a look! Let me see if I can figure out why `__new__` was overridden like this, very strange.
TheJulianJES: So apparently removing the ``__new__`` override actually causes the other Tuya tests to break, even with the older zigpy.
Both the one small test snippet in `test_tuya_quirkbuilder` and all others in `tuya_test_rcbo` (that are failing in this PR).
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/4088?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
Attention: Patch coverage is `96.87500%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 91.24%. Comparing base [(`f9898ee`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/f9898ee812046cb26c11995347d82f059f16bfb3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`2aceac1`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/2aceac1bfc52477397351d0b1f6c0a829bd4aae3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).

| [Files with missing lines](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/4088?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) | Patch % | Lines |
|---|---|---|
| [zhaquirks/tuya/\_\_init\_\_.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/4088?src=pr&el=tree&filepath=zhaquirks%2Ftuya%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL3R1eWEvX19pbml0X18ucHk=) | 96.87% | [1 Missing :warning: ](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/4088?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) |

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##              dev    #4088   +/-   ##
=======================================
  Coverage   91.23%   91.24%           
=======================================
  Files         338      338           
  Lines       10906    10918   +12     
=======================================
+ Hits         9950     9962   +12     
  Misses        956      956           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/4088?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
puddly: The Tuya code is very fragile. I'm having a hard time making sense of how or why it works 😅.

The current Tuya DP <-> ZCL attributes and commands mapping may be a use case for direct ZHA entity management and creation, if we ever want to go down that route.
TheJulianJES: > The Tuya code is very fragile. I'm having a hard time making sense of how or why it works 😅.

Oh, yeah... We've generally tried to not touch too much of the old Tuya code, since there are very few tests for the old Tuya quirks, and there are unfortunately also some very popular custom quirks that depend on how everything currently *"works"*.

The goal is to switch as many (upstream v1 and custom) Tuya quirks over to v2, since they take priority and also all depend on `TuyaQuirkBuilder`, allowing us to eventually swap out quite a big portion of the whole DP <-> ZCL conversion below, still relying on the `Bus` system (with DP writes not waiting on any confirmation, causing race conditions when writing multiple DPs at the same time).

Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 8021b3c..77c0710 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,7 +14,7 @@ readme = "README.md"
 license = {text = "Apache-2.0"}
 requires-python = ">=3.12"
 dependencies = [
-    "zigpy>=0.76.0",
+    "zigpy>=0.80.0",
 ]
 
 [tool.setuptools.packages.find]
diff --git a/zhaquirks/tuya/__init__.py b/zhaquirks/tuya/__init__.py
index 397649c..1ee4d3f 100644
--- a/zhaquirks/tuya/__init__.py
+++ b/zhaquirks/tuya/__init__.py
@@ -148,13 +148,41 @@ class TuyaDPType(t.enum8):
     BITMAP = 0x05
 
 
-class TuyaData(t.Struct):
+class TuyaData:
     """Tuya Data type."""
 
     dp_type: TuyaDPType
     function: t.uint8_t
     raw: t.LVBytes
 
+    def __init__(
+        self,
+        value: TuyaDPType | None = None,
+        function: t.uint8_t = t.uint8_t(0),
+        raw: bytes | None = None,
+    ):
+        """Convert from a zigpy typed value to a tuya data payload."""
+        self.dp_type = None
+        self.function = function
+        self.raw = raw
+
+        if value is None:
+            return
+        elif isinstance(value, (t.bitmap8, t.bitmap16, t.bitmap32)):
+            self.dp_type = TuyaDPType.BITMAP
+        elif isinstance(value, (bool, t.Bool)):
+            self.dp_type = TuyaDPType.BOOL
+        elif isinstance(value, enum.Enum):
+            self.dp_type = TuyaDPType.ENUM
+        elif isinstance(value, int):
+            self.dp_type = TuyaDPType.VALUE
+        elif isinstance(value, str):
+            self.dp_type = TuyaDPType.STRING
+        else:
+            self.dp_type = TuyaDPType.RAW
+
+        self.payload = value
+
     @property
     def payload(
         self,
@@ -208,30 +236,28 @@ class TuyaData(t.Struct):
         else:
             raise ValueError(f"Unknown {self.dp_type} datapoint type")
 
-    def __new__(cls, *args, **kwargs):
-        """Disable copy constructor."""
-        return super().__new__(cls)
+    def serialize(self) -> bytes:
+        """Serialize Tuya data."""
+        return (
+            self.dp_type.serialize()
+            + self.function.serialize()
+            + bytes([len(self.raw)])
+            + self.raw
+        )
 
-    def __init__(self, value=None, function=0, *args, **kwargs):
-        """Convert from a zigpy typed value to a tuya data payload."""
-        self.function = function
+    @classmethod
+    def deserialize(cls, data: bytes) -> tuple[TuyaData, bytes]:
+        """Deserialize Tuya data."""
+        dp_type, data = TuyaDPType.deserialize(data)
+        function, data = t.uint8_t.deserialize(data)
+        raw, data = t.LVBytes.deserialize(data)
 
-        if value is None:
-            return
-        elif isinstance(value, (t.bitmap8, t.bitmap16, t.bitmap32)):
-            self.dp_type = TuyaDPType.BITMAP
-        elif isinstance(value, (bool, t.Bool)):
-            self.dp_type = TuyaDPType.BOOL
-        elif isinstance(value, enum.Enum):  # type: ignore
-            self.dp_type = TuyaDPType.ENUM
-        elif isinstance(value, int):
-            self.dp_type = TuyaDPType.VALUE
-        elif isinstance(value, str):
-            self.dp_type = TuyaDPType.STRING
-        else:
-            self.dp_type = TuyaDPType.RAW
+        instance = cls()
+        instance.dp_type = dp_type
+        instance.function = function
+        instance.raw = raw
 
-        self.payload = value
+        return instance, data
 
 
 class Data(t.List, item_type=t.uint8_t):
@@ -1584,7 +1610,7 @@ class TuyaNewManufCluster(CustomCluster):
                 self.send_default_rsp(
                     hdr, status=foundation.Status.UNSUP_CLUSTER_COMMAND
                 )
-                return
+            return
 
         try:
             status = getattr(self, handler_name)(*args)
diff --git a/zhaquirks/xbee/__init__.py b/zhaquirks/xbee/__init__.py
index b25a4fc..1b05b5a 100644
--- a/zhaquirks/xbee/__init__.py
+++ b/zhaquirks/xbee/__init__.py
@@ -284,6 +284,7 @@ class XBeeRemoteATRequest(LocalDataCluster):
             name=v[0].replace("%V", "PercentV").replace("V+", "VPlus"),
             schema={"param?": v[1]} if v[1] else {},
             is_manufacturer_specific=True,
+            direction=foundation.Direction.Client_to_Server,
         )
         for k, v in zip(range(1, len(AT_COMMANDS) + 1), AT_COMMANDS.items())
     }
@@ -472,6 +473,7 @@ class XBeeRemoteATResponse(LocalDataCluster):
                 "value": Bytes,
             },
             is_manufacturer_specific=True,
+            direction=foundation.Direction.Client_to_Server,
         )
     }
 
@@ -525,6 +527,7 @@ class XBeeDigitalIOCluster(LocalDataCluster, BinaryInput):
             name="io_sample",
             schema={"io_sample": IOSample},
             is_manufacturer_specific=True,
+            direction=foundation.Direction.Client_to_Server,
         )
     }
 
@@ -541,11 +544,15 @@ class XBeeEventRelayCluster(EventableCluster, LocalDataCluster, LevelControl):
             + "_command_response",
             schema={"response?": v[1]} if v[1] else {},
             is_manufacturer_specific=True,
+            direction=foundation.Direction.Client_to_Server,
         )
         for k, v in zip(range(1, len(AT_COMMANDS) + 1), AT_COMMANDS.items())
     }
     server_commands[SERIAL_DATA_CMD] = foundation.ZCLCommandDef(
-        name="receive_data", schema={"data": str}, is_manufacturer_specific=True
+        name="receive_data",
+        schema={"data": str},
+        is_manufacturer_specific=True,
+        direction=foundation.Direction.Client_to_Server,
     )
 
 
@@ -605,6 +612,7 @@ class XBeeSerialDataCluster(LocalDataCluster):
             name="send_data",
             schema={"data": BinaryString},
             is_manufacturer_specific=True,
+            direction=foundation.Direction.Server_to_Client,
         )
     }
     server_commands = {
@@ -612,6 +620,7 @@ class XBeeSerialDataCluster(LocalDataCluster):
             name="receive_data",
             schema={"data": BinaryString},
             is_manufacturer_specific=True,
+            direction=foundation.Direction.Client_to_Server,
         )
     }
 


Test patch:
diff --git a/requirements_test.txt b/requirements_test.txt
index b0d2e96..f373004 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -13,5 +13,5 @@ pytest-sugar
 pytest-timeout
 pytest-asyncio
 pytest>=7.1.3
-zigpy>=0.76.0
+zigpy>=0.80.0
 ruff==0.0.261
diff --git a/tests/test_tuya_clusters.py b/tests/test_tuya_clusters.py
index 2dd809a..9d2ac36 100644
--- a/tests/test_tuya_clusters.py
+++ b/tests/test_tuya_clusters.py
@@ -252,7 +252,7 @@ def test_tuya_cluster_request(
     """Test cluster specific request."""
 
     hdr = zcl_f.ZCLHeader.general(1, cmd_id, direction=zcl_f.Direction.Server_to_Client)
-    hdr.frame_control.disable_default_response = False
+    hdr.frame_control = hdr.frame_control.replace(disable_default_response=False)
 
     with mock.patch.object(TuyaCluster, handler_name) as handler:
         handler.return_value = mock.sentinel.status
@@ -267,7 +267,7 @@ def test_tuya_cluster_request_unk_command(default_rsp_mock, TuyaCluster):
     """Test cluster specific request handler -- no handler."""
 
     hdr = zcl_f.ZCLHeader.general(1, 0xFE, direction=zcl_f.Direction.Server_to_Client)
-    hdr.frame_control.disable_default_response = False
+    hdr.frame_control = hdr.frame_control.replace(disable_default_response=False)
 
     TuyaCluster.handle_cluster_request(hdr, (mock.sentinel.args,))
     assert default_rsp_mock.call_count == 1
@@ -279,7 +279,7 @@ def test_tuya_cluster_request_no_handler(default_rsp_mock, TuyaCluster):
     """Test cluster specific request handler -- no handler."""
 
     hdr = zcl_f.ZCLHeader.general(1, 0xFE, direction=zcl_f.Direction.Server_to_Client)
-    hdr.frame_control.disable_default_response = False
+    hdr.frame_control = hdr.frame_control.replace(disable_default_response=False)
 
     new_client_commands = TuyaCluster.client_commands.copy()
     new_client_commands[0xFE] = zcl_f.ZCLCommandDef(