Repository: zigpy/zha-device-handlers
Pull request #3989 (2025-03-30 13:42:11)

Add temperature and humidity offset to Sonoff SNZB-02D
## Proposed change

Add temperature and humidity calibration to Sonoff SNZB-02D.

## Additional information

N/A.

## Checklist

- [x] The changes are tested and work correctly
- [x] `pre-commit` checks pass / the code has been formatted using Black
- [ ] Tests have been added to verify that the new code works


fgsch: > Also, any issues with this PR still, since it's still in draft? Seems like it should work for the most part.

Nop, sorry, I just been busy with life. I will try to wrap up this PR this week.

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/candeo/__init__.py b/zhaquirks/candeo/__init__.py
new file mode 100644
index 0000000..499d908
--- /dev/null
+++ b/zhaquirks/candeo/__init__.py
@@ -0,0 +1,30 @@
+"""Module for Candeo quirks implementations."""
+
+from zigpy.quirks import CustomCluster
+import zigpy.types as t
+from zigpy.zcl.clusters.general import Basic
+from zigpy.zcl.foundation import DataTypeId, ZCLAttributeDef
+
+CANDEO = "Candeo"
+
+
+class CandeoSwitchType(t.enum8):
+    """Candeo Switch Type."""
+
+    Momentary = 0x00
+    Toggle = 0x01
+
+
+class CandeoBasicCluster(Basic, CustomCluster):
+    """Candeo Basic Cluster."""
+
+    class AttributeDefs(Basic.AttributeDefs):
+        """Attribute Definitions."""
+
+        external_switch_type = ZCLAttributeDef(
+            id=0x8803,
+            type=CandeoSwitchType,
+            zcl_type=DataTypeId.uint8,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
diff --git a/zhaquirks/candeo/candeo_modules_V2.py b/zhaquirks/candeo/candeo_modules_V2.py
new file mode 100644
index 0000000..6797d20
--- /dev/null
+++ b/zhaquirks/candeo/candeo_modules_V2.py
@@ -0,0 +1,36 @@
+"""Candeo modules."""
+
+from zigpy.quirks.v2 import QuirkBuilder
+
+from zhaquirks.candeo import CANDEO, CandeoBasicCluster, CandeoSwitchType
+
+(
+    QuirkBuilder(CANDEO, "C203")
+    .applies_to(CANDEO, "HK-LN-DIM-A")
+    .applies_to(CANDEO, "C204")
+    .applies_to(CANDEO, "C-ZB-DM204")
+    .applies_to(CANDEO, "C205")
+    .replaces(CandeoBasicCluster)
+    .enum(
+        attribute_name=CandeoBasicCluster.AttributeDefs.external_switch_type.name,
+        enum_class=CandeoSwitchType,
+        cluster_id=CandeoBasicCluster.cluster_id,
+        translation_key="external_switch_type",
+        fallback_name="External switch type",
+    )
+    .add_to_registry()
+)
+
+(
+    QuirkBuilder(CANDEO, "C-ZB-SM205-2G")
+    .replaces(CandeoBasicCluster, endpoint_id=11)
+    .enum(
+        attribute_name=CandeoBasicCluster.AttributeDefs.external_switch_type.name,
+        enum_class=CandeoSwitchType,
+        cluster_id=CandeoBasicCluster.cluster_id,
+        endpoint_id=11,
+        translation_key="external_switch_type",
+        fallback_name="External switch type",
+    )
+    .add_to_registry()
+)
diff --git a/zhaquirks/legrand/wirelessswitch.py b/zhaquirks/legrand/wirelessswitch.py
new file mode 100644
index 0000000..5264c49
--- /dev/null
+++ b/zhaquirks/legrand/wirelessswitch.py
@@ -0,0 +1,42 @@
+"""Module for Legrand wireless radiant switch."""
+
+from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.zcl.clusters.general import BinaryInput
+
+from zhaquirks.const import (
+    BUTTON,
+    COMMAND,
+    COMMAND_MOVE,
+    COMMAND_OFF,
+    COMMAND_ON,
+    COMMAND_STOP,
+    LONG_PRESS,
+    LONG_RELEASE,
+    PARAMS,
+    SHORT_PRESS,
+    TURN_OFF,
+    TURN_ON,
+)
+from zhaquirks.legrand import LEGRAND, LegrandPowerConfigurationCluster
+
+(
+    QuirkBuilder(f" {LEGRAND}", " Remote switch")
+    .replaces(LegrandPowerConfigurationCluster)
+    .prevent_default_entity_creation(endpoint_id=1, cluster_id=BinaryInput.cluster_id)
+    .device_automation_triggers(
+        {
+            (SHORT_PRESS, TURN_ON): {COMMAND: COMMAND_ON},
+            (LONG_PRESS, TURN_ON): {
+                COMMAND: COMMAND_MOVE,
+                PARAMS: {"move_mode": 0, "rate": 255},
+            },
+            (SHORT_PRESS, TURN_OFF): {COMMAND: COMMAND_OFF},
+            (LONG_PRESS, TURN_OFF): {
+                COMMAND: COMMAND_MOVE,
+                PARAMS: {"move_mode": 1, "rate": 255},
+            },
+            (LONG_RELEASE, BUTTON): {COMMAND: COMMAND_STOP},
+        }
+    )
+    .add_to_registry()
+)
diff --git a/zhaquirks/sonoff/snzb02d.py b/zhaquirks/sonoff/snzb02d.py
index ff85524..33518d9 100644
--- a/zhaquirks/sonoff/snzb02d.py
+++ b/zhaquirks/sonoff/snzb02d.py
@@ -1,7 +1,7 @@
 """Sonoff SNZB-02D - Zigbee LCD smart temperature humidity sensor."""
 
 from zigpy.quirks import CustomCluster
-from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.quirks.v2 import NumberDeviceClass, QuirkBuilder
 from zigpy.quirks.v2.homeassistant import PERCENTAGE, UnitOfTemperature
 import zigpy.types as t
 from zigpy.zcl import foundation
@@ -50,6 +50,16 @@ class CustomSonoffCluster(CustomCluster):
             zcl_type=DataTypeId.uint16,
         )
 
