diff --git a/tests/test_sonoff.py b/tests/test_sonoff.py index 1916789f04..cc7393fe02 100644 --- a/tests/test_sonoff.py +++ b/tests/test_sonoff.py @@ -3,6 +3,7 @@ from unittest import mock import pytest +from zha.quirks import DEVICE_REGISTRY from zigpy.zcl import ClusterType, foundation from zigpy.zcl.clusters.general import OnOff @@ -10,6 +11,11 @@ import zhaquirks from zhaquirks.const import COMMAND_DOUBLE, COMMAND_HOLD, COMMAND_SINGLE, COMMAND_TRIPLE from zhaquirks.sonoff.snzb01m import SonoffButtonCluster +from zhaquirks.sonoff.swv import ( + CustomSonoffFlowCluster, + ValveState, + swap_endianness_32bit, +) from zhaquirks.sonoff.zbm5 import ( SonoffCluster, SonoffDetachedRelayMask, @@ -272,3 +278,57 @@ async def test_snzb01m_non_button_attribute_update(zigpy_device_from_v2_quirk): cluster.update_attribute(0x0001, 1) assert listener.zha_send_event.call_count == 0 + + +def test_swv_swap_endianness_32bit(): + """Test the 32-bit byte-swap helper used for the SWV-ZFE real-time counters.""" + assert swap_endianness_32bit(0x5E000000) == 94 + assert swap_endianness_32bit(94) == 0x5E000000 + assert swap_endianness_32bit(0) == 0 + assert swap_endianness_32bit(0xFFFFFFFF) == 0xFFFFFFFF + + +@pytest.mark.parametrize("model", ["SWV-ZFE", "SWV-ZFU"]) +async def test_sonoff_swv_zfe_quirk(zigpy_device_from_v2_quirk, model): + """Test the SWV-ZFE/SWV-ZFU flow-meter valve quirk entities and converters.""" + device = zigpy_device_from_v2_quirk("SONOFF", model) + + cluster = device.endpoints[1].in_clusters[CustomSonoffFlowCluster.cluster_id] + assert isinstance(cluster, CustomSonoffFlowCluster) + + entry = DEVICE_REGISTRY.match_entry(device) + metadata_by_suffix = { + metadata.resolved_unique_id_suffix: metadata + for metadata in entry.zha_device_factory.quirk_definition.entity_metadata + } + + for suffix in ( + "water_leak_status", + "water_supply_status", + "real_time_irrigation_volume", + "real_time_irrigation_duration", + "hour_irrigation_volume", + "hour_irrigation_duration", + ): + assert suffix in metadata_by_suffix + + # the real-time counters are reported big-endian and must be swapped + for suffix in ("real_time_irrigation_volume", "real_time_irrigation_duration"): + converter = metadata_by_suffix[suffix].attribute_converter + assert converter(0x5E000000) == 94 + + # the hourly counters are reported little-endian and must not be swapped + for suffix in ("hour_irrigation_volume", "hour_irrigation_duration"): + assert metadata_by_suffix[suffix].attribute_converter is None + + leak_converter = metadata_by_suffix["water_leak_status"].attribute_converter + assert leak_converter(ValveState.Water_Leakage) + assert leak_converter(ValveState.Water_Shortage_And_Leakage) + assert not leak_converter(ValveState.Water_Shortage) + assert not leak_converter(ValveState.Normal) + + supply_converter = metadata_by_suffix["water_supply_status"].attribute_converter + assert supply_converter(ValveState.Water_Shortage) + assert supply_converter(ValveState.Water_Shortage_And_Leakage) + assert not supply_converter(ValveState.Water_Leakage) + assert not supply_converter(ValveState.Normal) diff --git a/zhaquirks/sonoff/swv.py b/zhaquirks/sonoff/swv.py index aaa50d581b..522dba0599 100644 --- a/zhaquirks/sonoff/swv.py +++ b/zhaquirks/sonoff/swv.py @@ -3,7 +3,14 @@ import zigpy.types as t from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef -from zhaquirks.builder import BinarySensorDeviceClass, QuirkBuilder, ReportingConfig +from zhaquirks.builder import ( + BinarySensorDeviceClass, + QuirkBuilder, + ReportingConfig, + SensorDeviceClass, + UnitOfTime, + UnitOfVolume, +) from zhaquirks.clusters import CustomCluster @@ -16,6 +23,16 @@ class ValveState(t.enum8): Water_Shortage_And_Leakage = 3 +def swap_endianness_32bit(value: int) -> int: + """Byte-swap a 32-bit integer. + + The SWV-ZFE/SWV-ZFU firmware reports the two real-time counters + big-endian instead of ZCL little-endian, so the value zigpy parses + must be byte-swapped (zigbee2mqtt applies the same workaround). + """ + return int.from_bytes(int(value).to_bytes(4, "little"), "big") + + class CustomSonoffCluster(CustomCluster): """Custom Sonoff cluster.""" @@ -37,6 +54,37 @@ class AttributeDefs(BaseAttributeDefs): ) +class CustomSonoffFlowCluster(CustomSonoffCluster): + """Custom Sonoff cluster for the flow-meter valves (SWV-ZFE/SWV-ZFU).""" + + class AttributeDefs(CustomSonoffCluster.AttributeDefs): + """Attribute definitions.""" + + real_time_irrigation_duration = ZCLAttributeDef( + id=0x5006, + type=t.uint32_t, + manufacturer_code=None, + ) + + real_time_irrigation_volume = ZCLAttributeDef( + id=0x5007, + type=t.uint32_t, + manufacturer_code=None, + ) + + hour_irrigation_volume = ZCLAttributeDef( + id=0x501B, + type=t.uint32_t, + manufacturer_code=None, + ) + + hour_irrigation_duration = ZCLAttributeDef( + id=0x501C, + type=t.uint32_t, + manufacturer_code=None, + ) + + ( QuirkBuilder("SONOFF", "SWV") .replaces(CustomSonoffCluster) @@ -71,3 +119,70 @@ class AttributeDefs(BaseAttributeDefs): ) .add_to_registry() ) + + +( + QuirkBuilder("SONOFF", "SWV-ZFE") + .also_applies_to("SONOFF", "SWV-ZFU") + .replaces(CustomSonoffFlowCluster) + .binary_sensor( + CustomSonoffFlowCluster.AttributeDefs.water_valve_state.name, + CustomSonoffFlowCluster.cluster_id, + device_class=BinarySensorDeviceClass.MOISTURE, + attribute_converter=lambda x: x & ValveState.Water_Leakage, + unique_id_suffix="water_leak_status", + reporting_config=ReportingConfig( + min_interval=30, max_interval=900, reportable_change=1 + ), + translation_key="water_leak", + fallback_name="Water leak", + ) + .binary_sensor( + CustomSonoffFlowCluster.AttributeDefs.water_valve_state.name, + CustomSonoffFlowCluster.cluster_id, + device_class=BinarySensorDeviceClass.PROBLEM, + attribute_converter=lambda x: x & ValveState.Water_Shortage, + unique_id_suffix="water_supply_status", + translation_key="water_supply", + fallback_name="Water supply", + ) + .sensor( + CustomSonoffFlowCluster.AttributeDefs.real_time_irrigation_volume.name, + CustomSonoffFlowCluster.cluster_id, + suggested_display_precision=0, + device_class=SensorDeviceClass.VOLUME, + unit=UnitOfVolume.LITERS, + attribute_converter=swap_endianness_32bit, + translation_key="real_time_irrigation_volume", + fallback_name="Real-time irrigation volume", + ) + .sensor( + CustomSonoffFlowCluster.AttributeDefs.real_time_irrigation_duration.name, + CustomSonoffFlowCluster.cluster_id, + suggested_display_precision=0, + device_class=SensorDeviceClass.DURATION, + unit=UnitOfTime.MINUTES, + attribute_converter=swap_endianness_32bit, + translation_key="real_time_irrigation_duration", + fallback_name="Real-time irrigation duration", + ) + .sensor( + CustomSonoffFlowCluster.AttributeDefs.hour_irrigation_volume.name, + CustomSonoffFlowCluster.cluster_id, + suggested_display_precision=0, + device_class=SensorDeviceClass.VOLUME, + unit=UnitOfVolume.LITERS, + translation_key="hour_irrigation_volume", + fallback_name="Hourly irrigation volume", + ) + .sensor( + CustomSonoffFlowCluster.AttributeDefs.hour_irrigation_duration.name, + CustomSonoffFlowCluster.cluster_id, + suggested_display_precision=0, + device_class=SensorDeviceClass.DURATION, + unit=UnitOfTime.MINUTES, + translation_key="hour_irrigation_duration", + fallback_name="Hourly irrigation duration", + ) + .add_to_registry() +)