Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions tests/test_sonoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@
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

from tests.common import ClusterListener
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,
Expand Down Expand Up @@ -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)
117 changes: 116 additions & 1 deletion zhaquirks/sonoff/swv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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."""

Expand All @@ -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)
Expand Down Expand Up @@ -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()
)
Loading