+        temperature_offset = ZCLAttributeDef(
+            id=0x2003,
+            type=t.int16s,
+        )
+
+        humidity_offset = ZCLAttributeDef(
+            id=0x2004,
+            type=t.int16s,
+        )
+
 
 (
     QuirkBuilder("SONOFF", "SNZB-02D")
@@ -60,6 +70,7 @@ class CustomSonoffCluster(CustomCluster):
         min_value=-10,
         max_value=60,
         step=0.1,
+        device_class=NumberDeviceClass.TEMPERATURE,
         unit=UnitOfTemperature.CELSIUS,
         multiplier=0.01,
         translation_key="comfort_temperature_min",
@@ -71,6 +82,7 @@ class CustomSonoffCluster(CustomCluster):
         min_value=-10,
         max_value=60,
         step=0.1,
+        device_class=NumberDeviceClass.TEMPERATURE,
         unit=UnitOfTemperature.CELSIUS,
         multiplier=0.01,
         translation_key="comfort_temperature_max",
@@ -82,6 +94,7 @@ class CustomSonoffCluster(CustomCluster):
         min_value=5,
         max_value=95,
         step=0.1,
+        device_class=NumberDeviceClass.HUMIDITY,
         unit=PERCENTAGE,
         multiplier=0.01,
         translation_key="comfort_humidity_min",
@@ -93,6 +106,7 @@ class CustomSonoffCluster(CustomCluster):
         min_value=5,
         max_value=95,
         step=0.1,
+        device_class=NumberDeviceClass.HUMIDITY,
         unit=PERCENTAGE,
         multiplier=0.01,
         translation_key="comfort_humidity_max",
@@ -105,5 +119,29 @@ class CustomSonoffCluster(CustomCluster):
         translation_key="display_unit",
         fallback_name="Display unit",
     )
+    .number(
+        CustomSonoffCluster.AttributeDefs.temperature_offset.name,
+        CustomSonoffCluster.cluster_id,
+        min_value=-50,
+        max_value=50,
+        step=0.1,
+        device_class=NumberDeviceClass.TEMPERATURE,
+        unit=UnitOfTemperature.CELSIUS,
+        multiplier=0.01,
+        translation_key="temperature_offset",
+        fallback_name="Temperature offset",
+    )
+    .number(
+        CustomSonoffCluster.AttributeDefs.humidity_offset.name,
+        CustomSonoffCluster.cluster_id,
+        min_value=-50,
+        max_value=50,
+        step=0.1,
+        device_class=NumberDeviceClass.HUMIDITY,
+        unit=PERCENTAGE,
+        multiplier=0.01,
+        translation_key="humidity_offset",
+        fallback_name="Humidity offset",
+    )
     .add_to_registry()
 )
diff --git a/zhaquirks/sonoff/zbminir2.py b/zhaquirks/sonoff/zbminir2.py
index 77ab5bb..5d0168b 100644
--- a/zhaquirks/sonoff/zbminir2.py
+++ b/zhaquirks/sonoff/zbminir2.py
@@ -13,41 +13,27 @@ class SonoffCluster(CustomCluster):
 
     cluster_id = 0xFC11
 
+    manufacturer_id_override = foundation.ZCLHeader.NO_MANUFACTURER_ID
+
     class AttributeDefs(BaseAttributeDefs):
         """Attribute definitions."""
 
         external_trigger_mode = ZCLAttributeDef(
             id=0x0016,
             type=t.uint8_t,
+            is_manufacturer_specific=True,
         )
         detach_relay = ZCLAttributeDef(
             id=0x0017,
             type=t.Bool,
+            is_manufacturer_specific=True,
         )
         turbo_mode = ZCLAttributeDef(
             id=0x0012,
             type=t.int16s,
+            is_manufacturer_specific=True,
         )
 
-    async def _read_attributes(
-        self,
-        attribute_ids: list[t.uint16_t],
-        *args,
-        manufacturer: int | t.uint16_t | None = None,
-        **kwargs,
-    ):
-        """Read attributes ZCL foundation command."""
-        return await super()._read_attributes(
-            attribute_ids,
-            *args,
-            manufacturer=foundation.ZCLHeader.NO_MANUFACTURER_ID,
-            **kwargs,
-        )
-
-    @property
-    def _is_manuf_specific(self):
-        return False
-
 
 class SonoffExternalSwitchTriggerType(types.enum8):
     """extern switch trigger type."""
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/tuya/ts0601_power.py b/zhaquirks/tuya/ts0601_power.py
index 2c22744..f45fc94 100644
--- a/zhaquirks/tuya/ts0601_power.py
+++ b/zhaquirks/tuya/ts0601_power.py
@@ -2,11 +2,13 @@
 
 from collections.abc import ByteString
 
-from zigpy.quirks.v2 import SensorDeviceClass, SensorStateClass
+from zigpy.quirks.v2 import EntityType, SensorDeviceClass, SensorStateClass
 from zigpy.quirks.v2.homeassistant import (
+    PERCENTAGE,
     UnitOfElectricCurrent,
     UnitOfEnergy,
     UnitOfPower,
+    UnitOfTime,
 )
 import zigpy.types as t
 from zigpy.zcl.clusters.general import LevelControl, OnOff
@@ -216,3 +218,196 @@ class Tuya3PhaseElectricalMeasurement(ElectricalMeasurement, TuyaLocalCluster):
     .skip_configuration()
     .add_to_registry()
 )
+
+(
+    TuyaQuirkBuilder("_TZE200_dikb3dp6", "TS0601")
+    .applies_to("_TZE204_dikb3dp6", "TS0601")
+    .applies_to("_TZE284_dikb3dp6", "TS0601")
+    .tuya_sensor(
+        dp_id=1,
+        attribute_name="energy",
+        type=t.int32s,
+        divisor=100,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        fallback_name="Total energy",
+    )
+    .tuya_sensor(
+        dp_id=23,
+        attribute_name="energy_produced",
+        type=t.uint32_t,
+        divisor=100,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_produced",
+        fallback_name="Energy produced",
+    )
+    .tuya_dp(
+        dp_id=29,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="total_active_power",
+    )
+    .tuya_dp(
+        dp_id=32,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="ac_frequency",
+        converter=lambda x: x / 100,
+    )
+    .tuya_sensor(
+        dp_id=50,
+        attribute_name="power_factor",
+        type=t.uint8_t,
+        state_class=SensorStateClass.MEASUREMENT,
+        device_class=SensorDeviceClass.POWER_FACTOR,
+        unit=PERCENTAGE,
+        translation_key="total_power_factor",
+        fallback_name="Total power factor",
+    )
+    .tuya_number(
+        dp_id=102,
+        attribute_name="update_frequency",
+        type=t.uint16_t,
+        device_class=SensorDeviceClass.DURATION,
+        unit=UnitOfTime.SECONDS,
+        min_value=5,
+        max_value=3600,
+        step=1,
+        entity_type=EntityType.CONFIG,
+        translation_key="update_frequency",
+        fallback_name="Update frequency",
+    )
+    # Phase A
+    .tuya_dp(
+        dp_id=103,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="rms_voltage",
+    )
+    .tuya_dp(
+        dp_id=104,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="rms_current",
+    )
+    .tuya_dp(
+        dp_id=105,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="active_power",
+    )
+    .tuya_dp(
+        dp_id=108,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="power_factor",
+    )
+    .tuya_sensor(
+        dp_id=109,
+        attribute_name="energy_consumed_ph_a",
+        type=t.uint32_t,
+        divisor=100,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_ph_a",
+        fallback_name="Energy phase A",
+    )
+    .tuya_sensor(
+        dp_id=110,
+        attribute_name="energy_produced_ph_a",
+        type=t.uint32_t,
+        divisor=100,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_produced_ph_a",
+        fallback_name="Energy produced phase A",
+    )
+    # Phase B
+    .tuya_dp(
+        dp_id=112,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="rms_voltage_ph_b",
+    )
+    .tuya_dp(
+        dp_id=113,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="rms_current_ph_b",
+    )
+    .tuya_dp(
+        dp_id=114,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="active_power_ph_b",
+    )
+    .tuya_dp(
+        dp_id=117,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="power_factor_ph_b",
+    )
+    .tuya_sensor(
+        dp_id=118,
+        attribute_name="energy_consumed_ph_b",
+        type=t.uint32_t,
+        divisor=100,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_ph_b",
+        fallback_name="Energy phase B",
+    )
+    .tuya_sensor(
+        dp_id=119,
+        attribute_name="energy_produced_ph_b",
+        type=t.uint32_t,
+        divisor=100,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_produced_ph_b",
+        fallback_name="Energy produced phase B",
+    )
+    # Phase C
+    .tuya_dp(
+        dp_id=121,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="rms_voltage_ph_c",
+    )
+    .tuya_dp(
+        dp_id=122,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="rms_current_ph_c",
+    )
+    .tuya_dp(
+        dp_id=123,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="active_power_ph_c",
+    )
+    .tuya_dp(
+        dp_id=126,
+        ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
+        attribute_name="power_factor_ph_c",
+    )
+    .tuya_sensor(
+        dp_id=127,
+        attribute_name="energy_consumed_ph_c",
+        divisor=100,
+        type=t.uint32_t,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_ph_c",
+        fallback_name="Energy phase C",
+    )
+    .tuya_sensor(
+        dp_id=128,
+        attribute_name="energy_produced_ph_c",
+        type=t.uint32_t,
+        divisor=100,
+        state_class=SensorStateClass.TOTAL_INCREASING,
+        device_class=SensorDeviceClass.ENERGY,
+        unit=UnitOfEnergy.KILO_WATT_HOUR,
+        translation_key="energy_produced_ph_c",
+        fallback_name="Energy produced phase C",
+    )
+    .adds(Tuya3PhaseElectricalMeasurement)
+    .skip_configuration()
+    .add_to_registry()
+)
diff --git a/zhaquirks/tuya/ts0601_switch.py b/zhaquirks/tuya/ts0601_switch.py
index 5c702fd..f8baaa0 100644
--- a/zhaquirks/tuya/ts0601_switch.py
+++ b/zhaquirks/tuya/ts0601_switch.py
@@ -132,6 +132,7 @@ class TuyaSingleSwitch_GP(TuyaSwitch):
             ("_TZE204_6fk3gewc", "TS0601"),
             ("_TZE204_d0ypnbvn", "TS0601"),  # reported in #3220 & #3291
             ("_TZE204_ptaqh9tk", "TS0601"),  # reported in #2780
+            ("_TZE204_v5xjyphj", "TS0601"),  # reported in #4078
         ],
         ENDPOINTS: {
             # <SimpleDescriptor endpoint=1 profile=260 device_type=51 device_version=1
diff --git a/zhaquirks/tuya/tuya_valve.py b/zhaquirks/tuya/tuya_valve.py
index 8e0133b..c257bdc 100644
--- a/zhaquirks/tuya/tuya_valve.py
+++ b/zhaquirks/tuya/tuya_valve.py
@@ -211,16 +211,17 @@ def giex_string_to_td(v: str) -> int:
     return timedelta(hours=dt.hour, minutes=dt.minute, seconds=dt.second).seconds
 
 
-def giex_string_to_ts(v: str) -> int | None:
+def giex_string_to_dt(v: str) -> datetime | None:
     """Convert Giex String Duration datetime."""
     dev_tz = timezone(timedelta(hours=4))
     dev_dt = datetime.now(dev_tz)
+
     try:
         dt = datetime.strptime(v, "%H:%M:%S").replace(tzinfo=dev_tz)
-        dev_dt.replace(hour=dt.hour, minute=dt.minute, second=dt.second)
     except ValueError:
         return None  # on initial start the device will return '--:--:--'
-    return int(dev_dt.timestamp() + UNIX_EPOCH_TO_ZCL_EPOCH)
+    else:
+        return dev_dt.replace(hour=dt.hour, minute=dt.minute, second=dt.second)
 
 
 gx02_base_quirk = (
@@ -273,7 +274,7 @@ gx02_base_quirk = (
         dp_id=101,
         attribute_name="irrigation_start_time",
         type=t.CharacterString,
-        converter=lambda x: giex_string_to_ts(x),
+        converter=lambda x: giex_string_to_dt(x),
         device_class=SensorDeviceClass.TIMESTAMP,
         translation_key="irrigation_start_time",
         fallback_name="Irrigation start time",
@@ -282,7 +283,7 @@ gx02_base_quirk = (
         dp_id=102,
         attribute_name="irrigation_end_time",
         type=t.CharacterString,
-        converter=lambda x: giex_string_to_ts(x),
+        converter=lambda x: giex_string_to_dt(x),
         device_class=SensorDeviceClass.TIMESTAMP,
         translation_key="irrigation_end_time",
         fallback_name="Irrigation end time",
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(
diff --git a/tests/test_tuya_valve.py b/tests/test_tuya_valve.py
index a4c1dfb..f0ec47a 100644
--- a/tests/test_tuya_valve.py
+++ b/tests/test_tuya_valve.py
@@ -181,7 +181,7 @@ async def test_giex_02_quirk(zigpy_device_from_v2_quirk, model, manuf, use_minut
 async def test_giex_functions():
     """Test various Giex Valve functions."""
     assert zhaquirks.tuya.tuya_valve.giex_string_to_td("12:01:05,3") == 43265
-    assert zhaquirks.tuya.tuya_valve.giex_string_to_ts("--:--:--") is None
+    assert zhaquirks.tuya.tuya_valve.giex_string_to_dt("--:--:--") is None
 
     class MockDatetime:
         def now(self, tz: timezone):
@@ -193,11 +193,9 @@ async def test_giex_functions():
             return datetime.strptime(v, fmt)
 
     with patch("zhaquirks.tuya.tuya_valve.datetime", MockDatetime()):
-        assert (
-            zhaquirks.tuya.tuya_valve.giex_string_to_ts("20:12:01")
-            == datetime.fromisoformat("2024-10-02T12:10:23+04:00").timestamp()
-            + zhaquirks.tuya.tuya_valve.UNIX_EPOCH_TO_ZCL_EPOCH
-        )
+        assert zhaquirks.tuya.tuya_valve.giex_string_to_dt(
+            "20:12:01"
+        ) == datetime.fromisoformat("2024-10-02T20:12:01+04:00")
 
 
 @pytest.mark.parametrize(