From 50feafa8fb167b4c3f33865b5eccd2290cfab2fb Mon Sep 17 00:00:00 2001 From: Jack <46714706+jeverley@users.noreply.github.com> Date: Tue, 28 Jan 2025 19:05:31 +0000 Subject: [PATCH 01/13] Refactor into v2 quirk format --- zhaquirks/tuya/ts0601_energy_meter.py | 1036 +++++++++++++++++++++++++ 1 file changed, 1036 insertions(+) create mode 100644 zhaquirks/tuya/ts0601_energy_meter.py diff --git a/zhaquirks/tuya/ts0601_energy_meter.py b/zhaquirks/tuya/ts0601_energy_meter.py new file mode 100644 index 0000000000..b00d8528ae --- /dev/null +++ b/zhaquirks/tuya/ts0601_energy_meter.py @@ -0,0 +1,1036 @@ +"""Tuya Energy Meter.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Final + +from zigpy.quirks.v2.homeassistant import EntityType, UnitOfTime +import zigpy.types as t +from zigpy.zcl import Cluster +from zigpy.zcl.clusters.homeautomation import MeasurementType +from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef + +from zhaquirks import LocalDataCluster +from zhaquirks.tuya import ( + TuyaLocalCluster, + TuyaZBElectricalMeasurement, + TuyaZBMeteringClusterWithUnit, +) +from zhaquirks.tuya.builder import TuyaQuirkBuilder +from zhaquirks.tuya.mcu import TuyaMCUCluster + +POWER_FLOW: Final = "power_flow" + + +class Channel(t.enum8): + """Enum for meter channel endpoint_id.""" + + A = 1 + B = 2 + C = 3 + AB = 11 + + @classmethod + def attr_suffix(cls, channel: Channel | None) -> str: + """Return the attribute suffix for a channel.""" + return cls.__ATTRIBUTE_SUFFIX.get(channel, "") + + @classmethod + def virtual_channels(cls) -> set[Channel]: + """Return set of virtual channels.""" + return cls.__VIRTUAL_CHANNELS + + __ATTRIBUTE_SUFFIX: dict[Channel, str] = { + B: "_ch_b", + C: "_ch_c", + } + __VIRTUAL_CHANNELS: set[Channel] = {AB} + + +class TuyaPowerFlow(t.enum1): + """Power Flow attribute type.""" + + Forward = 0x0 + Reverse = 0x1 + + @classmethod + def align_value( + cls, value: int | None, power_flow: TuyaPowerFlow | None + ) -> int | None: + """Align the value with power_flow direction.""" + if value and ( + power_flow == cls.Reverse + and value > 0 + or power_flow == cls.Forward + and value < 0 + ): + value = -value + return value + + +class TuyaPowerPhase: + """Methods for extracting values from a Tuya power phase datapoints.""" + + @staticmethod + def variant_1(value) -> tuple[t.uint_t, t.uint_t]: + """Variant 1 of power phase Data Point.""" + voltage = value[14] | value[13] << 8 + current = value[12] | value[11] << 8 + return voltage, current + + @staticmethod + def variant_2(value) -> tuple[t.uint_t, t.uint_t, int]: + """Variant 2 of power phase Data Point.""" + voltage = value[1] | value[0] << 8 + current = value[4] | value[3] << 8 + power = value[7] | value[6] << 8 + return voltage, current, power * 10 + + @staticmethod + def variant_3(value) -> tuple[t.uint_t, t.uint_t, int]: + """Variant 3 of power phase Data Point.""" + voltage = (value[0] << 8) | value[1] + current = (value[2] << 16) | (value[3] << 8) | value[4] + power = (value[5] << 16) | (value[6] << 8) | value[7] + return voltage, current, power * 10 + + +class PowerFlowMitigation(t.enum8): + """Enum type for power flow mitigation attribute.""" + + Automatic = 0 + Disabled = 1 + Enabled = 2 + + +class VirtualChannelConfig(t.enum8): + """Enum type for virtual channel config attribute.""" + + none = 0 + A_plus_B = 1 + A_minus_B = 2 + B_minus_A = 3 + + +class EnergyMeterConfiguration(LocalDataCluster): + """Local cluster for storing meter configuration.""" + + cluster_id: Final[t.uint16_t] = 0xFC00 + name: Final = "Energy Meter Config" + ep_attribute: Final = "energy_meter_config" + + VirtualChannelConfig: Final = VirtualChannelConfig + PowerFlowMitigation: Final = PowerFlowMitigation + + _ATTRIBUTE_DEFAULTS: tuple[str, Any] = { + "virtual_channel_config": VirtualChannelConfig.none, + "power_flow_mitigation": PowerFlowMitigation.Automatic, + } + + class AttributeDefs(BaseAttributeDefs): + """Configuration attributes.""" + + virtual_channel_config = ZCLAttributeDef( + id=0x5000, + type=VirtualChannelConfig, + access="rw", + is_manufacturer_specific=True, + ) + power_flow_mitigation = ZCLAttributeDef( + id=0x5010, + type=PowerFlowMitigation, + access="rw", + is_manufacturer_specific=True, + ) + + def get(self, key: int | str, default: Any | None = None) -> Any: + """Attributes are updated with their default value on first access.""" + value = super().get(key, None) + if value is not None: + return value + attr_def = self.find_attribute(key) + attr_default = self._ATTRIBUTE_DEFAULTS.get(attr_def.name, None) + if attr_default is None: + return default + self.update_attribute(attr_def.id, attr_default) + return attr_default + + +class MeterClusterHelper: + """Common methods for energy meter clusters.""" + + _EXTENSIVE_ATTRIBUTES: tuple[str] = () + + @property + def channel(self) -> Channel | None: + """Return the cluster channel.""" + try: + return Channel(self.endpoint.endpoint_id) + except ValueError: + return None + + def get_cluster( + self, + endpoint_id: int, + ep_attribute: str | None = None, + ) -> Cluster: + """Return the cluster for the given endpoint, default to current cluster type.""" + return getattr( + self.endpoint.device.endpoints[endpoint_id], + ep_attribute or self.ep_attribute, + ) + + def get_config(self, attr_name: str, default: Any = None) -> Any: + """Return the config attribute's value.""" + cluster = getattr( + self.endpoint.device.endpoints[1], + EnergyMeterConfiguration.ep_attribute, + None, + ) + if not cluster: + return None + return cluster.get(attr_name, default) + + @property + def mcu_cluster(self) -> TuyaMCUCluster | None: + """Return the MCU cluster.""" + return getattr( + self.endpoint.device.endpoints[1], TuyaMCUCluster.ep_attribute, None + ) + + +class PowerFlowHelper(MeterClusterHelper): + """Apply Tuya power_flow to ZCL power attributes.""" + + UNSIGNED_ATTR_SUFFIX: Final = "_attr_unsigned" + + @property + def power_flow(self) -> TuyaPowerFlow | None: + """Return the channel power flow direction.""" + if not self.mcu_cluster: + return None + try: + return self.mcu_cluster.get(POWER_FLOW + Channel.attr_suffix(self.channel)) + except KeyError: + return None + + @power_flow.setter + def power_flow(self, value: TuyaPowerFlow): + """Update the channel power flow direction.""" + if not self.mcu_cluster: + return + self.mcu_cluster.update_attribute( + POWER_FLOW + Channel.attr_suffix(self.channel) + ) + + def power_flow_handler(self, attr_name: str, value) -> tuple[str, Any]: + """Unsigned attributes are aligned with power flow direction.""" + if attr_name.endswith(self.UNSIGNED_ATTR_SUFFIX): + attr_name = attr_name.removesuffix(self.UNSIGNED_ATTR_SUFFIX) + value = TuyaPowerFlow.align_value(value, self.power_flow) + return attr_name, value + + +class PowerFlowMitigationHelper(PowerFlowHelper, MeterClusterHelper): + """Logic compensating for delayed power flow direction reporting. + + _TZE204_81yrt3lo (app_version: 74, hw_version: 1 and stack_version: 0) has a bug + which results in it reporting power_flow after its power data points. + This means a change in direction would only be reported after the subsequent DP report, + resulting in incorrect attribute signing in the ZCL clusters. + + This mitigation holds attribute update values until the subsequent power_flow report, + resulting in correct values, but a delay in attribute update equal to the update interval. + """ + + HOLD = "hold" + RELEASE = "release" + + """Devices requiring power flow mitigation.""" + _POWER_FLOW_MITIGATION: tuple[dict] = ( + { + "manufacturer": "_TZE204_81yrt3lo", + "model": "TS0601", + "basic_cluster": { + "app_version": 74, + "hw_version": 1, + "stack_version": 0, + }, + }, + ) + + def __init__(self, *args, **kwargs): + """Init.""" + self._held_values: dict[str, Any] = {} + self._mitigation_required: bool | None = None + super().__init__(*args, **kwargs) + + @property + def power_flow_mitigation(self) -> bool: + """Return the mitigation configuration.""" + return self.get_config( + EnergyMeterConfiguration.AttributeDefs.power_flow_mitigation.name + ) + + @property + def power_flow_mitigation_required(self) -> bool: + """Return True if the device requires Power Flow mitigations.""" + if self._mitigation_required is None: + self._mitigation_required = self._evaluate_device_mitigation() + return self._mitigation_required + + def power_flow_mitigation_handler(self, attr_name: str, value) -> str | None: + """Compensate for delay in reported power flow direction.""" + if ( + attr_name.removesuffix(self.UNSIGNED_ATTR_SUFFIX) + not in self._EXTENSIVE_ATTRIBUTES + or self.power_flow_mitigation + not in ( + PowerFlowMitigation.Automatic, + PowerFlowMitigation.Enabled, + ) + or self.power_flow_mitigation == PowerFlowMitigation.Automatic + and not self.power_flow_mitigation_required + ): + return None + + return self.RELEASE + # action = self._mitigation_action(attr_name, value, trigger_channel) + # if action != self.RELEASE: + # self._store_value(attr_name, value) + # if action != self.PREEMPT: + # return action + # self._release_held_values(attr_name, source_channels, trigger_channel) + # return action + + def _mitigation_action( + self, attr_name: str, value: int, trigger_channel: Channel + ) -> str: + """Return the action for the power flow mitigation handler.""" + return self.RELEASE + + def _get_held_value(self, attr_name: str) -> int | None: + """Retrieve the held attribute value.""" + return self._held_values.get(attr_name, None) + + def _store_value(self, attr_name: str, value: int | None): + """Store the update value.""" + self._held_values[attr_name] = value + + def _release_held_values( + self, attr_name: str, source_channels: tuple[Channel], trigger_channel: Channel + ): + """Release held values to update the cluster attributes.""" + for channel in source_channels: + cluster = self.get_cluster(channel) + if channel != trigger_channel: + value = cluster._get_held_value(attr_name) + if value is not None: + cluster.update_attribute(attr_name, value) + cluster._store_value(attr_name, None) + + def _evaluate_device_mitigation(self) -> bool: + """Return True if the device requires Power Flow mitigation.""" + basic_cluster = self.endpoint.device.endpoints[1].basic + return { + "manufacturer": self.endpoint.device.manufacturer, + "model": self.endpoint.device.model, + "basic_cluster": { + "app_version": basic_cluster.get( + basic_cluster.AttributeDefs.app_version.name + ), + "hw_version": basic_cluster.get( + basic_cluster.AttributeDefs.hw_version.name + ), + "stack_version": basic_cluster.get( + basic_cluster.AttributeDefs.stack_version.name + ), + }, + } in self._POWER_FLOW_MITIGATION + + +class VirtualChannelHelper(PowerFlowHelper, MeterClusterHelper): + """Methods for calculating virtual energy meter channel attributes.""" + + @property + def virtual(self) -> bool: + """Return True if the cluster channel is virtual.""" + return self.channel in Channel.virtual_channels() + + @property + def virtual_channel_config(self) -> VirtualChannelConfig | None: + """Return the virtual channel configuration.""" + return self.get_config( + EnergyMeterConfiguration.AttributeDefs.virtual_channel_config.name + ) + + def virtual_channel_handler(self, attr_name: str): + """Handle updates to virtual energy meter channels.""" + + if self.virtual or attr_name not in self._EXTENSIVE_ATTRIBUTES: + return + for channel in self._device_virtual_channels: + trigger_channel, method = self._VIRTUAL_CHANNEL_CONFIGURATION.get( + (channel, self.virtual_channel_config), None + ) + if self.channel != trigger_channel: + continue + value = method(self, attr_name) if method else None + virtual_cluster = self.get_cluster(channel) + virtual_cluster.update_attribute(attr_name, value) + + def _is_attr_uint(self, attr_name: str) -> bool: + """Return True if the attribute type is an unsigned integer.""" + return issubclass(getattr(self.AttributeDefs, attr_name).type, t.uint_t) + + def _retrieve_source_values( + self, attr_name: str, channels: tuple[Channel] + ) -> tuple: + """Retrieve source values from channel clusters.""" + return tuple( + TuyaPowerFlow.align_value(cluster.get(attr_name), cluster.power_flow) + if attr_name in self._EXTENSIVE_ATTRIBUTES and self._is_attr_uint(attr_name) + else cluster.get(attr_name) + for channel in channels + for cluster in [self.get_cluster(channel)] + ) + + @property + def _device_virtual_channels(self) -> set[Channel]: + """Virtual channels present on the device.""" + return Channel.virtual_channels().intersection( + self.endpoint.device.endpoints.keys() + ) + + def _virtual_a_plus_b(self, attr_name: str) -> int | None: + """Calculate virtual channel value for A_plus_B configuration.""" + value_a, value_b = self._retrieve_source_values( + attr_name, (Channel.A, Channel.B) + ) + if None in (value_a, value_b): + return None + return value_a + value_b + + def _virtual_a_minus_b(self, attr_name: str) -> int | None: + """Calculate virtual channel value for A_minus_B configuration.""" + value_a, value_b = self._retrieve_source_values( + attr_name, (Channel.A, Channel.B) + ) + if None in (value_a, value_b): + return None + return value_a - value_b + + def _virtual_b_minus_a(self, attr_name: str) -> int | None: + """Calculate virtual channel value for A_minus_B configuration.""" + value_a, value_b = self._retrieve_source_values( + attr_name, (Channel.A, Channel.B) + ) + if None in (value_a, value_b): + return None + return value_b - value_a + + """Map of virtual channels to their trigger channel and calculation method.""" + _VIRTUAL_CHANNEL_CONFIGURATION: dict[ + tuple[Channel, VirtualChannelConfig | None], + tuple[Channel, Callable | None], + ] = { + (Channel.AB, VirtualChannelConfig.A_plus_B): ( + Channel.B, + _virtual_a_plus_b, + ), + (Channel.AB, VirtualChannelConfig.A_minus_B): ( + Channel.B, + _virtual_a_minus_b, + ), + (Channel.AB, VirtualChannelConfig.B_minus_A): ( + Channel.B, + _virtual_b_minus_a, + ), + (Channel.AB, VirtualChannelConfig.none): (Channel.B, None), + } + + +class TuyaElectricalMeasurement( + VirtualChannelHelper, + PowerFlowMitigationHelper, + PowerFlowHelper, + MeterClusterHelper, + TuyaLocalCluster, + TuyaZBElectricalMeasurement, +): + """ElectricalMeasurement cluster for Tuya energy meter devices.""" + + _CONSTANT_ATTRIBUTES: dict[int, Any] = { + **TuyaZBElectricalMeasurement._CONSTANT_ATTRIBUTES, + TuyaZBElectricalMeasurement.AttributeDefs.ac_frequency_divisor.id: 100, + TuyaZBElectricalMeasurement.AttributeDefs.ac_frequency_multiplier.id: 1, + TuyaZBElectricalMeasurement.AttributeDefs.ac_power_divisor.id: 10, + TuyaZBElectricalMeasurement.AttributeDefs.ac_power_multiplier.id: 1, + TuyaZBElectricalMeasurement.AttributeDefs.ac_voltage_divisor.id: 10, + TuyaZBElectricalMeasurement.AttributeDefs.ac_voltage_multiplier.id: 1, + } + + _ATTRIBUTE_MEASUREMENT_TYPES: dict[str, MeasurementType] = { + TuyaZBElectricalMeasurement.AttributeDefs.active_power.name: MeasurementType.Active_measurement_AC + | MeasurementType.Phase_A_measurement, + TuyaZBElectricalMeasurement.AttributeDefs.active_power_ph_b.name: MeasurementType.Active_measurement_AC + | MeasurementType.Phase_B_measurement, + TuyaZBElectricalMeasurement.AttributeDefs.active_power_ph_c.name: MeasurementType.Active_measurement_AC + | MeasurementType.Phase_C_measurement, + TuyaZBElectricalMeasurement.AttributeDefs.reactive_power.name: MeasurementType.Reactive_measurement_AC + | MeasurementType.Phase_A_measurement, + TuyaZBElectricalMeasurement.AttributeDefs.reactive_power_ph_b.name: MeasurementType.Reactive_measurement_AC + | MeasurementType.Phase_B_measurement, + TuyaZBElectricalMeasurement.AttributeDefs.reactive_power_ph_c.name: MeasurementType.Reactive_measurement_AC + | MeasurementType.Phase_C_measurement, + TuyaZBElectricalMeasurement.AttributeDefs.apparent_power.name: MeasurementType.Apparent_measurement_AC + | MeasurementType.Phase_A_measurement, + TuyaZBElectricalMeasurement.AttributeDefs.apparent_power_ph_b.name: MeasurementType.Apparent_measurement_AC + | MeasurementType.Phase_B_measurement, + TuyaZBElectricalMeasurement.AttributeDefs.apparent_power_ph_c.name: MeasurementType.Apparent_measurement_AC + | MeasurementType.Phase_C_measurement, + } + + _EXTENSIVE_ATTRIBUTES: tuple[str] = ( + TuyaZBElectricalMeasurement.AttributeDefs.active_power.name, + TuyaZBElectricalMeasurement.AttributeDefs.active_power_ph_b.name, + TuyaZBElectricalMeasurement.AttributeDefs.active_power_ph_c.name, + TuyaZBElectricalMeasurement.AttributeDefs.apparent_power.name, + TuyaZBElectricalMeasurement.AttributeDefs.apparent_power_ph_b.name, + TuyaZBElectricalMeasurement.AttributeDefs.apparent_power_ph_c.name, + TuyaZBElectricalMeasurement.AttributeDefs.reactive_power.name, + TuyaZBElectricalMeasurement.AttributeDefs.reactive_power_ph_b.name, + TuyaZBElectricalMeasurement.AttributeDefs.reactive_power_ph_c.name, + TuyaZBElectricalMeasurement.AttributeDefs.rms_current.name, + TuyaZBElectricalMeasurement.AttributeDefs.rms_current_ph_b.name, + TuyaZBElectricalMeasurement.AttributeDefs.rms_current_ph_c.name, + ) + + def update_attribute(self, attr_name: str, value): + """Update the cluster attribute.""" + if ( + self.power_flow_mitigation_handler(attr_name, value) + == PowerFlowMitigationHelper.HOLD + ): + return + attr_name, value = self.power_flow_handler(attr_name, value) + super().update_attribute(attr_name, value) + self._update_measurement_type(attr_name) + self.virtual_channel_handler(attr_name) + + def _update_measurement_type(self, attr_name: str): + """Derive the measurement_type from reported attributes.""" + if attr_name not in self._ATTRIBUTE_MEASUREMENT_TYPES: + return + measurement_type = 0 + for measurement, mask in self._ATTRIBUTE_MEASUREMENT_TYPES.items(): + if measurement == attr_name or self.get(measurement) is not None: + measurement_type |= mask + super().update_attribute( + self.AttributeDefs.measurement_type.name, measurement_type + ) + + +class TuyaMetering( + VirtualChannelHelper, + PowerFlowMitigationHelper, + PowerFlowHelper, + MeterClusterHelper, + TuyaLocalCluster, + TuyaZBMeteringClusterWithUnit, +): + """Metering cluster for Tuya energy meter devices.""" + + @staticmethod + def format( + whole_digits: int, dec_digits: int, suppress_leading_zeros: bool = True + ) -> int: + """Return the formatter value for summation and demand Metering attributes.""" + assert 0 <= whole_digits <= 7, "must be within range of 0 to 7." + assert 0 <= dec_digits <= 7, "must be within range of 0 to 7." + return (suppress_leading_zeros << 6) | (whole_digits << 3) | dec_digits + + _CONSTANT_ATTRIBUTES: dict[int, Any] = { + **TuyaZBMeteringClusterWithUnit._CONSTANT_ATTRIBUTES, + TuyaZBMeteringClusterWithUnit.AttributeDefs.status.id: 0x00, + TuyaZBMeteringClusterWithUnit.AttributeDefs.multiplier.id: 1, + TuyaZBMeteringClusterWithUnit.AttributeDefs.divisor.id: 10000, # 1 decimal place after conversion from kW to W + TuyaZBMeteringClusterWithUnit.AttributeDefs.summation_formatting.id: format( + whole_digits=7, dec_digits=2 + ), + TuyaZBMeteringClusterWithUnit.AttributeDefs.demand_formatting.id: format( + whole_digits=7, dec_digits=1 + ), + } + + _EXTENSIVE_ATTRIBUTES: tuple[str] = ( + TuyaZBMeteringClusterWithUnit.AttributeDefs.instantaneous_demand.name, + ) + + def update_attribute(self, attr_name: str, value): + """Update the cluster attribute.""" + if ( + self.power_flow_mitigation_handler(attr_name, value) + == PowerFlowMitigationHelper.HOLD + ): + return + attr_name, value = self.power_flow_handler(attr_name, value) + super().update_attribute(attr_name, value) + self.virtual_channel_handler(attr_name) + + +( + ### Tuya PJ-MGW1203 1 channel energy meter. + TuyaQuirkBuilder("_TZE204_cjbofhxw", "TS0601") + # .tuya_enchantment() + .adds(EnergyMeterConfiguration) + .adds(TuyaElectricalMeasurement) + .adds(TuyaMetering) + .tuya_dp( + dp_id=101, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, + converter=lambda x: x * 10, + ) + .tuya_dp( + dp_id=19, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.active_power.name, + ) + .tuya_dp( + dp_id=18, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, + endpoint_id=Channel.B, + ) + .tuya_dp( + dp_id=20, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, + ) + .add_to_registry() +) + + +( + ### Tuya bidirectional 1 channel energy meter with Zigbee Green Power. + TuyaQuirkBuilder("_TZE204_ac0fhfiq", "TS0601") + # .tuya_enchantment() + .adds(EnergyMeterConfiguration) + .adds(TuyaElectricalMeasurement) + .adds(TuyaMetering) + .tuya_dp_attribute( + dp_id=102, + attribute_name=POWER_FLOW, + type=TuyaPowerFlow, + converter=lambda x: TuyaPowerFlow(x), + ) + .tuya_dp( + dp_id=101, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, + converter=lambda x: x * 100, + ) + .tuya_dp( + dp_id=102, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_received.name, + converter=lambda x: x * 100, + ) + .tuya_dp( + dp_id=108, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.instantaneous_demand.name + + PowerFlowHelper.UNSIGNED_ATTR_SUFFIX, + converter=lambda x: x * 10, + ) + .tuya_dp( + dp_id=6, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=( + TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, + TuyaElectricalMeasurement.AttributeDefs.rms_current.name, + TuyaElectricalMeasurement.AttributeDefs.active_power.name + + PowerFlowHelper.UNSIGNED_ATTR_SUFFIX, + ), + converter=lambda x: TuyaPowerPhase.variant_3(x), + ) + .add_to_registry() +) + + +( + ### EARU Tuya 2 channel bidirectional energy meter manufacturer cluster. + TuyaQuirkBuilder("_TZE200_rks0sgb7", "TS0601") + # .tuya_enchantment() + .adds_endpoint(Channel.B) + .adds_endpoint(Channel.AB) + .adds(EnergyMeterConfiguration) + .adds(TuyaElectricalMeasurement) + .adds(TuyaElectricalMeasurement, endpoint_id=Channel.B) + .adds(TuyaElectricalMeasurement, endpoint_id=Channel.AB) + .adds(TuyaMetering) + .adds(TuyaMetering, endpoint_id=Channel.B) + .adds(TuyaMetering, endpoint_id=Channel.AB) + .enum( + EnergyMeterConfiguration.AttributeDefs.virtual_channel_config.name, + VirtualChannelConfig, + EnergyMeterConfiguration.cluster_id, + entity_type=EntityType.CONFIG, + translation_key="virtual_channel_config", + fallback_name="Virtual Channel", + ) + .tuya_dp_attribute( + dp_id=114, + attribute_name=POWER_FLOW, + type=TuyaPowerFlow, + converter=lambda x: TuyaPowerFlow(x), + ) + .tuya_dp_attribute( + dp_id=115, + attribute_name=POWER_FLOW + Channel.attr_suffix(Channel.B), + type=TuyaPowerFlow, + converter=lambda x: TuyaPowerFlow(x), + ) + .tuya_dp( + dp_id=113, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.ac_frequency.name, + ) + .tuya_dp( + dp_id=101, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, + converter=lambda x: x * 100, + ) + .tuya_dp( + dp_id=103, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, + converter=lambda x: x * 100, + endpoint_id=Channel.B, + ) + .tuya_dp( + dp_id=102, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_received.name, + converter=lambda x: x * 100, + ) + .tuya_dp( + dp_id=104, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_received.name, + converter=lambda x: x * 100, + endpoint_id=Channel.B, + ) + .tuya_dp( + dp_id=108, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.instantaneous_demand.name, + ) + .tuya_dp( + dp_id=111, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.instantaneous_demand.name, + endpoint_id=Channel.B, + ) + .tuya_dp( + dp_id=109, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.power_factor.name, + ) + .tuya_dp( + dp_id=112, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.power_factor.name, + endpoint_id=Channel.B, + ) + .tuya_dp( + dp_id=107, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, + ) + .tuya_dp( + dp_id=110, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, + endpoint_id=Channel.B, + ) + .tuya_dp( + dp_id=106, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, + ) + .tuya_number( + dp_id=116, + attribute_name="update_interval", + type=t.uint32_t_be, + unit=UnitOfTime.SECONDS, + min_value=5, + max_value=60, + step=1, + translation_key="update_interval", + fallback_name="Update Interval", + entity_type=EntityType.CONFIG, + ) + .add_to_registry() +) + + +( + ### MatSee Plus Tuya PJ-1203A 2 channel bidirectional energy meter with Zigbee Green Power. + TuyaQuirkBuilder("_TZE204_81yrt3lo", "TS0601") + # .tuya_enchantment() + .adds_endpoint(Channel.B) + .adds_endpoint(Channel.AB) + .adds(EnergyMeterConfiguration) + .adds(TuyaElectricalMeasurement) + .adds(TuyaElectricalMeasurement, endpoint_id=Channel.B) + .adds(TuyaElectricalMeasurement, endpoint_id=Channel.AB) + .adds(TuyaMetering) + .adds(TuyaMetering, endpoint_id=Channel.B) + .adds(TuyaMetering, endpoint_id=Channel.AB) + .enum( + EnergyMeterConfiguration.AttributeDefs.virtual_channel_config.name, + VirtualChannelConfig, + EnergyMeterConfiguration.cluster_id, + entity_type=EntityType.CONFIG, + translation_key="virtual_channel_config", + fallback_name="Virtual Channel", + ) + .enum( + EnergyMeterConfiguration.AttributeDefs.power_flow_mitigation.name, + PowerFlowMitigation, + EnergyMeterConfiguration.cluster_id, + entity_type=EntityType.CONFIG, + translation_key="power_flow_mitigation", + fallback_name="Power Flow delay mitigation", + ) + .tuya_dp_attribute( + dp_id=102, + attribute_name=POWER_FLOW, + type=TuyaPowerFlow, + converter=lambda x: TuyaPowerFlow(x), + ) + .tuya_dp_attribute( + dp_id=104, + attribute_name=POWER_FLOW + Channel.attr_suffix(Channel.B), + type=TuyaPowerFlow, + converter=lambda x: TuyaPowerFlow(x), + ) + .tuya_dp( + dp_id=111, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.ac_frequency.name, + ) + .tuya_dp( + dp_id=106, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, + converter=lambda x: x * 100, + ) + .tuya_dp( + dp_id=108, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, + converter=lambda x: x * 100, + endpoint_id=Channel.B, + ) + .tuya_dp( + dp_id=107, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_received.name, + converter=lambda x: x * 100, + ) + .tuya_dp( + dp_id=109, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_received.name, + converter=lambda x: x * 100, + endpoint_id=Channel.B, + ) + .tuya_dp( + dp_id=101, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.instantaneous_demand.name + + PowerFlowHelper.UNSIGNED_ATTR_SUFFIX, + ) + .tuya_dp( + dp_id=105, + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.instantaneous_demand.name + + PowerFlowHelper.UNSIGNED_ATTR_SUFFIX, + endpoint_id=Channel.B, + ) + .tuya_dp( + dp_id=110, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.power_factor.name, + ) + .tuya_dp( + dp_id=121, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.power_factor.name, + endpoint_id=Channel.B, + ) + .tuya_dp( + dp_id=113, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, + ) + .tuya_dp( + dp_id=114, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, + endpoint_id=Channel.B, + ) + .tuya_dp( + dp_id=112, + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, + ) + .tuya_number( + dp_id=129, + attribute_name="update_interval", + type=t.uint32_t_be, + unit=UnitOfTime.SECONDS, + min_value=5, + max_value=60, + step=1, + translation_key="update_interval", + fallback_name="Update Interval", + entity_type=EntityType.CONFIG, + ) + # .tuya_number( + # dp_id=122, + # attribute_name="ac_frequency_coefficient", + # type=t.uint32_t_be, + # # unit=PERCENTAGE, + # min_value=500, + # max_value=1500, + # step=1, + # multiplier=0.1, + # translation_key="ac_frequency_calibration", + # fallback_name="AC Frequency Calibration", + # entity_type=EntityType.CONFIG, + # ) + # .tuya_number( + # dp_id=119, + # attribute_name="current_summ_delivered_coefficient", + # type=t.uint32_t_be, + # # unit=PERCENTAGE, + # min_value=500, + # max_value=1500, + # step=1, + # multiplier=0.1, + # translation_key="summ_delivered_calibration", + # fallback_name="Summation Delivered Calibration", + # entity_type=EntityType.CONFIG, + # ) + # .tuya_number( + # dp_id=125, + # attribute_name="current_summ_delivered_coefficient_ch_b", + # type=t.uint32_t_be, + # # unit=PERCENTAGE, + # min_value=500, + # max_value=1500, + # step=1, + # multiplier=0.1, + # translation_key="summ_delivered_calibration_b", + # fallback_name="Summation Delivered Calibration B", + # entity_type=EntityType.CONFIG, + # ) + # .tuya_number( + # dp_id=127, + # attribute_name="current_summ_received_coefficient", + # type=t.uint32_t_be, + # # unit=PERCENTAGE, + # min_value=500, + # max_value=1500, + # step=1, + # multiplier=0.1, + # translation_key="summ_received_calibration", + # fallback_name="Summation Received Calibration", + # entity_type=EntityType.CONFIG, + # ) + # .tuya_number( + # dp_id=128, + # attribute_name="current_summ_received_coefficient_ch_b", + # type=t.uint32_t_be, + # # unit=PERCENTAGE, + # min_value=500, + # max_value=1500, + # step=1, + # multiplier=0.1, + # translation_key="summ_received_calibration_b", + # fallback_name="Summation Received Calibration B", + # entity_type=EntityType.CONFIG, + # ) + # .tuya_number( + # dp_id=118, + # attribute_name="instantaneous_demand_coefficient", + # type=t.uint32_t_be, + # # unit=PERCENTAGE, + # min_value=500, + # max_value=1500, + # step=1, + # multiplier=0.1, + # translation_key="instantaneous_demand_calibration", + # fallback_name="Instantaneous Demand Calibration", + # entity_type=EntityType.CONFIG, + # ) + # .tuya_number( + # dp_id=124, + # attribute_name="instantaneous_demand_coefficient_ch_b", + # type=t.uint32_t_be, + # # unit=PERCENTAGE, + # min_value=500, + # max_value=1500, + # step=1, + # multiplier=0.1, + # translation_key="instantaneous_demand_calibration_b", + # fallback_name="Instantaneous Demand Calibration B", + # entity_type=EntityType.CONFIG, + # ) + # .tuya_number( + # dp_id=117, + # attribute_name="rms_current_coefficient", + # type=t.uint32_t_be, + # # unit=PERCENTAGE, + # min_value=500, + # max_value=1500, + # step=1, + # multiplier=0.1, + # translation_key="rms_current_calibration", + # fallback_name="RMS Current Calibration", + # entity_type=EntityType.CONFIG, + # ) + # .tuya_number( + # dp_id=123, + # attribute_name="rms_current_coefficient_ch_b", + # type=t.uint32_t_be, + # # unit=PERCENTAGE, + # min_value=500, + # max_value=1500, + # step=1, + # multiplier=0.1, + # translation_key="rms_current_calibration_b", + # fallback_name="RMS Current Calibration B", + # entity_type=EntityType.CONFIG, + # ) + # .tuya_number( + # dp_id=116, + # attribute_name="rms_voltage_coefficient", + # type=t.uint32_t_be, + # # unit=PERCENTAGE, + # min_value=500, + # max_value=1500, + # step=1, + # multiplier=0.1, + # translation_key="rms_voltage_calibration", + # fallback_name="RMS Voltage Calibration", + # entity_type=EntityType.CONFIG, + # ) + .add_to_registry() +) From 8e414b9dd0af6c0b103cf7f804c90ccf252c1cba Mon Sep 17 00:00:00 2001 From: Jack <46714706+jeverley@users.noreply.github.com> Date: Mon, 3 Feb 2025 11:11:38 +0000 Subject: [PATCH 02/13] Simplify virtual channel helper and introduce calibration entities --- zhaquirks/tuya/ts0601_energy_meter.py | 490 +++++++++++++------------- 1 file changed, 247 insertions(+), 243 deletions(-) diff --git a/zhaquirks/tuya/ts0601_energy_meter.py b/zhaquirks/tuya/ts0601_energy_meter.py index b00d8528ae..6f902566f5 100644 --- a/zhaquirks/tuya/ts0601_energy_meter.py +++ b/zhaquirks/tuya/ts0601_energy_meter.py @@ -5,7 +5,7 @@ from collections.abc import Callable from typing import Any, Final -from zigpy.quirks.v2.homeassistant import EntityType, UnitOfTime +from zigpy.quirks.v2.homeassistant import EntityType, UnitOfTime # , PERCENTAGE import zigpy.types as t from zigpy.zcl import Cluster from zigpy.zcl.clusters.homeautomation import MeasurementType @@ -54,20 +54,6 @@ class TuyaPowerFlow(t.enum1): Forward = 0x0 Reverse = 0x1 - @classmethod - def align_value( - cls, value: int | None, power_flow: TuyaPowerFlow | None - ) -> int | None: - """Align the value with power_flow direction.""" - if value and ( - power_flow == cls.Reverse - and value > 0 - or power_flow == cls.Forward - and value < 0 - ): - value = -value - return value - class TuyaPowerPhase: """Methods for extracting values from a Tuya power phase datapoints.""" @@ -205,6 +191,17 @@ class PowerFlowHelper(MeterClusterHelper): UNSIGNED_ATTR_SUFFIX: Final = "_attr_unsigned" + def align_with_power_flow(self, value: int | None) -> int | None: + """Align the value with current power_flow direction.""" + if value and ( + self.power_flow == TuyaPowerFlow.Reverse + and value > 0 + or self.power_flow == TuyaPowerFlow.Forward + and value < 0 + ): + value = -value + return value + @property def power_flow(self) -> TuyaPowerFlow | None: """Return the channel power flow direction.""" @@ -228,7 +225,7 @@ def power_flow_handler(self, attr_name: str, value) -> tuple[str, Any]: """Unsigned attributes are aligned with power flow direction.""" if attr_name.endswith(self.UNSIGNED_ATTR_SUFFIX): attr_name = attr_name.removesuffix(self.UNSIGNED_ATTR_SUFFIX) - value = TuyaPowerFlow.align_value(value, self.power_flow) + value = self.align_with_power_flow(value) return attr_name, value @@ -353,6 +350,28 @@ def _evaluate_device_mitigation(self) -> bool: class VirtualChannelHelper(PowerFlowHelper, MeterClusterHelper): """Methods for calculating virtual energy meter channel attributes.""" + """Map of virtual channels to their trigger channel and calculation method.""" + _VIRTUAL_CHANNEL_CALCULATIONS: dict[ + tuple[Channel, VirtualChannelConfig | None], + tuple[tuple[Channel], Callable | None, Channel | None], + ] = { + (Channel.AB, VirtualChannelConfig.A_plus_B): ( + (Channel.A, Channel.B), + lambda a, b: a + b, + Channel.B, + ), + (Channel.AB, VirtualChannelConfig.A_minus_B): ( + (Channel.A, Channel.B), + lambda a, b: a - b, + Channel.B, + ), + (Channel.AB, VirtualChannelConfig.B_minus_A): ( + (Channel.A, Channel.B), + lambda a, b: b - a, + Channel.B, + ), + } + @property def virtual(self) -> bool: """Return True if the cluster channel is virtual.""" @@ -371,30 +390,30 @@ def virtual_channel_handler(self, attr_name: str): if self.virtual or attr_name not in self._EXTENSIVE_ATTRIBUTES: return for channel in self._device_virtual_channels: - trigger_channel, method = self._VIRTUAL_CHANNEL_CONFIGURATION.get( - (channel, self.virtual_channel_config), None + source_channels, method, trigger_channel = ( + self._VIRTUAL_CHANNEL_CALCULATIONS.get( + (channel, self.virtual_channel_config), (None, None, None) + ) ) - if self.channel != trigger_channel: + if trigger_channel is not None and self.channel != trigger_channel: continue - value = method(self, attr_name) if method else None + value = self._calculate_virtual_value(attr_name, source_channels, method) virtual_cluster = self.get_cluster(channel) virtual_cluster.update_attribute(attr_name, value) - def _is_attr_uint(self, attr_name: str) -> bool: - """Return True if the attribute type is an unsigned integer.""" - return issubclass(getattr(self.AttributeDefs, attr_name).type, t.uint_t) - - def _retrieve_source_values( - self, attr_name: str, channels: tuple[Channel] - ) -> tuple: - """Retrieve source values from channel clusters.""" - return tuple( - TuyaPowerFlow.align_value(cluster.get(attr_name), cluster.power_flow) - if attr_name in self._EXTENSIVE_ATTRIBUTES and self._is_attr_uint(attr_name) - else cluster.get(attr_name) - for channel in channels - for cluster in [self.get_cluster(channel)] - ) + def _calculate_virtual_value( + self, + attr_name: str, + source_channels: tuple[Channel] | None, + method: Callable | None, + ) -> int | None: + """Calculate virtual channel value from source channels.""" + if source_channels is None or method is None: + return None + source_values = self._get_source_values(attr_name, source_channels) + if None in source_values: + return None + return method(*source_values) @property def _device_virtual_channels(self) -> set[Channel]: @@ -403,52 +422,24 @@ def _device_virtual_channels(self) -> set[Channel]: self.endpoint.device.endpoints.keys() ) - def _virtual_a_plus_b(self, attr_name: str) -> int | None: - """Calculate virtual channel value for A_plus_B configuration.""" - value_a, value_b = self._retrieve_source_values( - attr_name, (Channel.A, Channel.B) - ) - if None in (value_a, value_b): - return None - return value_a + value_b - - def _virtual_a_minus_b(self, attr_name: str) -> int | None: - """Calculate virtual channel value for A_minus_B configuration.""" - value_a, value_b = self._retrieve_source_values( - attr_name, (Channel.A, Channel.B) - ) - if None in (value_a, value_b): - return None - return value_a - value_b + def _is_attr_uint(self, attr_name: str) -> bool: + """Return True if the attribute type is an unsigned integer.""" + return issubclass(getattr(self.AttributeDefs, attr_name).type, t.uint_t) - def _virtual_b_minus_a(self, attr_name: str) -> int | None: - """Calculate virtual channel value for A_minus_B configuration.""" - value_a, value_b = self._retrieve_source_values( - attr_name, (Channel.A, Channel.B) + def _get_source_values( + self, + attr_name: str, + channels: tuple[Channel], + align_uint_with_power_flow: bool = True, + ) -> tuple: + """Get source values from channel clusters.""" + return tuple( + cluster.align_with_power_flow(cluster.get(attr_name)) + if align_uint_with_power_flow and self._is_attr_uint(attr_name) + else cluster.get(attr_name) + for channel in channels + for cluster in [self.get_cluster(channel)] ) - if None in (value_a, value_b): - return None - return value_b - value_a - - """Map of virtual channels to their trigger channel and calculation method.""" - _VIRTUAL_CHANNEL_CONFIGURATION: dict[ - tuple[Channel, VirtualChannelConfig | None], - tuple[Channel, Callable | None], - ] = { - (Channel.AB, VirtualChannelConfig.A_plus_B): ( - Channel.B, - _virtual_a_plus_b, - ), - (Channel.AB, VirtualChannelConfig.A_minus_B): ( - Channel.B, - _virtual_a_minus_b, - ), - (Channel.AB, VirtualChannelConfig.B_minus_A): ( - Channel.B, - _virtual_b_minus_a, - ), - (Channel.AB, VirtualChannelConfig.none): (Channel.B, None), - } class TuyaElectricalMeasurement( @@ -620,12 +611,6 @@ def update_attribute(self, attr_name: str, value): .adds(EnergyMeterConfiguration) .adds(TuyaElectricalMeasurement) .adds(TuyaMetering) - .tuya_dp_attribute( - dp_id=102, - attribute_name=POWER_FLOW, - type=TuyaPowerFlow, - converter=lambda x: TuyaPowerFlow(x), - ) .tuya_dp( dp_id=101, ep_attribute=TuyaMetering.ep_attribute, @@ -656,6 +641,12 @@ def update_attribute(self, attr_name: str, value): ), converter=lambda x: TuyaPowerPhase.variant_3(x), ) + .tuya_dp_attribute( + dp_id=102, + attribute_name=POWER_FLOW, + type=TuyaPowerFlow, + converter=lambda x: TuyaPowerFlow(x), + ) .add_to_registry() ) @@ -679,19 +670,7 @@ def update_attribute(self, attr_name: str, value): EnergyMeterConfiguration.cluster_id, entity_type=EntityType.CONFIG, translation_key="virtual_channel_config", - fallback_name="Virtual Channel", - ) - .tuya_dp_attribute( - dp_id=114, - attribute_name=POWER_FLOW, - type=TuyaPowerFlow, - converter=lambda x: TuyaPowerFlow(x), - ) - .tuya_dp_attribute( - dp_id=115, - attribute_name=POWER_FLOW + Channel.attr_suffix(Channel.B), - type=TuyaPowerFlow, - converter=lambda x: TuyaPowerFlow(x), + fallback_name="Virtual channel", ) .tuya_dp( dp_id=113, @@ -762,6 +741,18 @@ def update_attribute(self, attr_name: str, value): ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, ) + .tuya_dp_attribute( + dp_id=114, + attribute_name=POWER_FLOW, + type=TuyaPowerFlow, + converter=lambda x: TuyaPowerFlow(x), + ) + .tuya_dp_attribute( + dp_id=115, + attribute_name=POWER_FLOW + Channel.attr_suffix(Channel.B), + type=TuyaPowerFlow, + converter=lambda x: TuyaPowerFlow(x), + ) .tuya_number( dp_id=116, attribute_name="update_interval", @@ -797,27 +788,15 @@ def update_attribute(self, attr_name: str, value): EnergyMeterConfiguration.cluster_id, entity_type=EntityType.CONFIG, translation_key="virtual_channel_config", - fallback_name="Virtual Channel", + fallback_name="Virtual channel", ) .enum( EnergyMeterConfiguration.AttributeDefs.power_flow_mitigation.name, PowerFlowMitigation, EnergyMeterConfiguration.cluster_id, entity_type=EntityType.CONFIG, - translation_key="power_flow_mitigation", - fallback_name="Power Flow delay mitigation", - ) - .tuya_dp_attribute( - dp_id=102, - attribute_name=POWER_FLOW, - type=TuyaPowerFlow, - converter=lambda x: TuyaPowerFlow(x), - ) - .tuya_dp_attribute( - dp_id=104, - attribute_name=POWER_FLOW + Channel.attr_suffix(Channel.B), - type=TuyaPowerFlow, - converter=lambda x: TuyaPowerFlow(x), + translation_key="power_flow_delay_mitigation", + fallback_name="Power flow delay mitigation", ) .tuya_dp( dp_id=111, @@ -890,6 +869,18 @@ def update_attribute(self, attr_name: str, value): ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, ) + .tuya_dp_attribute( + dp_id=102, + attribute_name=POWER_FLOW, + type=TuyaPowerFlow, + converter=lambda x: TuyaPowerFlow(x), + ) + .tuya_dp_attribute( + dp_id=104, + attribute_name=POWER_FLOW + Channel.attr_suffix(Channel.B), + type=TuyaPowerFlow, + converter=lambda x: TuyaPowerFlow(x), + ) .tuya_number( dp_id=129, attribute_name="update_interval", @@ -902,135 +893,148 @@ def update_attribute(self, attr_name: str, value): fallback_name="Update Interval", entity_type=EntityType.CONFIG, ) - # .tuya_number( - # dp_id=122, - # attribute_name="ac_frequency_coefficient", - # type=t.uint32_t_be, - # # unit=PERCENTAGE, - # min_value=500, - # max_value=1500, - # step=1, - # multiplier=0.1, - # translation_key="ac_frequency_calibration", - # fallback_name="AC Frequency Calibration", - # entity_type=EntityType.CONFIG, - # ) - # .tuya_number( - # dp_id=119, - # attribute_name="current_summ_delivered_coefficient", - # type=t.uint32_t_be, - # # unit=PERCENTAGE, - # min_value=500, - # max_value=1500, - # step=1, - # multiplier=0.1, - # translation_key="summ_delivered_calibration", - # fallback_name="Summation Delivered Calibration", - # entity_type=EntityType.CONFIG, - # ) - # .tuya_number( - # dp_id=125, - # attribute_name="current_summ_delivered_coefficient_ch_b", - # type=t.uint32_t_be, - # # unit=PERCENTAGE, - # min_value=500, - # max_value=1500, - # step=1, - # multiplier=0.1, - # translation_key="summ_delivered_calibration_b", - # fallback_name="Summation Delivered Calibration B", - # entity_type=EntityType.CONFIG, - # ) - # .tuya_number( - # dp_id=127, - # attribute_name="current_summ_received_coefficient", - # type=t.uint32_t_be, - # # unit=PERCENTAGE, - # min_value=500, - # max_value=1500, - # step=1, - # multiplier=0.1, - # translation_key="summ_received_calibration", - # fallback_name="Summation Received Calibration", - # entity_type=EntityType.CONFIG, - # ) - # .tuya_number( - # dp_id=128, - # attribute_name="current_summ_received_coefficient_ch_b", - # type=t.uint32_t_be, - # # unit=PERCENTAGE, - # min_value=500, - # max_value=1500, - # step=1, - # multiplier=0.1, - # translation_key="summ_received_calibration_b", - # fallback_name="Summation Received Calibration B", - # entity_type=EntityType.CONFIG, - # ) - # .tuya_number( - # dp_id=118, - # attribute_name="instantaneous_demand_coefficient", - # type=t.uint32_t_be, - # # unit=PERCENTAGE, - # min_value=500, - # max_value=1500, - # step=1, - # multiplier=0.1, - # translation_key="instantaneous_demand_calibration", - # fallback_name="Instantaneous Demand Calibration", - # entity_type=EntityType.CONFIG, - # ) - # .tuya_number( - # dp_id=124, - # attribute_name="instantaneous_demand_coefficient_ch_b", - # type=t.uint32_t_be, - # # unit=PERCENTAGE, - # min_value=500, - # max_value=1500, - # step=1, - # multiplier=0.1, - # translation_key="instantaneous_demand_calibration_b", - # fallback_name="Instantaneous Demand Calibration B", - # entity_type=EntityType.CONFIG, - # ) - # .tuya_number( - # dp_id=117, - # attribute_name="rms_current_coefficient", - # type=t.uint32_t_be, - # # unit=PERCENTAGE, - # min_value=500, - # max_value=1500, - # step=1, - # multiplier=0.1, - # translation_key="rms_current_calibration", - # fallback_name="RMS Current Calibration", - # entity_type=EntityType.CONFIG, - # ) - # .tuya_number( - # dp_id=123, - # attribute_name="rms_current_coefficient_ch_b", - # type=t.uint32_t_be, - # # unit=PERCENTAGE, - # min_value=500, - # max_value=1500, - # step=1, - # multiplier=0.1, - # translation_key="rms_current_calibration_b", - # fallback_name="RMS Current Calibration B", - # entity_type=EntityType.CONFIG, - # ) - # .tuya_number( - # dp_id=116, - # attribute_name="rms_voltage_coefficient", - # type=t.uint32_t_be, - # # unit=PERCENTAGE, - # min_value=500, - # max_value=1500, - # step=1, - # multiplier=0.1, - # translation_key="rms_voltage_calibration", - # fallback_name="RMS Voltage Calibration", - # entity_type=EntityType.CONFIG, - # ) + .tuya_number( + dp_id=122, + attribute_name="ac_frequency_coefficient", + type=t.uint32_t_be, + # unit=PERCENTAGE, + min_value=0, + max_value=2000, + step=1, + multiplier=0.1, + translation_key="calibrate_ac_frequency", + fallback_name="Calibrate AC frequency", + entity_type=EntityType.CONFIG, + initially_disabled=True, + ) + .tuya_number( + dp_id=119, + attribute_name="current_summ_delivered_coefficient", + type=t.uint32_t_be, + # unit=PERCENTAGE, + min_value=0, + max_value=2000, + step=1, + multiplier=0.1, + translation_key="calibrate_summ_delivered", + fallback_name="Calibrate summation delivered", + entity_type=EntityType.CONFIG, + initially_disabled=True, + ) + .tuya_number( + dp_id=125, + attribute_name="current_summ_delivered_coefficient" + + Channel.attr_suffix(Channel.B), + type=t.uint32_t_be, + # unit=PERCENTAGE, + min_value=0, + max_value=2000, + step=1, + multiplier=0.1, + translation_key="calibrate_summ_delivered_b", + fallback_name="Calibrate summation delivered B", + entity_type=EntityType.CONFIG, + initially_disabled=True, + ) + .tuya_number( + dp_id=127, + attribute_name="current_summ_received_coefficient", + type=t.uint32_t_be, + # unit=PERCENTAGE, + min_value=0, + max_value=2000, + step=1, + multiplier=0.1, + translation_key="calibrate_summ_received", + fallback_name="Calibrate summation received", + entity_type=EntityType.CONFIG, + initially_disabled=True, + ) + .tuya_number( + dp_id=128, + attribute_name="current_summ_received_coefficient" + + Channel.attr_suffix(Channel.B), + type=t.uint32_t_be, + # unit=PERCENTAGE, + min_value=0, + max_value=2000, + step=1, + multiplier=0.1, + translation_key="calibrate_summ_received_b", + fallback_name="Calibrate summation received B", + entity_type=EntityType.CONFIG, + initially_disabled=True, + ) + .tuya_number( + dp_id=118, + attribute_name="instantaneous_demand_coefficient", + type=t.uint32_t_be, + # unit=PERCENTAGE, + min_value=0, + max_value=2000, + step=1, + multiplier=0.1, + translation_key="calibrate_instantaneous_demand", + fallback_name="Calibrate instantaneous demand", + entity_type=EntityType.CONFIG, + initially_disabled=True, + ) + .tuya_number( + dp_id=124, + attribute_name="instantaneous_demand_coefficient" + + Channel.attr_suffix(Channel.B), + type=t.uint32_t_be, + # unit=PERCENTAGE, + min_value=0, + max_value=2000, + step=1, + multiplier=0.1, + translation_key="calibrate_instantaneous_demand_b", + fallback_name="Calibrate instantaneous demand B", + entity_type=EntityType.CONFIG, + initially_disabled=True, + ) + .tuya_number( + dp_id=117, + attribute_name="rms_current_coefficient", + type=t.uint32_t_be, + # unit=PERCENTAGE, + min_value=0, + max_value=2000, + step=1, + multiplier=0.1, + translation_key="calibrate_current", + fallback_name="Calibrate current", + entity_type=EntityType.CONFIG, + initially_disabled=True, + ) + .tuya_number( + dp_id=123, + attribute_name="rms_current_coefficient" + Channel.attr_suffix(Channel.B), + type=t.uint32_t_be, + # unit=PERCENTAGE, + min_value=0, + max_value=2000, + step=1, + multiplier=0.1, + translation_key="calibrate_current_b", + fallback_name="Calibrate current B", + entity_type=EntityType.CONFIG, + initially_disabled=True, + ) + .tuya_number( + dp_id=116, + attribute_name="rms_voltage_coefficient", + type=t.uint32_t_be, + # unit=PERCENTAGE, + min_value=0, + max_value=2000, + step=1, + multiplier=0.1, + translation_key="calibrate_voltage", + fallback_name="Calibrate voltage", + entity_type=EntityType.CONFIG, + initially_disabled=True, + ) .add_to_registry() ) From 475bd935cceb9a2c696ce7d93502f1f54fcf2985 Mon Sep 17 00:00:00 2001 From: Jack <46714706+jeverley@users.noreply.github.com> Date: Mon, 3 Feb 2025 13:05:13 +0000 Subject: [PATCH 03/13] Refer to energy direction rather than power flow --- zhaquirks/tuya/ts0601_energy_meter.py | 166 +++++++++++++------------- 1 file changed, 84 insertions(+), 82 deletions(-) diff --git a/zhaquirks/tuya/ts0601_energy_meter.py b/zhaquirks/tuya/ts0601_energy_meter.py index 6f902566f5..00aaad6317 100644 --- a/zhaquirks/tuya/ts0601_energy_meter.py +++ b/zhaquirks/tuya/ts0601_energy_meter.py @@ -20,7 +20,7 @@ from zhaquirks.tuya.builder import TuyaQuirkBuilder from zhaquirks.tuya.mcu import TuyaMCUCluster -POWER_FLOW: Final = "power_flow" +ENERGY_DIRECTION: Final = "energy_direction" class Channel(t.enum8): @@ -48,8 +48,8 @@ def virtual_channels(cls) -> set[Channel]: __VIRTUAL_CHANNELS: set[Channel] = {AB} -class TuyaPowerFlow(t.enum1): - """Power Flow attribute type.""" +class TuyaEnergyDirection(t.enum1): + """Energy direction attribute type.""" Forward = 0x0 Reverse = 0x1 @@ -82,8 +82,8 @@ def variant_3(value) -> tuple[t.uint_t, t.uint_t, int]: return voltage, current, power * 10 -class PowerFlowMitigation(t.enum8): - """Enum type for power flow mitigation attribute.""" +class EnergyDirectionMitigation(t.enum8): + """Enum type for energy direction mitigation attribute.""" Automatic = 0 Disabled = 1 @@ -107,11 +107,11 @@ class EnergyMeterConfiguration(LocalDataCluster): ep_attribute: Final = "energy_meter_config" VirtualChannelConfig: Final = VirtualChannelConfig - PowerFlowMitigation: Final = PowerFlowMitigation + EnergyDirectionMitigation: Final = EnergyDirectionMitigation _ATTRIBUTE_DEFAULTS: tuple[str, Any] = { "virtual_channel_config": VirtualChannelConfig.none, - "power_flow_mitigation": PowerFlowMitigation.Automatic, + "energy_direction_mitigation": EnergyDirectionMitigation.Automatic, } class AttributeDefs(BaseAttributeDefs): @@ -123,9 +123,9 @@ class AttributeDefs(BaseAttributeDefs): access="rw", is_manufacturer_specific=True, ) - power_flow_mitigation = ZCLAttributeDef( + energy_direction_mitigation = ZCLAttributeDef( id=0x5010, - type=PowerFlowMitigation, + type=EnergyDirectionMitigation, access="rw", is_manufacturer_specific=True, ) @@ -186,66 +186,68 @@ def mcu_cluster(self) -> TuyaMCUCluster | None: ) -class PowerFlowHelper(MeterClusterHelper): - """Apply Tuya power_flow to ZCL power attributes.""" +class EnergyDirectionHelper(MeterClusterHelper): + """Apply Tuya EnergyDirection to ZCL power attributes.""" UNSIGNED_ATTR_SUFFIX: Final = "_attr_unsigned" - def align_with_power_flow(self, value: int | None) -> int | None: - """Align the value with current power_flow direction.""" + def align_with_energy_direction(self, value: int | None) -> int | None: + """Align the value with current energy_direction.""" if value and ( - self.power_flow == TuyaPowerFlow.Reverse + self.energy_direction == TuyaEnergyDirection.Reverse and value > 0 - or self.power_flow == TuyaPowerFlow.Forward + or self.energy_direction == TuyaEnergyDirection.Forward and value < 0 ): value = -value return value @property - def power_flow(self) -> TuyaPowerFlow | None: - """Return the channel power flow direction.""" + def energy_direction(self) -> TuyaEnergyDirection | None: + """Return the channel energy direction.""" if not self.mcu_cluster: return None try: - return self.mcu_cluster.get(POWER_FLOW + Channel.attr_suffix(self.channel)) + return self.mcu_cluster.get( + ENERGY_DIRECTION + Channel.attr_suffix(self.channel) + ) except KeyError: return None - @power_flow.setter - def power_flow(self, value: TuyaPowerFlow): - """Update the channel power flow direction.""" + @energy_direction.setter + def energy_direction(self, value: TuyaEnergyDirection): + """Update the channel energy direction.""" if not self.mcu_cluster: return self.mcu_cluster.update_attribute( - POWER_FLOW + Channel.attr_suffix(self.channel) + ENERGY_DIRECTION + Channel.attr_suffix(self.channel) ) - def power_flow_handler(self, attr_name: str, value) -> tuple[str, Any]: - """Unsigned attributes are aligned with power flow direction.""" + def energy_direction_handler(self, attr_name: str, value) -> tuple[str, Any]: + """Unsigned attributes are aligned with energy direction.""" if attr_name.endswith(self.UNSIGNED_ATTR_SUFFIX): attr_name = attr_name.removesuffix(self.UNSIGNED_ATTR_SUFFIX) - value = self.align_with_power_flow(value) + value = self.align_with_energy_direction(value) return attr_name, value -class PowerFlowMitigationHelper(PowerFlowHelper, MeterClusterHelper): - """Logic compensating for delayed power flow direction reporting. +class EnergyDirectionMitigationHelper(EnergyDirectionHelper, MeterClusterHelper): + """Logic compensating for delayed energy direction reporting. _TZE204_81yrt3lo (app_version: 74, hw_version: 1 and stack_version: 0) has a bug - which results in it reporting power_flow after its power data points. + which results in it reporting energy_direction after its power data points. This means a change in direction would only be reported after the subsequent DP report, resulting in incorrect attribute signing in the ZCL clusters. - This mitigation holds attribute update values until the subsequent power_flow report, + This mitigation holds attribute update values until the subsequent energy_direction report, resulting in correct values, but a delay in attribute update equal to the update interval. """ HOLD = "hold" RELEASE = "release" - """Devices requiring power flow mitigation.""" - _POWER_FLOW_MITIGATION: tuple[dict] = ( + """Devices requiring energy direction mitigation.""" + _ENERGY_DIRECTION_MITIGATION_MATCHES: tuple[dict] = ( { "manufacturer": "_TZE204_81yrt3lo", "model": "TS0601", @@ -264,31 +266,31 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @property - def power_flow_mitigation(self) -> bool: + def energy_direction_mitigation(self) -> bool: """Return the mitigation configuration.""" return self.get_config( - EnergyMeterConfiguration.AttributeDefs.power_flow_mitigation.name + EnergyMeterConfiguration.AttributeDefs.energy_direction_mitigation.name ) @property - def power_flow_mitigation_required(self) -> bool: - """Return True if the device requires Power Flow mitigations.""" + def energy_direction_mitigation_required(self) -> bool: + """Return True if the device requires Energy direction mitigations.""" if self._mitigation_required is None: self._mitigation_required = self._evaluate_device_mitigation() return self._mitigation_required - def power_flow_mitigation_handler(self, attr_name: str, value) -> str | None: - """Compensate for delay in reported power flow direction.""" + def energy_direction_mitigation_handler(self, attr_name: str, value) -> str | None: + """Compensate for delay in reported energy direction.""" if ( attr_name.removesuffix(self.UNSIGNED_ATTR_SUFFIX) not in self._EXTENSIVE_ATTRIBUTES - or self.power_flow_mitigation + or self.energy_direction_mitigation not in ( - PowerFlowMitigation.Automatic, - PowerFlowMitigation.Enabled, + EnergyDirectionMitigation.Automatic, + EnergyDirectionMitigation.Enabled, ) - or self.power_flow_mitigation == PowerFlowMitigation.Automatic - and not self.power_flow_mitigation_required + or self.energy_direction_mitigation == EnergyDirectionMitigation.Automatic + and not self.energy_direction_mitigation_required ): return None @@ -304,7 +306,7 @@ def power_flow_mitigation_handler(self, attr_name: str, value) -> str | None: def _mitigation_action( self, attr_name: str, value: int, trigger_channel: Channel ) -> str: - """Return the action for the power flow mitigation handler.""" + """Return the action for the energy direction mitigation handler.""" return self.RELEASE def _get_held_value(self, attr_name: str) -> int | None: @@ -328,7 +330,7 @@ def _release_held_values( cluster._store_value(attr_name, None) def _evaluate_device_mitigation(self) -> bool: - """Return True if the device requires Power Flow mitigation.""" + """Return True if the device requires energy direction mitigation.""" basic_cluster = self.endpoint.device.endpoints[1].basic return { "manufacturer": self.endpoint.device.manufacturer, @@ -344,10 +346,10 @@ def _evaluate_device_mitigation(self) -> bool: basic_cluster.AttributeDefs.stack_version.name ), }, - } in self._POWER_FLOW_MITIGATION + } in self._ENERGY_DIRECTION_MITIGATION_MATCHES -class VirtualChannelHelper(PowerFlowHelper, MeterClusterHelper): +class VirtualChannelHelper(EnergyDirectionHelper, MeterClusterHelper): """Methods for calculating virtual energy meter channel attributes.""" """Map of virtual channels to their trigger channel and calculation method.""" @@ -430,12 +432,12 @@ def _get_source_values( self, attr_name: str, channels: tuple[Channel], - align_uint_with_power_flow: bool = True, + align_uint_with_energy_direction: bool = True, ) -> tuple: """Get source values from channel clusters.""" return tuple( - cluster.align_with_power_flow(cluster.get(attr_name)) - if align_uint_with_power_flow and self._is_attr_uint(attr_name) + cluster.align_with_energy_direction(cluster.get(attr_name)) + if align_uint_with_energy_direction and self._is_attr_uint(attr_name) else cluster.get(attr_name) for channel in channels for cluster in [self.get_cluster(channel)] @@ -444,8 +446,8 @@ def _get_source_values( class TuyaElectricalMeasurement( VirtualChannelHelper, - PowerFlowMitigationHelper, - PowerFlowHelper, + EnergyDirectionMitigationHelper, + EnergyDirectionHelper, MeterClusterHelper, TuyaLocalCluster, TuyaZBElectricalMeasurement, @@ -501,11 +503,11 @@ class TuyaElectricalMeasurement( def update_attribute(self, attr_name: str, value): """Update the cluster attribute.""" if ( - self.power_flow_mitigation_handler(attr_name, value) - == PowerFlowMitigationHelper.HOLD + self.energy_direction_mitigation_handler(attr_name, value) + == EnergyDirectionMitigationHelper.HOLD ): return - attr_name, value = self.power_flow_handler(attr_name, value) + attr_name, value = self.energy_direction_handler(attr_name, value) super().update_attribute(attr_name, value) self._update_measurement_type(attr_name) self.virtual_channel_handler(attr_name) @@ -525,8 +527,8 @@ def _update_measurement_type(self, attr_name: str): class TuyaMetering( VirtualChannelHelper, - PowerFlowMitigationHelper, - PowerFlowHelper, + EnergyDirectionMitigationHelper, + EnergyDirectionHelper, MeterClusterHelper, TuyaLocalCluster, TuyaZBMeteringClusterWithUnit, @@ -562,11 +564,11 @@ def format( def update_attribute(self, attr_name: str, value): """Update the cluster attribute.""" if ( - self.power_flow_mitigation_handler(attr_name, value) - == PowerFlowMitigationHelper.HOLD + self.energy_direction_mitigation_handler(attr_name, value) + == EnergyDirectionMitigationHelper.HOLD ): return - attr_name, value = self.power_flow_handler(attr_name, value) + attr_name, value = self.energy_direction_handler(attr_name, value) super().update_attribute(attr_name, value) self.virtual_channel_handler(attr_name) @@ -627,7 +629,7 @@ def update_attribute(self, attr_name: str, value): dp_id=108, ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.instantaneous_demand.name - + PowerFlowHelper.UNSIGNED_ATTR_SUFFIX, + + EnergyDirectionHelper.UNSIGNED_ATTR_SUFFIX, converter=lambda x: x * 10, ) .tuya_dp( @@ -637,15 +639,15 @@ def update_attribute(self, attr_name: str, value): TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, TuyaElectricalMeasurement.AttributeDefs.rms_current.name, TuyaElectricalMeasurement.AttributeDefs.active_power.name - + PowerFlowHelper.UNSIGNED_ATTR_SUFFIX, + + EnergyDirectionHelper.UNSIGNED_ATTR_SUFFIX, ), converter=lambda x: TuyaPowerPhase.variant_3(x), ) .tuya_dp_attribute( dp_id=102, - attribute_name=POWER_FLOW, - type=TuyaPowerFlow, - converter=lambda x: TuyaPowerFlow(x), + attribute_name=ENERGY_DIRECTION, + type=TuyaEnergyDirection, + converter=lambda x: TuyaEnergyDirection(x), ) .add_to_registry() ) @@ -743,15 +745,15 @@ def update_attribute(self, attr_name: str, value): ) .tuya_dp_attribute( dp_id=114, - attribute_name=POWER_FLOW, - type=TuyaPowerFlow, - converter=lambda x: TuyaPowerFlow(x), + attribute_name=ENERGY_DIRECTION, + type=TuyaEnergyDirection, + converter=lambda x: TuyaEnergyDirection(x), ) .tuya_dp_attribute( dp_id=115, - attribute_name=POWER_FLOW + Channel.attr_suffix(Channel.B), - type=TuyaPowerFlow, - converter=lambda x: TuyaPowerFlow(x), + attribute_name=ENERGY_DIRECTION + Channel.attr_suffix(Channel.B), + type=TuyaEnergyDirection, + converter=lambda x: TuyaEnergyDirection(x), ) .tuya_number( dp_id=116, @@ -791,12 +793,12 @@ def update_attribute(self, attr_name: str, value): fallback_name="Virtual channel", ) .enum( - EnergyMeterConfiguration.AttributeDefs.power_flow_mitigation.name, - PowerFlowMitigation, + EnergyMeterConfiguration.AttributeDefs.energy_direction_mitigation.name, + EnergyDirectionMitigation, EnergyMeterConfiguration.cluster_id, entity_type=EntityType.CONFIG, - translation_key="power_flow_delay_mitigation", - fallback_name="Power flow delay mitigation", + translation_key="energy_direction_delay_mitigation", + fallback_name="Energy direction delay mitigation", ) .tuya_dp( dp_id=111, @@ -833,13 +835,13 @@ def update_attribute(self, attr_name: str, value): dp_id=101, ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.instantaneous_demand.name - + PowerFlowHelper.UNSIGNED_ATTR_SUFFIX, + + EnergyDirectionHelper.UNSIGNED_ATTR_SUFFIX, ) .tuya_dp( dp_id=105, ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.instantaneous_demand.name - + PowerFlowHelper.UNSIGNED_ATTR_SUFFIX, + + EnergyDirectionHelper.UNSIGNED_ATTR_SUFFIX, endpoint_id=Channel.B, ) .tuya_dp( @@ -871,15 +873,15 @@ def update_attribute(self, attr_name: str, value): ) .tuya_dp_attribute( dp_id=102, - attribute_name=POWER_FLOW, - type=TuyaPowerFlow, - converter=lambda x: TuyaPowerFlow(x), + attribute_name=ENERGY_DIRECTION, + type=TuyaEnergyDirection, + converter=lambda x: TuyaEnergyDirection(x), ) .tuya_dp_attribute( dp_id=104, - attribute_name=POWER_FLOW + Channel.attr_suffix(Channel.B), - type=TuyaPowerFlow, - converter=lambda x: TuyaPowerFlow(x), + attribute_name=ENERGY_DIRECTION + Channel.attr_suffix(Channel.B), + type=TuyaEnergyDirection, + converter=lambda x: TuyaEnergyDirection(x), ) .tuya_number( dp_id=129, From d764ea514ac62242b38d59bade0586c845a81fbf Mon Sep 17 00:00:00 2001 From: Jack <46714706+jeverley@users.noreply.github.com> Date: Mon, 3 Feb 2025 13:39:40 +0000 Subject: [PATCH 04/13] Implement energy direction delayed reporting mitigation --- zhaquirks/tuya/ts0601_energy_meter.py | 115 +++++++++----------------- 1 file changed, 38 insertions(+), 77 deletions(-) diff --git a/zhaquirks/tuya/ts0601_energy_meter.py b/zhaquirks/tuya/ts0601_energy_meter.py index 00aaad6317..01317444bc 100644 --- a/zhaquirks/tuya/ts0601_energy_meter.py +++ b/zhaquirks/tuya/ts0601_energy_meter.py @@ -165,6 +165,7 @@ def get_cluster( return getattr( self.endpoint.device.endpoints[endpoint_id], ep_attribute or self.ep_attribute, + None, ) def get_config(self, attr_name: str, default: Any = None) -> Any: @@ -185,6 +186,11 @@ def mcu_cluster(self) -> TuyaMCUCluster | None: self.endpoint.device.endpoints[1], TuyaMCUCluster.ep_attribute, None ) + @property + def virtual(self) -> bool: + """Return True if the cluster channel is virtual.""" + return self.channel in Channel.virtual_channels() + class EnergyDirectionHelper(MeterClusterHelper): """Apply Tuya EnergyDirection to ZCL power attributes.""" @@ -243,9 +249,6 @@ class EnergyDirectionMitigationHelper(EnergyDirectionHelper, MeterClusterHelper) resulting in correct values, but a delay in attribute update equal to the update interval. """ - HOLD = "hold" - RELEASE = "release" - """Devices requiring energy direction mitigation.""" _ENERGY_DIRECTION_MITIGATION_MATCHES: tuple[dict] = ( { @@ -279,12 +282,10 @@ def energy_direction_mitigation_required(self) -> bool: self._mitigation_required = self._evaluate_device_mitigation() return self._mitigation_required - def energy_direction_mitigation_handler(self, attr_name: str, value) -> str | None: - """Compensate for delay in reported energy direction.""" - if ( - attr_name.removesuffix(self.UNSIGNED_ATTR_SUFFIX) - not in self._EXTENSIVE_ATTRIBUTES - or self.energy_direction_mitigation + def energy_direction_mitigation_handler(self, attr_name: str, value: Any) -> Any: + """Hold the attribute value until the next update is received from the device.""" + if self.virtual or ( + self.energy_direction_mitigation not in ( EnergyDirectionMitigation.Automatic, EnergyDirectionMitigation.Enabled, @@ -292,42 +293,13 @@ def energy_direction_mitigation_handler(self, attr_name: str, value) -> str | No or self.energy_direction_mitigation == EnergyDirectionMitigation.Automatic and not self.energy_direction_mitigation_required ): - return None + if attr_name in self._held_values: + self._held_values.remove(attr_name) + return value - return self.RELEASE - # action = self._mitigation_action(attr_name, value, trigger_channel) - # if action != self.RELEASE: - # self._store_value(attr_name, value) - # if action != self.PREEMPT: - # return action - # self._release_held_values(attr_name, source_channels, trigger_channel) - # return action - - def _mitigation_action( - self, attr_name: str, value: int, trigger_channel: Channel - ) -> str: - """Return the action for the energy direction mitigation handler.""" - return self.RELEASE - - def _get_held_value(self, attr_name: str) -> int | None: - """Retrieve the held attribute value.""" - return self._held_values.get(attr_name, None) - - def _store_value(self, attr_name: str, value: int | None): - """Store the update value.""" + held_value = self._held_values.get(attr_name, None) self._held_values[attr_name] = value - - def _release_held_values( - self, attr_name: str, source_channels: tuple[Channel], trigger_channel: Channel - ): - """Release held values to update the cluster attributes.""" - for channel in source_channels: - cluster = self.get_cluster(channel) - if channel != trigger_channel: - value = cluster._get_held_value(attr_name) - if value is not None: - cluster.update_attribute(attr_name, value) - cluster._store_value(attr_name, None) + return held_value def _evaluate_device_mitigation(self) -> bool: """Return True if the device requires energy direction mitigation.""" @@ -354,8 +326,8 @@ class VirtualChannelHelper(EnergyDirectionHelper, MeterClusterHelper): """Map of virtual channels to their trigger channel and calculation method.""" _VIRTUAL_CHANNEL_CALCULATIONS: dict[ - tuple[Channel, VirtualChannelConfig | None], - tuple[tuple[Channel], Callable | None, Channel | None], + tuple[Channel, VirtualChannelConfig], + tuple[tuple[Channel], Callable, Channel | None], ] = { (Channel.AB, VirtualChannelConfig.A_plus_B): ( (Channel.A, Channel.B), @@ -374,11 +346,6 @@ class VirtualChannelHelper(EnergyDirectionHelper, MeterClusterHelper): ), } - @property - def virtual(self) -> bool: - """Return True if the cluster channel is virtual.""" - return self.channel in Channel.virtual_channels() - @property def virtual_channel_config(self) -> VirtualChannelConfig | None: """Return the virtual channel configuration.""" @@ -392,6 +359,9 @@ def virtual_channel_handler(self, attr_name: str): if self.virtual or attr_name not in self._EXTENSIVE_ATTRIBUTES: return for channel in self._device_virtual_channels: + virtual_cluster = self.get_cluster(channel) + if not virtual_cluster: + continue source_channels, method, trigger_channel = ( self._VIRTUAL_CHANNEL_CALCULATIONS.get( (channel, self.virtual_channel_config), (None, None, None) @@ -400,7 +370,6 @@ def virtual_channel_handler(self, attr_name: str): if trigger_channel is not None and self.channel != trigger_channel: continue value = self._calculate_virtual_value(attr_name, source_channels, method) - virtual_cluster = self.get_cluster(channel) virtual_cluster.update_attribute(attr_name, value) def _calculate_virtual_value( @@ -502,11 +471,7 @@ class TuyaElectricalMeasurement( def update_attribute(self, attr_name: str, value): """Update the cluster attribute.""" - if ( - self.energy_direction_mitigation_handler(attr_name, value) - == EnergyDirectionMitigationHelper.HOLD - ): - return + value = self.energy_direction_mitigation_handler(attr_name, value) attr_name, value = self.energy_direction_handler(attr_name, value) super().update_attribute(attr_name, value) self._update_measurement_type(attr_name) @@ -563,11 +528,7 @@ def format( def update_attribute(self, attr_name: str, value): """Update the cluster attribute.""" - if ( - self.energy_direction_mitigation_handler(attr_name, value) - == EnergyDirectionMitigationHelper.HOLD - ): - return + value = self.energy_direction_mitigation_handler(attr_name, value) attr_name, value = self.energy_direction_handler(attr_name, value) super().update_attribute(attr_name, value) self.virtual_channel_handler(attr_name) @@ -757,14 +718,14 @@ def update_attribute(self, attr_name: str, value): ) .tuya_number( dp_id=116, - attribute_name="update_interval", + attribute_name="reporting_interval", type=t.uint32_t_be, unit=UnitOfTime.SECONDS, min_value=5, max_value=60, step=1, - translation_key="update_interval", - fallback_name="Update Interval", + translation_key="reporting_interval", + fallback_name="Reporting interval", entity_type=EntityType.CONFIG, ) .add_to_registry() @@ -885,14 +846,14 @@ def update_attribute(self, attr_name: str, value): ) .tuya_number( dp_id=129, - attribute_name="update_interval", + attribute_name="reporting_interval", type=t.uint32_t_be, unit=UnitOfTime.SECONDS, min_value=5, max_value=60, step=1, - translation_key="update_interval", - fallback_name="Update Interval", + translation_key="reporting_interval", + fallback_name="Reporting interval", entity_type=EntityType.CONFIG, ) .tuya_number( @@ -902,7 +863,7 @@ def update_attribute(self, attr_name: str, value): # unit=PERCENTAGE, min_value=0, max_value=2000, - step=1, + step=0.1, multiplier=0.1, translation_key="calibrate_ac_frequency", fallback_name="Calibrate AC frequency", @@ -916,7 +877,7 @@ def update_attribute(self, attr_name: str, value): # unit=PERCENTAGE, min_value=0, max_value=2000, - step=1, + step=0.1, multiplier=0.1, translation_key="calibrate_summ_delivered", fallback_name="Calibrate summation delivered", @@ -931,7 +892,7 @@ def update_attribute(self, attr_name: str, value): # unit=PERCENTAGE, min_value=0, max_value=2000, - step=1, + step=0.1, multiplier=0.1, translation_key="calibrate_summ_delivered_b", fallback_name="Calibrate summation delivered B", @@ -945,7 +906,7 @@ def update_attribute(self, attr_name: str, value): # unit=PERCENTAGE, min_value=0, max_value=2000, - step=1, + step=0.1, multiplier=0.1, translation_key="calibrate_summ_received", fallback_name="Calibrate summation received", @@ -960,7 +921,7 @@ def update_attribute(self, attr_name: str, value): # unit=PERCENTAGE, min_value=0, max_value=2000, - step=1, + step=0.1, multiplier=0.1, translation_key="calibrate_summ_received_b", fallback_name="Calibrate summation received B", @@ -974,7 +935,7 @@ def update_attribute(self, attr_name: str, value): # unit=PERCENTAGE, min_value=0, max_value=2000, - step=1, + step=0.1, multiplier=0.1, translation_key="calibrate_instantaneous_demand", fallback_name="Calibrate instantaneous demand", @@ -989,7 +950,7 @@ def update_attribute(self, attr_name: str, value): # unit=PERCENTAGE, min_value=0, max_value=2000, - step=1, + step=0.1, multiplier=0.1, translation_key="calibrate_instantaneous_demand_b", fallback_name="Calibrate instantaneous demand B", @@ -1003,7 +964,7 @@ def update_attribute(self, attr_name: str, value): # unit=PERCENTAGE, min_value=0, max_value=2000, - step=1, + step=0.1, multiplier=0.1, translation_key="calibrate_current", fallback_name="Calibrate current", @@ -1017,7 +978,7 @@ def update_attribute(self, attr_name: str, value): # unit=PERCENTAGE, min_value=0, max_value=2000, - step=1, + step=0.1, multiplier=0.1, translation_key="calibrate_current_b", fallback_name="Calibrate current B", @@ -1031,7 +992,7 @@ def update_attribute(self, attr_name: str, value): # unit=PERCENTAGE, min_value=0, max_value=2000, - step=1, + step=0.1, multiplier=0.1, translation_key="calibrate_voltage", fallback_name="Calibrate voltage", From 13786615cd0695b356eda8e9ab5c9dd38adc8570 Mon Sep 17 00:00:00 2001 From: Jack <46714706+jeverley@users.noreply.github.com> Date: Mon, 3 Feb 2025 18:49:20 +0000 Subject: [PATCH 05/13] Test for energy direction alignment on ZCL clusters --- tests/test_tuya_energy_meter.py | 269 ++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 tests/test_tuya_energy_meter.py diff --git a/tests/test_tuya_energy_meter.py b/tests/test_tuya_energy_meter.py new file mode 100644 index 0000000000..e785197aeb --- /dev/null +++ b/tests/test_tuya_energy_meter.py @@ -0,0 +1,269 @@ +"""Tests for Tuya quirks.""" + +import pytest +from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement +from zigpy.zcl.clusters.smartenergy import Metering + +from tests.common import ClusterListener +import zhaquirks +from zhaquirks import LocalDataCluster +import zhaquirks.tuya +from zhaquirks.tuya.mcu import TuyaMCUCluster + +zhaquirks.setup() + + +@pytest.mark.parametrize( + "model,manuf,channels,bidirectional", + [ + ( + "_TZE204_cjbofhxw", + "TS0601", + {1}, + False, + ), + ("_TZE204_ac0fhfiq", "TS0601", {1}, True), + ("_TZE200_rks0sgb7", "TS0601", {1, 2, 11}, True), + ("_TZE204_81yrt3lo", "TS0601", {1, 2, 11}, True), + ], +) +async def test_tuya_energy_meter_quirk_energy_direction_align( + zigpy_device_from_v2_quirk, + model: str, + manuf: str, + channels, + bidirectional: bool, +): + """Test Tuya Energy Meter Quirk energy direction align in ElectricalMeasurement and Metering clusters.""" + quirked_device = zigpy_device_from_v2_quirk(model, manuf) + + ENERGY_DIRECTION_ATTR = "energy_direction" + ENERGY_DIRECTION_ATTR_B = "energy_direction_ch_b" + FORWARD = 0 + REVERSE = 1 + + CH_A = 1 + CH_B = 2 + CH_AB = 11 + + UNSIGNED_ATTR_SUFFIX = "_attr_unsigned" + + CURRENT = 5 + POWER = 100 + VOLTAGE = 230 + SUMM_RECEIVED = 15000 + DIRECTION_A = REVERSE + DIRECTION_B = FORWARD + + ep = quirked_device.endpoints[1] + + assert ep.tuya_manufacturer is not None + assert isinstance(ep.tuya_manufacturer, TuyaMCUCluster) + mcu_listener = ClusterListener(ep.tuya_manufacturer) + + listeners = {} + for channel in channels: + channel_ep = quirked_device.endpoints.get(channel, None) + assert channel_ep is not None + + assert channel_ep.electrical_measurement is not None + assert isinstance(channel_ep.electrical_measurement, ElectricalMeasurement) + + assert channel_ep.smartenergy_metering is not None + assert isinstance(channel_ep.smartenergy_metering, Metering) + + listeners[channel] = { + "metering": ClusterListener(channel_ep.smartenergy_metering), + "electrical_measurement": ClusterListener( + channel_ep.electrical_measurement + ), + } + + if bidirectional: + # verify the direction attribute is present + attr = getattr(ep.tuya_manufacturer.AttributeDefs, ENERGY_DIRECTION_ATTR, None) + assert attr is not None + + # set the initial direction + ep.tuya_manufacturer.update_attribute(ENERGY_DIRECTION_ATTR, DIRECTION_A) + assert len(mcu_listener.attribute_updates) == 1 + assert mcu_listener.attribute_updates[0][0] == attr.id + assert mcu_listener.attribute_updates[0][1] == DIRECTION_A + else: + # verify the direction & direction B attributes are not present + attr = getattr(ep.tuya_manufacturer.AttributeDefs, ENERGY_DIRECTION_ATTR, None) + assert attr is None + attr = getattr( + ep.tuya_manufacturer.AttributeDefs, + ENERGY_DIRECTION_ATTR_B, + None, + ) + assert attr is None + + if bidirectional and CH_B in channels: + # verify the direction B attribute is present + attr = getattr( + ep.tuya_manufacturer.AttributeDefs, + ENERGY_DIRECTION_ATTR_B, + None, + ) + assert attr is not None + + # set the initial direction + ep.tuya_manufacturer.update_attribute(ENERGY_DIRECTION_ATTR_B, DIRECTION_B) + assert len(mcu_listener.attribute_updates) == 2 + assert mcu_listener.attribute_updates[1][0] == attr.id + assert mcu_listener.attribute_updates[1][1] == DIRECTION_B + + if CH_AB in channels: + # verify the config cluster is present + channel_ep = quirked_device.endpoints[1] + assert channel_ep.energy_meter_config is not None + assert isinstance(channel_ep.energy_meter_config, LocalDataCluster) + + config_listener = ClusterListener(ep.energy_meter_config) + + # set the initial virtual channel calculation method (sum A and B) + channel_ep.energy_meter_config.update_attribute( + channel_ep.energy_meter_config.AttributeDefs.virtual_channel_config.id, + channel_ep.energy_meter_config.VirtualChannelConfig.A_plus_B, + ) + assert len(config_listener.attribute_updates) == 1 + assert ( + config_listener.attribute_updates[0][0] + == channel_ep.energy_meter_config.AttributeDefs.virtual_channel_config.id + ) + assert ( + config_listener.attribute_updates[0][1] + == channel_ep.energy_meter_config.VirtualChannelConfig.A_plus_B + ) + + for channel in channels: + if channel == CH_A: + direction = DIRECTION_A + elif channel == CH_B: + direction = DIRECTION_B + elif channel == CH_AB: + # updates to channel AB will occur as a result of the device updates to channels A & B + continue + assert direction is not None + + channel_ep = quirked_device.endpoints[channel] + + # update ElectricalMeasurement attributes + channel_ep.electrical_measurement.update_attribute( + ElectricalMeasurement.AttributeDefs.rms_current.name, CURRENT + ) + channel_ep.electrical_measurement.update_attribute( + ElectricalMeasurement.AttributeDefs.rms_voltage.name, VOLTAGE + ) + channel_ep.electrical_measurement.update_attribute( + # The UNSIGNED_ATTR_SUFFIX applies energy direction on bidirectional devices + ElectricalMeasurement.AttributeDefs.active_power.name + + UNSIGNED_ATTR_SUFFIX, + POWER, + ) + + # verify the ElectricalMeasurement attributes were updated correctly + assert len(listeners[channel]["electrical_measurement"].attribute_updates) == 4 + assert ( + listeners[channel]["electrical_measurement"].attribute_updates[0][0] + == ElectricalMeasurement.AttributeDefs.rms_current.id + ) + assert ( + listeners[channel]["electrical_measurement"].attribute_updates[0][1] + == CURRENT + ) + assert ( + listeners[channel]["electrical_measurement"].attribute_updates[1][0] + == ElectricalMeasurement.AttributeDefs.rms_voltage.id + ) + assert ( + listeners[channel]["electrical_measurement"].attribute_updates[1][1] + == VOLTAGE + ) + assert ( + listeners[channel]["electrical_measurement"].attribute_updates[2][0] + == ElectricalMeasurement.AttributeDefs.active_power.id + ) + assert ( + listeners[channel]["electrical_measurement"].attribute_updates[2][1] + == POWER + if not bidirectional or direction == FORWARD + else -POWER + ) + assert ( + listeners[channel]["electrical_measurement"].attribute_updates[3][0] + == ElectricalMeasurement.AttributeDefs.measurement_type.id + ) + assert ( + listeners[channel]["electrical_measurement"].attribute_updates[3][1] + == ElectricalMeasurement.MeasurementType.Active_measurement_AC + | ElectricalMeasurement.MeasurementType.Phase_A_measurement # updated by the _update_measurement_type function + ) + + # update Metering attributes + channel_ep.smartenergy_metering.update_attribute( + Metering.AttributeDefs.instantaneous_demand.name + UNSIGNED_ATTR_SUFFIX, + POWER, + ) + channel_ep.smartenergy_metering.update_attribute( + # The UNSIGNED_ATTR_SUFFIX applies energy direction on bidirectional devices + Metering.AttributeDefs.current_summ_received.name, + SUMM_RECEIVED, + ) + + # verify the Metering attributes were updated correctly + assert len(listeners[channel]["metering"].attribute_updates) == 2 + assert ( + listeners[channel]["metering"].attribute_updates[0][0] + == Metering.AttributeDefs.instantaneous_demand.id + ) + assert ( + listeners[channel]["metering"].attribute_updates[0][1] == POWER + if not bidirectional or direction == FORWARD + else -POWER + ) + assert ( + listeners[channel]["metering"].attribute_updates[1][0] + == Metering.AttributeDefs.current_summ_received.id + ) + assert listeners[channel]["metering"].attribute_updates[1][1] == SUMM_RECEIVED + + if CH_AB in channels: + # verify the ElectricalMeasurement attributes were updated correctly + assert len(listeners[CH_AB]["electrical_measurement"].attribute_updates) == 3 + assert ( + listeners[CH_AB]["electrical_measurement"].attribute_updates[0][0] + == ElectricalMeasurement.AttributeDefs.rms_current.id + ) + assert ( + listeners[CH_AB]["electrical_measurement"].attribute_updates[0][1] + == -CURRENT + CURRENT # -CURRENT + CURRENT = 0 + ) + assert ( + listeners[CH_AB]["electrical_measurement"].attribute_updates[1][0] + == ElectricalMeasurement.AttributeDefs.active_power.id + ) + assert ( + listeners[CH_AB]["electrical_measurement"].attribute_updates[1][1] == 0 + ) # -POWER + POWER = 0 + assert ( + listeners[CH_AB]["electrical_measurement"].attribute_updates[2][0] + == ElectricalMeasurement.AttributeDefs.measurement_type.id + ) + assert ( + listeners[CH_AB]["electrical_measurement"].attribute_updates[2][1] + == ElectricalMeasurement.MeasurementType.Active_measurement_AC + | ElectricalMeasurement.MeasurementType.Phase_A_measurement # updated by the _update_measurement_type function + ) + + # verify the Metering attributes were updated correctly + assert len(listeners[CH_AB]["metering"].attribute_updates) == 1 + assert ( + listeners[CH_AB]["metering"].attribute_updates[0][0] + == Metering.AttributeDefs.instantaneous_demand.id + ) + assert ( + listeners[CH_AB]["metering"].attribute_updates[0][1] == 0 + ) # -POWER + POWER = 0 From a6a0edad1b6e0e8270255afbd4d00a8c505f590a Mon Sep 17 00:00:00 2001 From: jeverley <46714706+jeverley@users.noreply.github.com> Date: Mon, 3 Feb 2025 22:40:39 +0000 Subject: [PATCH 06/13] Test for energy direction delay mitigation --- tests/test_tuya_energy_meter.py | 192 +++++++++++++++++++++++--- zhaquirks/tuya/ts0601_energy_meter.py | 9 -- 2 files changed, 173 insertions(+), 28 deletions(-) diff --git a/tests/test_tuya_energy_meter.py b/tests/test_tuya_energy_meter.py index e785197aeb..e387bff340 100644 --- a/tests/test_tuya_energy_meter.py +++ b/tests/test_tuya_energy_meter.py @@ -1,6 +1,7 @@ """Tests for Tuya quirks.""" import pytest +from zigpy.zcl.clusters.general import Basic from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement from zigpy.zcl.clusters.smartenergy import Metering @@ -42,9 +43,9 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( FORWARD = 0 REVERSE = 1 - CH_A = 1 - CH_B = 2 - CH_AB = 11 + CHANNEL_A = 1 + CHANNEL_B = 2 + CHANNEL_AB = 11 UNSIGNED_ATTR_SUFFIX = "_attr_unsigned" @@ -100,7 +101,7 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( ) assert attr is None - if bidirectional and CH_B in channels: + if bidirectional and CHANNEL_B in channels: # verify the direction B attribute is present attr = getattr( ep.tuya_manufacturer.AttributeDefs, @@ -115,7 +116,7 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( assert mcu_listener.attribute_updates[1][0] == attr.id assert mcu_listener.attribute_updates[1][1] == DIRECTION_B - if CH_AB in channels: + if CHANNEL_AB in channels: # verify the config cluster is present channel_ep = quirked_device.endpoints[1] assert channel_ep.energy_meter_config is not None @@ -139,11 +140,11 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( ) for channel in channels: - if channel == CH_A: + if channel == CHANNEL_A: direction = DIRECTION_A - elif channel == CH_B: + elif channel == CHANNEL_B: direction = DIRECTION_B - elif channel == CH_AB: + elif channel == CHANNEL_AB: # updates to channel AB will occur as a result of the device updates to channels A & B continue assert direction is not None @@ -230,40 +231,193 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( ) assert listeners[channel]["metering"].attribute_updates[1][1] == SUMM_RECEIVED - if CH_AB in channels: + if CHANNEL_AB in channels: # verify the ElectricalMeasurement attributes were updated correctly - assert len(listeners[CH_AB]["electrical_measurement"].attribute_updates) == 3 assert ( - listeners[CH_AB]["electrical_measurement"].attribute_updates[0][0] + len(listeners[CHANNEL_AB]["electrical_measurement"].attribute_updates) == 3 + ) + assert ( + listeners[CHANNEL_AB]["electrical_measurement"].attribute_updates[0][0] == ElectricalMeasurement.AttributeDefs.rms_current.id ) assert ( - listeners[CH_AB]["electrical_measurement"].attribute_updates[0][1] + listeners[CHANNEL_AB]["electrical_measurement"].attribute_updates[0][1] == -CURRENT + CURRENT # -CURRENT + CURRENT = 0 ) assert ( - listeners[CH_AB]["electrical_measurement"].attribute_updates[1][0] + listeners[CHANNEL_AB]["electrical_measurement"].attribute_updates[1][0] == ElectricalMeasurement.AttributeDefs.active_power.id ) assert ( - listeners[CH_AB]["electrical_measurement"].attribute_updates[1][1] == 0 + listeners[CHANNEL_AB]["electrical_measurement"].attribute_updates[1][1] == 0 ) # -POWER + POWER = 0 assert ( - listeners[CH_AB]["electrical_measurement"].attribute_updates[2][0] + listeners[CHANNEL_AB]["electrical_measurement"].attribute_updates[2][0] == ElectricalMeasurement.AttributeDefs.measurement_type.id ) assert ( - listeners[CH_AB]["electrical_measurement"].attribute_updates[2][1] + listeners[CHANNEL_AB]["electrical_measurement"].attribute_updates[2][1] == ElectricalMeasurement.MeasurementType.Active_measurement_AC | ElectricalMeasurement.MeasurementType.Phase_A_measurement # updated by the _update_measurement_type function ) # verify the Metering attributes were updated correctly - assert len(listeners[CH_AB]["metering"].attribute_updates) == 1 + assert len(listeners[CHANNEL_AB]["metering"].attribute_updates) == 1 assert ( - listeners[CH_AB]["metering"].attribute_updates[0][0] + listeners[CHANNEL_AB]["metering"].attribute_updates[0][0] == Metering.AttributeDefs.instantaneous_demand.id ) assert ( - listeners[CH_AB]["metering"].attribute_updates[0][1] == 0 + listeners[CHANNEL_AB]["metering"].attribute_updates[0][1] == 0 ) # -POWER + POWER = 0 + + +@pytest.mark.parametrize( + "model,manuf,mitigation_config,basic_cluster_match", + [ + ("_TZE204_cjbofhxw", "TS0601", 0, None), # Automatic + ("_TZE204_ac0fhfiq", "TS0601", 0, None), # Automatic + ("_TZE200_rks0sgb7", "TS0601", 1, None), # Disabled + ("_TZE204_81yrt3lo", "TS0601", 2, None), # Enabled + ( + "_TZE204_81yrt3lo", + "TS0601", + 0, # Automatic + { + "app_version": 74, + "hw_version": 1, + "stack_version": 0, + }, + ), + ], +) +async def test_tuya_energy_meter_quirk_energy_direction_delay_mitigation( + zigpy_device_from_v2_quirk, + model: str, + manuf: str, + mitigation_config: None | int, + basic_cluster_match: dict, +): + """Test Tuya Energy Meter Quirk energy direction report mitigation.""" + quirked_device = zigpy_device_from_v2_quirk(model, manuf) + + UNSIGNED_ATTR_SUFFIX = "_attr_unsigned" + + POWER_1 = 100 + POWER_2 = 200 + POWER_3 = 300 + + AUTOMATIC = 0 + DISABLED = 1 + ENABLED = 2 + + ep = quirked_device.endpoints[1] + + # verify the config cluster is present + assert ep.energy_meter_config is not None + assert isinstance(ep.energy_meter_config, LocalDataCluster) + + # set the mitigation config value + config_listener = ClusterListener(ep.energy_meter_config) + ep.energy_meter_config.update_attribute( + ep.energy_meter_config.AttributeDefs.energy_direction_mitigation.id, + mitigation_config, + ) + assert len(config_listener.attribute_updates) == 1 + assert ( + config_listener.attribute_updates[0][0] + == ep.energy_meter_config.AttributeDefs.energy_direction_mitigation.id + ) + assert config_listener.attribute_updates[0][1] == mitigation_config + + if basic_cluster_match: + # verify the basic cluster is present + assert ep.basic is not None + assert isinstance(ep.basic, Basic) + + # populate match details for automatic mitigation + basic_listener = ClusterListener(ep.basic) + ep.basic.update_attribute( + Basic.AttributeDefs.app_version.id, + basic_cluster_match["app_version"], + ) + ep.basic.update_attribute( + Basic.AttributeDefs.hw_version.id, + basic_cluster_match["hw_version"], + ) + ep.basic.update_attribute( + Basic.AttributeDefs.stack_version.id, + basic_cluster_match["stack_version"], + ) + assert len(basic_listener.attribute_updates) == 3 + assert ( + basic_listener.attribute_updates[0][0] == Basic.AttributeDefs.app_version.id + ) + assert ( + basic_listener.attribute_updates[0][1] == basic_cluster_match["app_version"] + ) + assert ( + basic_listener.attribute_updates[1][0] == Basic.AttributeDefs.hw_version.id + ) + assert ( + basic_listener.attribute_updates[1][1] == basic_cluster_match["hw_version"] + ) + assert ( + basic_listener.attribute_updates[2][0] + == Basic.AttributeDefs.stack_version.id + ) + assert ( + basic_listener.attribute_updates[2][1] + == basic_cluster_match["stack_version"] + ) + + # verify the reporting cluster is present + assert ep.smartenergy_metering is not None + assert isinstance(ep.smartenergy_metering, Metering) + + # update the reporting cluster + metering_listener = ClusterListener(ep.smartenergy_metering) + ep.smartenergy_metering.update_attribute( + Metering.AttributeDefs.instantaneous_demand.name + UNSIGNED_ATTR_SUFFIX, + POWER_1, + ) + ep.smartenergy_metering.update_attribute( + Metering.AttributeDefs.instantaneous_demand.name + UNSIGNED_ATTR_SUFFIX, + POWER_2, + ) + ep.smartenergy_metering.update_attribute( + Metering.AttributeDefs.instantaneous_demand.name + UNSIGNED_ATTR_SUFFIX, + POWER_3, + ) + + # cluster values are delayed until their next update when the mitigation is active + assert ( + len(metering_listener.attribute_updates) == 3 + if mitigation_config == DISABLED + or mitigation_config == AUTOMATIC + and not basic_cluster_match + else 2 + ) + + assert ( + metering_listener.attribute_updates[0][0] + == Metering.AttributeDefs.instantaneous_demand.id + ) + assert metering_listener.attribute_updates[0][1] == POWER_1 + + assert ( + metering_listener.attribute_updates[1][0] + == Metering.AttributeDefs.instantaneous_demand.id + ) + assert metering_listener.attribute_updates[1][1] == POWER_2 + + if ( + mitigation_config == DISABLED + or mitigation_config == AUTOMATIC + and not basic_cluster_match + ): + assert ( + metering_listener.attribute_updates[2][0] + == Metering.AttributeDefs.instantaneous_demand.id + ) + assert metering_listener.attribute_updates[2][1] == POWER_3 diff --git a/zhaquirks/tuya/ts0601_energy_meter.py b/zhaquirks/tuya/ts0601_energy_meter.py index 01317444bc..6ad6356929 100644 --- a/zhaquirks/tuya/ts0601_energy_meter.py +++ b/zhaquirks/tuya/ts0601_energy_meter.py @@ -220,15 +220,6 @@ def energy_direction(self) -> TuyaEnergyDirection | None: except KeyError: return None - @energy_direction.setter - def energy_direction(self, value: TuyaEnergyDirection): - """Update the channel energy direction.""" - if not self.mcu_cluster: - return - self.mcu_cluster.update_attribute( - ENERGY_DIRECTION + Channel.attr_suffix(self.channel) - ) - def energy_direction_handler(self, attr_name: str, value) -> tuple[str, Any]: """Unsigned attributes are aligned with energy direction.""" if attr_name.endswith(self.UNSIGNED_ATTR_SUFFIX): From f3b1cf03664827d6be2c1707a5c2ad49e2057d2c Mon Sep 17 00:00:00 2001 From: jeverley <46714706+jeverley@users.noreply.github.com> Date: Mon, 3 Feb 2025 23:30:41 +0000 Subject: [PATCH 07/13] Use tuya_dp_multi for _TZE204_ac0fhfiq --- tests/test_tuya_energy_meter.py | 1 - zhaquirks/tuya/ts0601_energy_meter.py | 79 ++++++++++++++------------- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/tests/test_tuya_energy_meter.py b/tests/test_tuya_energy_meter.py index e387bff340..832c261bdb 100644 --- a/tests/test_tuya_energy_meter.py +++ b/tests/test_tuya_energy_meter.py @@ -309,7 +309,6 @@ async def test_tuya_energy_meter_quirk_energy_direction_delay_mitigation( AUTOMATIC = 0 DISABLED = 1 - ENABLED = 2 ep = quirked_device.endpoints[1] diff --git a/zhaquirks/tuya/ts0601_energy_meter.py b/zhaquirks/tuya/ts0601_energy_meter.py index 6ad6356929..a3080a6312 100644 --- a/zhaquirks/tuya/ts0601_energy_meter.py +++ b/zhaquirks/tuya/ts0601_energy_meter.py @@ -5,7 +5,7 @@ from collections.abc import Callable from typing import Any, Final -from zigpy.quirks.v2.homeassistant import EntityType, UnitOfTime # , PERCENTAGE +from zigpy.quirks.v2.homeassistant import EntityType, PERCENTAGE, UnitOfTime import zigpy.types as t from zigpy.zcl import Cluster from zigpy.zcl.clusters.homeautomation import MeasurementType @@ -13,6 +13,7 @@ from zhaquirks import LocalDataCluster from zhaquirks.tuya import ( + DPToAttributeMapping, TuyaLocalCluster, TuyaZBElectricalMeasurement, TuyaZBMeteringClusterWithUnit, @@ -56,30 +57,22 @@ class TuyaEnergyDirection(t.enum1): class TuyaPowerPhase: - """Methods for extracting values from a Tuya power phase datapoints.""" + """Methods for extracting values from a Tuya Power Phase datapoint.""" @staticmethod - def variant_1(value) -> tuple[t.uint_t, t.uint_t]: - """Variant 1 of power phase Data Point.""" - voltage = value[14] | value[13] << 8 - current = value[12] | value[11] << 8 - return voltage, current + def voltage(value) -> t.uint_t: + """Return the voltage value.""" + return (value[0] << 8) | value[1] @staticmethod - def variant_2(value) -> tuple[t.uint_t, t.uint_t, int]: - """Variant 2 of power phase Data Point.""" - voltage = value[1] | value[0] << 8 - current = value[4] | value[3] << 8 - power = value[7] | value[6] << 8 - return voltage, current, power * 10 + def current(value) -> t.uint_t: + """Return the current value.""" + return (value[2] << 16) | (value[3] << 8) | value[4] @staticmethod - def variant_3(value) -> tuple[t.uint_t, t.uint_t, int]: - """Variant 3 of power phase Data Point.""" - voltage = (value[0] << 8) | value[1] - current = (value[2] << 16) | (value[3] << 8) | value[4] - power = (value[5] << 16) | (value[6] << 8) | value[7] - return voltage, current, power * 10 + def power(value) -> int: + """Return the power value.""" + return (value[5] << 16) | (value[6] << 8) | value[7] * 10 class EnergyDirectionMitigation(t.enum8): @@ -584,16 +577,26 @@ def update_attribute(self, attr_name: str, value): + EnergyDirectionHelper.UNSIGNED_ATTR_SUFFIX, converter=lambda x: x * 10, ) - .tuya_dp( + .tuya_dp_multi( dp_id=6, - ep_attribute=TuyaElectricalMeasurement.ep_attribute, - attribute_name=( - TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, - TuyaElectricalMeasurement.AttributeDefs.rms_current.name, - TuyaElectricalMeasurement.AttributeDefs.active_power.name - + EnergyDirectionHelper.UNSIGNED_ATTR_SUFFIX, - ), - converter=lambda x: TuyaPowerPhase.variant_3(x), + attribute_mapping=[ + DPToAttributeMapping( + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, + converter=TuyaPowerPhase.voltage, + ), + DPToAttributeMapping( + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, + converter=TuyaPowerPhase.current, + ), + DPToAttributeMapping( + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.active_power.name + + EnergyDirectionHelper.UNSIGNED_ATTR_SUFFIX, + converter=TuyaPowerPhase.power, + ), + ], ) .tuya_dp_attribute( dp_id=102, @@ -851,7 +854,7 @@ def update_attribute(self, attr_name: str, value): dp_id=122, attribute_name="ac_frequency_coefficient", type=t.uint32_t_be, - # unit=PERCENTAGE, + unit=PERCENTAGE, min_value=0, max_value=2000, step=0.1, @@ -865,7 +868,7 @@ def update_attribute(self, attr_name: str, value): dp_id=119, attribute_name="current_summ_delivered_coefficient", type=t.uint32_t_be, - # unit=PERCENTAGE, + unit=PERCENTAGE, min_value=0, max_value=2000, step=0.1, @@ -880,7 +883,7 @@ def update_attribute(self, attr_name: str, value): attribute_name="current_summ_delivered_coefficient" + Channel.attr_suffix(Channel.B), type=t.uint32_t_be, - # unit=PERCENTAGE, + unit=PERCENTAGE, min_value=0, max_value=2000, step=0.1, @@ -894,7 +897,7 @@ def update_attribute(self, attr_name: str, value): dp_id=127, attribute_name="current_summ_received_coefficient", type=t.uint32_t_be, - # unit=PERCENTAGE, + unit=PERCENTAGE, min_value=0, max_value=2000, step=0.1, @@ -909,7 +912,7 @@ def update_attribute(self, attr_name: str, value): attribute_name="current_summ_received_coefficient" + Channel.attr_suffix(Channel.B), type=t.uint32_t_be, - # unit=PERCENTAGE, + unit=PERCENTAGE, min_value=0, max_value=2000, step=0.1, @@ -923,7 +926,7 @@ def update_attribute(self, attr_name: str, value): dp_id=118, attribute_name="instantaneous_demand_coefficient", type=t.uint32_t_be, - # unit=PERCENTAGE, + unit=PERCENTAGE, min_value=0, max_value=2000, step=0.1, @@ -938,7 +941,7 @@ def update_attribute(self, attr_name: str, value): attribute_name="instantaneous_demand_coefficient" + Channel.attr_suffix(Channel.B), type=t.uint32_t_be, - # unit=PERCENTAGE, + unit=PERCENTAGE, min_value=0, max_value=2000, step=0.1, @@ -952,7 +955,7 @@ def update_attribute(self, attr_name: str, value): dp_id=117, attribute_name="rms_current_coefficient", type=t.uint32_t_be, - # unit=PERCENTAGE, + unit=PERCENTAGE, min_value=0, max_value=2000, step=0.1, @@ -966,7 +969,7 @@ def update_attribute(self, attr_name: str, value): dp_id=123, attribute_name="rms_current_coefficient" + Channel.attr_suffix(Channel.B), type=t.uint32_t_be, - # unit=PERCENTAGE, + unit=PERCENTAGE, min_value=0, max_value=2000, step=0.1, @@ -980,7 +983,7 @@ def update_attribute(self, attr_name: str, value): dp_id=116, attribute_name="rms_voltage_coefficient", type=t.uint32_t_be, - # unit=PERCENTAGE, + unit=PERCENTAGE, min_value=0, max_value=2000, step=0.1, From 28f62e400a0ef28dde53f81aabba7903c6e1b81d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Feb 2025 23:30:51 +0000 Subject: [PATCH 08/13] Apply pre-commit auto fixes --- zhaquirks/tuya/ts0601_energy_meter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zhaquirks/tuya/ts0601_energy_meter.py b/zhaquirks/tuya/ts0601_energy_meter.py index a3080a6312..4bfa630656 100644 --- a/zhaquirks/tuya/ts0601_energy_meter.py +++ b/zhaquirks/tuya/ts0601_energy_meter.py @@ -5,7 +5,7 @@ from collections.abc import Callable from typing import Any, Final -from zigpy.quirks.v2.homeassistant import EntityType, PERCENTAGE, UnitOfTime +from zigpy.quirks.v2.homeassistant import PERCENTAGE, EntityType, UnitOfTime import zigpy.types as t from zigpy.zcl import Cluster from zigpy.zcl.clusters.homeautomation import MeasurementType From 26b36ec5a8fe4c570ba5ded49243f6bbd14ecc19 Mon Sep 17 00:00:00 2001 From: jeverley <46714706+jeverley@users.noreply.github.com> Date: Mon, 3 Feb 2025 23:38:11 +0000 Subject: [PATCH 09/13] Enable tuya_enchantment --- zhaquirks/tuya/ts0601_energy_meter.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/zhaquirks/tuya/ts0601_energy_meter.py b/zhaquirks/tuya/ts0601_energy_meter.py index 4bfa630656..961a6b3e85 100644 --- a/zhaquirks/tuya/ts0601_energy_meter.py +++ b/zhaquirks/tuya/ts0601_energy_meter.py @@ -521,7 +521,7 @@ def update_attribute(self, attr_name: str, value): ( ### Tuya PJ-MGW1203 1 channel energy meter. TuyaQuirkBuilder("_TZE204_cjbofhxw", "TS0601") - # .tuya_enchantment() + .tuya_enchantment() .adds(EnergyMeterConfiguration) .adds(TuyaElectricalMeasurement) .adds(TuyaMetering) @@ -554,7 +554,7 @@ def update_attribute(self, attr_name: str, value): ( ### Tuya bidirectional 1 channel energy meter with Zigbee Green Power. TuyaQuirkBuilder("_TZE204_ac0fhfiq", "TS0601") - # .tuya_enchantment() + .tuya_enchantment() .adds(EnergyMeterConfiguration) .adds(TuyaElectricalMeasurement) .adds(TuyaMetering) @@ -611,7 +611,7 @@ def update_attribute(self, attr_name: str, value): ( ### EARU Tuya 2 channel bidirectional energy meter manufacturer cluster. TuyaQuirkBuilder("_TZE200_rks0sgb7", "TS0601") - # .tuya_enchantment() + .tuya_enchantment() .adds_endpoint(Channel.B) .adds_endpoint(Channel.AB) .adds(EnergyMeterConfiguration) @@ -729,7 +729,7 @@ def update_attribute(self, attr_name: str, value): ( ### MatSee Plus Tuya PJ-1203A 2 channel bidirectional energy meter with Zigbee Green Power. TuyaQuirkBuilder("_TZE204_81yrt3lo", "TS0601") - # .tuya_enchantment() + .tuya_enchantment() .adds_endpoint(Channel.B) .adds_endpoint(Channel.AB) .adds(EnergyMeterConfiguration) From a195f82bbc37182ea1eadf2245405ac159bdfc72 Mon Sep 17 00:00:00 2001 From: jeverley <46714706+jeverley@users.noreply.github.com> Date: Tue, 4 Feb 2025 11:29:21 +0000 Subject: [PATCH 10/13] Move power phase methods to tuya lib Energy direction handler improvements Apply pre-commit auto fixes --- zhaquirks/tuya/__init__.py | 74 ++++++++++++++++++++++++++- zhaquirks/tuya/ts0601_energy_meter.py | 74 +++++++++++++++------------ 2 files changed, 114 insertions(+), 34 deletions(-) diff --git a/zhaquirks/tuya/__init__.py b/zhaquirks/tuya/__init__.py index 046ca940d9..87a588b153 100644 --- a/zhaquirks/tuya/__init__.py +++ b/zhaquirks/tuya/__init__.py @@ -1,6 +1,6 @@ """Tuya devices.""" -from collections.abc import Callable +from collections.abc import ByteString, Callable import dataclasses import datetime import enum @@ -1079,6 +1079,78 @@ def handle_cluster_request( ) +class PowerPhaseVariant1: + """Methods for extracting values from a Tuya Power Phase datapoint. + 'TS0601': ( + '_TZE204_ac0fhfiq', + '_TZE200_qhlxve78', + '_TZE204_qhlxve78' + ) + """ + + @staticmethod + def voltage_dV(data: ByteString) -> t.uint_t: + """Return the voltage in decivolts (V * 10).""" + return data[14] | (data[13] << 8) + + @staticmethod + def current_mA(data: ByteString) -> t.uint_t: + """Return the current in milliamperes (A * 1000).""" + return data[12] | (data[11] << 8) + + +class PowerPhaseVariant2: + """Methods for extracting values from a Tuya Power Phase datapoint. + 'TS0601': ( + '_TZE200_lsanae15', + '_TZE204_lsanae15' + ) + """ + + @staticmethod + def voltage_dV(data: ByteString) -> t.uint_t: + """Return the voltage in decivolts (V * 10).""" + return data[1] | (data[0] << 8) + + @staticmethod + def current_mA(data: ByteString) -> t.uint_t: + """Return the current in milliamperes (A * 1000).""" + return data[4] | (data[3] << 8) + + @staticmethod + def power_W(data: ByteString) -> int: + """Return the signed power in watts (W).""" + power = data[7] | (data[6] << 8) + if power > 0x7FFF: + power = (0x999A - power) * -1 + return power + + +class PowerPhaseVariant3: + """Methods for extracting values from a Tuya Power Phase datapoint. + 'TS0601': ( + '_TZE204_ac0fhfiq', + '_TZE200_qhlxve78', + '_TZE204_qhlxve78' + ) + """ + + @staticmethod + def voltage_dV(data: ByteString) -> t.uint_t: + """Return the voltage in decivolts (V * 10).""" + return (data[0] << 8) | data[1] + + @staticmethod + def current_mA(data: ByteString) -> t.uint_t: + """Return the current in milliamperes (A * 1000).""" + return (data[2] << 16) | (data[3] << 8) | data[4] + + @staticmethod + def power_W(data: ByteString) -> int: + """Return the power in watts (W).""" + return (data[5] << 16) | (data[6] << 8) | data[7] + + MULTIPLIER = 0x0301 DIVISOR = 0x0302 diff --git a/zhaquirks/tuya/ts0601_energy_meter.py b/zhaquirks/tuya/ts0601_energy_meter.py index 961a6b3e85..607ca30242 100644 --- a/zhaquirks/tuya/ts0601_energy_meter.py +++ b/zhaquirks/tuya/ts0601_energy_meter.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any, Final +from typing import Any, Final, Type from zigpy.quirks.v2.homeassistant import PERCENTAGE, EntityType, UnitOfTime import zigpy.types as t @@ -14,6 +14,7 @@ from zhaquirks import LocalDataCluster from zhaquirks.tuya import ( DPToAttributeMapping, + PowerPhaseVariant3, TuyaLocalCluster, TuyaZBElectricalMeasurement, TuyaZBMeteringClusterWithUnit, @@ -31,6 +32,7 @@ class Channel(t.enum8): B = 2 C = 3 AB = 11 + ABC = 12 @classmethod def attr_suffix(cls, channel: Channel | None) -> str: @@ -46,7 +48,7 @@ def virtual_channels(cls) -> set[Channel]: B: "_ch_b", C: "_ch_c", } - __VIRTUAL_CHANNELS: set[Channel] = {AB} + __VIRTUAL_CHANNELS: set[Channel] = {AB, ABC} class TuyaEnergyDirection(t.enum1): @@ -56,25 +58,6 @@ class TuyaEnergyDirection(t.enum1): Reverse = 0x1 -class TuyaPowerPhase: - """Methods for extracting values from a Tuya Power Phase datapoint.""" - - @staticmethod - def voltage(value) -> t.uint_t: - """Return the voltage value.""" - return (value[0] << 8) | value[1] - - @staticmethod - def current(value) -> t.uint_t: - """Return the current value.""" - return (value[2] << 16) | (value[3] << 8) | value[4] - - @staticmethod - def power(value) -> int: - """Return the power value.""" - return (value[5] << 16) | (value[6] << 8) | value[7] * 10 - - class EnergyDirectionMitigation(t.enum8): """Enum type for energy direction mitigation attribute.""" @@ -87,7 +70,7 @@ class VirtualChannelConfig(t.enum8): """Enum type for virtual channel config attribute.""" none = 0 - A_plus_B = 1 + Total = 1 A_minus_B = 2 B_minus_A = 3 @@ -172,6 +155,10 @@ def get_config(self, attr_name: str, default: Any = None) -> Any: return None return cluster.get(attr_name, default) + def is_attr_type(self, attr_name: str, compare_type: Type) -> bool: + """Return True if the attribute type is a subclass of the compare type.""" + return issubclass(getattr(self.AttributeDefs, attr_name).type, compare_type) + @property def mcu_cluster(self) -> TuyaMCUCluster | None: """Return the MCU cluster.""" @@ -190,6 +177,11 @@ class EnergyDirectionHelper(MeterClusterHelper): UNSIGNED_ATTR_SUFFIX: Final = "_attr_unsigned" + def __init__(self, *args, **kwargs): + """Init.""" + self._energy_direction: TuyaEnergyDirection | None = None + super().__init__(*args, **kwargs) + def align_with_energy_direction(self, value: int | None) -> int | None: """Align the value with current energy_direction.""" if value and ( @@ -205,19 +197,33 @@ def align_with_energy_direction(self, value: int | None) -> int | None: def energy_direction(self) -> TuyaEnergyDirection | None: """Return the channel energy direction.""" if not self.mcu_cluster: - return None + return self._energy_direction try: return self.mcu_cluster.get( ENERGY_DIRECTION + Channel.attr_suffix(self.channel) ) except KeyError: - return None + return self._energy_direction def energy_direction_handler(self, attr_name: str, value) -> tuple[str, Any]: """Unsigned attributes are aligned with energy direction.""" + if attr_name not in self._EXTENSIVE_ATTRIBUTES or not self.is_attr_type( + attr_name, t.int_t + ): + return attr_name, value + + # unsigned attribtues have the energy direction applied if attr_name.endswith(self.UNSIGNED_ATTR_SUFFIX): attr_name = attr_name.removesuffix(self.UNSIGNED_ATTR_SUFFIX) value = self.align_with_energy_direction(value) + + # the cluster energy direction is updated (used in virtual channel calculations on devices with native signed values) + if value is not None: + self._energy_direction = ( + TuyaEnergyDirection.Reverse + if value < 0 + else TuyaEnergyDirection.Forward + ) return attr_name, value @@ -313,11 +319,16 @@ class VirtualChannelHelper(EnergyDirectionHelper, MeterClusterHelper): tuple[Channel, VirtualChannelConfig], tuple[tuple[Channel], Callable, Channel | None], ] = { - (Channel.AB, VirtualChannelConfig.A_plus_B): ( + (Channel.AB, VirtualChannelConfig.Total): ( (Channel.A, Channel.B), lambda a, b: a + b, Channel.B, ), + (Channel.ABC, VirtualChannelConfig.Total): ( + (Channel.A, Channel.B, Channel.C), + lambda a, b, c: a + b + c, + Channel.C, + ), (Channel.AB, VirtualChannelConfig.A_minus_B): ( (Channel.A, Channel.B), lambda a, b: a - b, @@ -377,10 +388,6 @@ def _device_virtual_channels(self) -> set[Channel]: self.endpoint.device.endpoints.keys() ) - def _is_attr_uint(self, attr_name: str) -> bool: - """Return True if the attribute type is an unsigned integer.""" - return issubclass(getattr(self.AttributeDefs, attr_name).type, t.uint_t) - def _get_source_values( self, attr_name: str, @@ -390,7 +397,8 @@ def _get_source_values( """Get source values from channel clusters.""" return tuple( cluster.align_with_energy_direction(cluster.get(attr_name)) - if align_uint_with_energy_direction and self._is_attr_uint(attr_name) + if align_uint_with_energy_direction + and self.is_attr_type(attr_name, t.uint_t) else cluster.get(attr_name) for channel in channels for cluster in [self.get_cluster(channel)] @@ -583,18 +591,18 @@ def update_attribute(self, attr_name: str, value): DPToAttributeMapping( ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, - converter=TuyaPowerPhase.voltage, + converter=PowerPhaseVariant3.voltage_dV, ), DPToAttributeMapping( ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, - converter=TuyaPowerPhase.current, + converter=PowerPhaseVariant3.current_mA, ), DPToAttributeMapping( ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.active_power.name + EnergyDirectionHelper.UNSIGNED_ATTR_SUFFIX, - converter=TuyaPowerPhase.power, + converter=lambda x: PowerPhaseVariant3.power_W(x) * 10, ), ], ) From b56613953b53f8aa8659f11613590c24368e474a Mon Sep 17 00:00:00 2001 From: Jack <46714706+jeverley@users.noreply.github.com> Date: Tue, 4 Feb 2025 16:55:23 +0000 Subject: [PATCH 11/13] Add 3 phase device support and tests --- tests/test_tuya_energy_meter.py | 53 +++- zhaquirks/tuya/__init__.py | 3 + zhaquirks/tuya/ts0601_energy_meter.py | 418 +++++++++++++++++++++----- 3 files changed, 382 insertions(+), 92 deletions(-) diff --git a/tests/test_tuya_energy_meter.py b/tests/test_tuya_energy_meter.py index 832c261bdb..528ff4c1af 100644 --- a/tests/test_tuya_energy_meter.py +++ b/tests/test_tuya_energy_meter.py @@ -15,7 +15,7 @@ @pytest.mark.parametrize( - "model,manuf,channels,bidirectional", + "model,manuf,channels,direction_attrs", [ ( "_TZE204_cjbofhxw", @@ -26,6 +26,8 @@ ("_TZE204_ac0fhfiq", "TS0601", {1}, True), ("_TZE200_rks0sgb7", "TS0601", {1, 2, 11}, True), ("_TZE204_81yrt3lo", "TS0601", {1, 2, 11}, True), + ("_TZE200_nslr42tt", "TS0601", {1, 2, 3, 10}, True), + ("_TZE204_v9hkz2yn", "TS0601", {1}, True), ], ) async def test_tuya_energy_meter_quirk_energy_direction_align( @@ -33,7 +35,7 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( model: str, manuf: str, channels, - bidirectional: bool, + direction_attrs: bool, ): """Test Tuya Energy Meter Quirk energy direction align in ElectricalMeasurement and Metering clusters.""" quirked_device = zigpy_device_from_v2_quirk(model, manuf) @@ -45,7 +47,10 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( CHANNEL_A = 1 CHANNEL_B = 2 + CHANNEL_C = 3 + CHANNEL_TOTAL = 10 CHANNEL_AB = 11 + CHANNEL_ABC = 12 UNSIGNED_ATTR_SUFFIX = "_attr_unsigned" @@ -53,8 +58,11 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( POWER = 100 VOLTAGE = 230 SUMM_RECEIVED = 15000 + DIRECTION_A = REVERSE DIRECTION_B = FORWARD + DIRECTION_C = FORWARD + DIRECTION_TOTAL = FORWARD ep = quirked_device.endpoints[1] @@ -80,7 +88,7 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( ), } - if bidirectional: + if direction_attrs: # verify the direction attribute is present attr = getattr(ep.tuya_manufacturer.AttributeDefs, ENERGY_DIRECTION_ATTR, None) assert attr is not None @@ -101,7 +109,7 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( ) assert attr is None - if bidirectional and CHANNEL_B in channels: + if direction_attrs and CHANNEL_B in channels: # verify the direction B attribute is present attr = getattr( ep.tuya_manufacturer.AttributeDefs, @@ -144,8 +152,12 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( direction = DIRECTION_A elif channel == CHANNEL_B: direction = DIRECTION_B - elif channel == CHANNEL_AB: - # updates to channel AB will occur as a result of the device updates to channels A & B + elif channel == CHANNEL_C: + direction = DIRECTION_C + elif channel == CHANNEL_TOTAL: + direction = DIRECTION_TOTAL + elif channel in (CHANNEL_AB, CHANNEL_ABC): + # virtual channel updates occur as a result of updates to their source channels continue assert direction is not None @@ -190,7 +202,7 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( assert ( listeners[channel]["electrical_measurement"].attribute_updates[2][1] == POWER - if not bidirectional or direction == FORWARD + if not direction_attrs or direction == FORWARD else -POWER ) assert ( @@ -222,7 +234,7 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( ) assert ( listeners[channel]["metering"].attribute_updates[0][1] == POWER - if not bidirectional or direction == FORWARD + if not direction_attrs or direction == FORWARD else -POWER ) assert ( @@ -275,9 +287,8 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( @pytest.mark.parametrize( "model,manuf,mitigation_config,basic_cluster_match", [ - ("_TZE204_cjbofhxw", "TS0601", 0, None), # Automatic - ("_TZE204_ac0fhfiq", "TS0601", 0, None), # Automatic - ("_TZE200_rks0sgb7", "TS0601", 1, None), # Disabled + ("_TZE204_81yrt3lo", "TS0601", 0, None), # Automatic + ("_TZE204_81yrt3lo", "TS0601", 1, None), # Disabled ("_TZE204_81yrt3lo", "TS0601", 2, None), # Enabled ( "_TZE204_81yrt3lo", @@ -289,6 +300,26 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( "stack_version": 0, }, ), + ( + "_TZE204_81yrt3lo", + "TS0601", + 1, # Disabled + { + "app_version": 74, + "hw_version": 1, + "stack_version": 0, + }, + ), + ( + "_TZE204_81yrt3lo", + "TS0601", + 2, # Enabled + { + "app_version": 74, + "hw_version": 1, + "stack_version": 0, + }, + ), ], ) async def test_tuya_energy_meter_quirk_energy_direction_delay_mitigation( diff --git a/zhaquirks/tuya/__init__.py b/zhaquirks/tuya/__init__.py index 87a588b153..2ab2025963 100644 --- a/zhaquirks/tuya/__init__.py +++ b/zhaquirks/tuya/__init__.py @@ -1081,6 +1081,7 @@ def handle_cluster_request( class PowerPhaseVariant1: """Methods for extracting values from a Tuya Power Phase datapoint. + 'TS0601': ( '_TZE204_ac0fhfiq', '_TZE200_qhlxve78', @@ -1101,6 +1102,7 @@ def current_mA(data: ByteString) -> t.uint_t: class PowerPhaseVariant2: """Methods for extracting values from a Tuya Power Phase datapoint. + 'TS0601': ( '_TZE200_lsanae15', '_TZE204_lsanae15' @@ -1128,6 +1130,7 @@ def power_W(data: ByteString) -> int: class PowerPhaseVariant3: """Methods for extracting values from a Tuya Power Phase datapoint. + 'TS0601': ( '_TZE204_ac0fhfiq', '_TZE200_qhlxve78', diff --git a/zhaquirks/tuya/ts0601_energy_meter.py b/zhaquirks/tuya/ts0601_energy_meter.py index 607ca30242..10b9f709b0 100644 --- a/zhaquirks/tuya/ts0601_energy_meter.py +++ b/zhaquirks/tuya/ts0601_energy_meter.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable -from typing import Any, Final, Type +from typing import Any, Final from zigpy.quirks.v2.homeassistant import PERCENTAGE, EntityType, UnitOfTime import zigpy.types as t @@ -14,6 +14,7 @@ from zhaquirks import LocalDataCluster from zhaquirks.tuya import ( DPToAttributeMapping, + PowerPhaseVariant2, PowerPhaseVariant3, TuyaLocalCluster, TuyaZBElectricalMeasurement, @@ -31,6 +32,8 @@ class Channel(t.enum8): A = 1 B = 2 C = 3 + + Total = 10 AB = 11 ABC = 12 @@ -86,7 +89,7 @@ class EnergyMeterConfiguration(LocalDataCluster): EnergyDirectionMitigation: Final = EnergyDirectionMitigation _ATTRIBUTE_DEFAULTS: tuple[str, Any] = { - "virtual_channel_config": VirtualChannelConfig.none, + "virtual_channel_config": VirtualChannelConfig.Total, "energy_direction_mitigation": EnergyDirectionMitigation.Automatic, } @@ -155,7 +158,7 @@ def get_config(self, attr_name: str, default: Any = None) -> Any: return None return cluster.get(attr_name, default) - def is_attr_type(self, attr_name: str, compare_type: Type) -> bool: + def is_attr_type(self, attr_name: str, compare_type: type) -> bool: """Return True if the attribute type is a subclass of the compare type.""" return issubclass(getattr(self.AttributeDefs, attr_name).type, compare_type) @@ -206,19 +209,17 @@ def energy_direction(self) -> TuyaEnergyDirection | None: return self._energy_direction def energy_direction_handler(self, attr_name: str, value) -> tuple[str, Any]: - """Unsigned attributes are aligned with energy direction.""" + """Unsigned device values are aligned with the energy direction.""" if attr_name not in self._EXTENSIVE_ATTRIBUTES or not self.is_attr_type( attr_name, t.int_t ): return attr_name, value - - # unsigned attribtues have the energy direction applied if attr_name.endswith(self.UNSIGNED_ATTR_SUFFIX): attr_name = attr_name.removesuffix(self.UNSIGNED_ATTR_SUFFIX) value = self.align_with_energy_direction(value) - # the cluster energy direction is updated (used in virtual channel calculations on devices with native signed values) if value is not None: + # _energy_direction used for virtual calculations on devices with native signed values self._energy_direction = ( TuyaEnergyDirection.Reverse if value < 0 @@ -413,15 +414,27 @@ class TuyaElectricalMeasurement( TuyaLocalCluster, TuyaZBElectricalMeasurement, ): - """ElectricalMeasurement cluster for Tuya energy meter devices.""" + """ElectricalMeasurement cluster for Tuya energy meter devices. + + Attribute units prior to cluster formatting: + Current: A * 1000 (milliampres) + Frequency: Hz * 100 + Power: W * 10 (deciwatt) + Voltage: V * 10 (decivolt) + """ + + AMPERE_MULTIPLIER = 1000 + HZ_MULTIPLIER = 100 + VOLT_MULTIPLIER = 10 + WATT_MULTIPLIER = 10 _CONSTANT_ATTRIBUTES: dict[int, Any] = { - **TuyaZBElectricalMeasurement._CONSTANT_ATTRIBUTES, - TuyaZBElectricalMeasurement.AttributeDefs.ac_frequency_divisor.id: 100, + **TuyaZBElectricalMeasurement._CONSTANT_ATTRIBUTES, # imports current divisor of 1000 + TuyaZBElectricalMeasurement.AttributeDefs.ac_frequency_divisor.id: 100, # 2 decimals TuyaZBElectricalMeasurement.AttributeDefs.ac_frequency_multiplier.id: 1, - TuyaZBElectricalMeasurement.AttributeDefs.ac_power_divisor.id: 10, + TuyaZBElectricalMeasurement.AttributeDefs.ac_power_divisor.id: 10, # 1 decimal TuyaZBElectricalMeasurement.AttributeDefs.ac_power_multiplier.id: 1, - TuyaZBElectricalMeasurement.AttributeDefs.ac_voltage_divisor.id: 10, + TuyaZBElectricalMeasurement.AttributeDefs.ac_voltage_divisor.id: 10, # 1 decimal TuyaZBElectricalMeasurement.AttributeDefs.ac_voltage_multiplier.id: 1, } @@ -492,6 +505,10 @@ class TuyaMetering( ): """Metering cluster for Tuya energy meter devices.""" + WATT_MULTIPLIER = 10 + WATT_HOUR_MULTIPLIER = 1000 + DECIWATT_HOUR_MULTIPLIER = 100 + @staticmethod def format( whole_digits: int, dec_digits: int, suppress_leading_zeros: bool = True @@ -505,7 +522,7 @@ def format( **TuyaZBMeteringClusterWithUnit._CONSTANT_ATTRIBUTES, TuyaZBMeteringClusterWithUnit.AttributeDefs.status.id: 0x00, TuyaZBMeteringClusterWithUnit.AttributeDefs.multiplier.id: 1, - TuyaZBMeteringClusterWithUnit.AttributeDefs.divisor.id: 10000, # 1 decimal place after conversion from kW to W + TuyaZBMeteringClusterWithUnit.AttributeDefs.divisor.id: 10000, # 2 decimals for summation attributes TuyaZBMeteringClusterWithUnit.AttributeDefs.summation_formatting.id: format( whole_digits=7, dec_digits=2 ), @@ -529,29 +546,30 @@ def update_attribute(self, attr_name: str, value): ( ### Tuya PJ-MGW1203 1 channel energy meter. TuyaQuirkBuilder("_TZE204_cjbofhxw", "TS0601") + .applies_to("_TZE284_cjbofhxw", "TS0601") .tuya_enchantment() .adds(EnergyMeterConfiguration) .adds(TuyaElectricalMeasurement) .adds(TuyaMetering) .tuya_dp( - dp_id=101, + dp_id=101, # Wh (watt/hour) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, - converter=lambda x: x * 10, + converter=lambda x: x * TuyaMetering.WATT_HOUR_MULTIPLIER, ) .tuya_dp( - dp_id=19, - ep_attribute=TuyaElectricalMeasurement.ep_attribute, - attribute_name=TuyaElectricalMeasurement.AttributeDefs.active_power.name, + dp_id=19, # W * 10 (deciwatt) + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.instantaneous_demand.name, ) .tuya_dp( - dp_id=18, + dp_id=18, # A * 1000 (milliampre) ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, endpoint_id=Channel.B, ) .tuya_dp( - dp_id=20, + dp_id=20, # V * 10 (decivolt) ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, ) @@ -560,30 +578,29 @@ def update_attribute(self, attr_name: str, value): ( - ### Tuya bidirectional 1 channel energy meter with Zigbee Green Power. + ### Tuya bidirectional 1 channel energy meter. TuyaQuirkBuilder("_TZE204_ac0fhfiq", "TS0601") .tuya_enchantment() .adds(EnergyMeterConfiguration) .adds(TuyaElectricalMeasurement) .adds(TuyaMetering) .tuya_dp( - dp_id=101, + dp_id=1, # Wh * 10 (deciwatt/hour) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, - converter=lambda x: x * 100, + converter=lambda x: x * TuyaMetering.DECIWATT_HOUR_MULTIPLIER, ) .tuya_dp( - dp_id=102, + dp_id=2, # Wh * 10 (deciwatt/hour) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.current_summ_received.name, - converter=lambda x: x * 100, + converter=lambda x: x * TuyaMetering.DECIWATT_HOUR_MULTIPLIER, ) .tuya_dp( - dp_id=108, + dp_id=101, # deciwatt? ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.instantaneous_demand.name + EnergyDirectionHelper.UNSIGNED_ATTR_SUFFIX, - converter=lambda x: x * 10, ) .tuya_dp_multi( dp_id=6, @@ -591,23 +608,24 @@ def update_attribute(self, attr_name: str, value): DPToAttributeMapping( ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, - converter=PowerPhaseVariant3.voltage_dV, + converter=PowerPhaseVariant3.voltage_dV, # V * 10 (decivolt) ), DPToAttributeMapping( ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, - converter=PowerPhaseVariant3.current_mA, + converter=PowerPhaseVariant3.current_mA, # A * 1000 (milliampre) ), DPToAttributeMapping( ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.active_power.name + EnergyDirectionHelper.UNSIGNED_ATTR_SUFFIX, - converter=lambda x: PowerPhaseVariant3.power_W(x) * 10, + converter=lambda x: PowerPhaseVariant3.power_W(x) + * TuyaElectricalMeasurement.WATT_MULTIPLIER, # W (watt) ), ], ) .tuya_dp_attribute( - dp_id=102, + dp_id=102, # 0=Forward/1=Reverse attribute_name=ENERGY_DIRECTION, type=TuyaEnergyDirection, converter=lambda x: TuyaEnergyDirection(x), @@ -617,7 +635,7 @@ def update_attribute(self, attr_name: str, value): ( - ### EARU Tuya 2 channel bidirectional energy meter manufacturer cluster. + ### EARU Tuya 2 channel bidirectional energy meter. TuyaQuirkBuilder("_TZE200_rks0sgb7", "TS0601") .tuya_enchantment() .adds_endpoint(Channel.B) @@ -638,88 +656,91 @@ def update_attribute(self, attr_name: str, value): fallback_name="Virtual channel", ) .tuya_dp( - dp_id=113, + dp_id=113, # Hz ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.ac_frequency.name, + converter=lambda x: x * TuyaElectricalMeasurement.HZ_MULTIPLIER, ) .tuya_dp( - dp_id=101, + dp_id=101, # Wh * 10 (deciwatt/hour) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, - converter=lambda x: x * 100, + converter=lambda x: x * TuyaMetering.DECIWATT_HOUR_MULTIPLIER, ) .tuya_dp( - dp_id=103, + dp_id=103, # Wh * 10 (deciwatt/hour) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, - converter=lambda x: x * 100, + converter=lambda x: x * TuyaMetering.DECIWATT_HOUR_MULTIPLIER, endpoint_id=Channel.B, ) .tuya_dp( - dp_id=102, + dp_id=102, # Wh * 10 (deciwatt/hour) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.current_summ_received.name, - converter=lambda x: x * 100, + converter=lambda x: x * TuyaMetering.DECIWATT_HOUR_MULTIPLIER, ) .tuya_dp( - dp_id=104, + dp_id=104, # Wh * 10 (deciwatt/hour) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.current_summ_received.name, - converter=lambda x: x * 100, + converter=lambda x: x * TuyaMetering.DECIWATT_HOUR_MULTIPLIER, endpoint_id=Channel.B, ) .tuya_dp( - dp_id=108, + dp_id=108, # W (watt) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.instantaneous_demand.name, + converter=lambda x: x * TuyaMetering.WATT_MULTIPLIER, ) .tuya_dp( - dp_id=111, + dp_id=111, # W (watt) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.instantaneous_demand.name, + converter=lambda x: x * TuyaMetering.WATT_MULTIPLIER, endpoint_id=Channel.B, ) .tuya_dp( - dp_id=109, + dp_id=109, # % (power factor) ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.power_factor.name, ) .tuya_dp( - dp_id=112, + dp_id=112, # % (power factor) ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.power_factor.name, endpoint_id=Channel.B, ) .tuya_dp( - dp_id=107, + dp_id=107, # A * 1000 (milliampre) ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, ) .tuya_dp( - dp_id=110, + dp_id=110, # A * 1000 (milliampre) ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, endpoint_id=Channel.B, ) .tuya_dp( - dp_id=106, + dp_id=106, # V * 10 (decivolt) ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, ) .tuya_dp_attribute( - dp_id=114, + dp_id=114, # 0=Forward/1=Reverse attribute_name=ENERGY_DIRECTION, type=TuyaEnergyDirection, converter=lambda x: TuyaEnergyDirection(x), ) .tuya_dp_attribute( - dp_id=115, + dp_id=115, # 0=Forward/1=Reverse attribute_name=ENERGY_DIRECTION + Channel.attr_suffix(Channel.B), type=TuyaEnergyDirection, converter=lambda x: TuyaEnergyDirection(x), ) .tuya_number( - dp_id=116, + dp_id=116, # seconds attribute_name="reporting_interval", type=t.uint32_t_be, unit=UnitOfTime.SECONDS, @@ -735,8 +756,9 @@ def update_attribute(self, attr_name: str, value): ( - ### MatSee Plus Tuya PJ-1203A 2 channel bidirectional energy meter with Zigbee Green Power. + ### MatSee Plus Tuya PJ-1203A 2 channel bidirectional energy meter. TuyaQuirkBuilder("_TZE204_81yrt3lo", "TS0601") + .applies_to("_TZE284_81yrt3lo", "TS0601") .tuya_enchantment() .adds_endpoint(Channel.B) .adds_endpoint(Channel.AB) @@ -764,90 +786,90 @@ def update_attribute(self, attr_name: str, value): fallback_name="Energy direction delay mitigation", ) .tuya_dp( - dp_id=111, + dp_id=111, # Hz * 100 ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.ac_frequency.name, ) .tuya_dp( - dp_id=106, + dp_id=106, # Wh * 10 (deciwatt/hour) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, - converter=lambda x: x * 100, + converter=lambda x: x * TuyaMetering.DECIWATT_HOUR_MULTIPLIER, ) .tuya_dp( - dp_id=108, + dp_id=108, # Wh * 10 (deciwatt/hour) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, - converter=lambda x: x * 100, + converter=lambda x: x * TuyaMetering.DECIWATT_HOUR_MULTIPLIER, endpoint_id=Channel.B, ) .tuya_dp( - dp_id=107, + dp_id=107, # Wh * 10 (deciwatt/hour) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.current_summ_received.name, - converter=lambda x: x * 100, + converter=lambda x: x * TuyaMetering.DECIWATT_HOUR_MULTIPLIER, ) .tuya_dp( - dp_id=109, + dp_id=109, # Wh * 10 (deciwatt/hour) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.current_summ_received.name, - converter=lambda x: x * 100, + converter=lambda x: x * TuyaMetering.DECIWATT_HOUR_MULTIPLIER, endpoint_id=Channel.B, ) .tuya_dp( - dp_id=101, + dp_id=101, # W * 10 (deciwatt) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.instantaneous_demand.name + EnergyDirectionHelper.UNSIGNED_ATTR_SUFFIX, ) .tuya_dp( - dp_id=105, + dp_id=105, # W * 10 (deciwatt) ep_attribute=TuyaMetering.ep_attribute, attribute_name=TuyaMetering.AttributeDefs.instantaneous_demand.name + EnergyDirectionHelper.UNSIGNED_ATTR_SUFFIX, endpoint_id=Channel.B, ) .tuya_dp( - dp_id=110, + dp_id=110, # % (power factor) ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.power_factor.name, ) .tuya_dp( - dp_id=121, + dp_id=121, # % (power factor) ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.power_factor.name, endpoint_id=Channel.B, ) .tuya_dp( - dp_id=113, + dp_id=113, # A * 1000 (milliampre) ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, ) .tuya_dp( - dp_id=114, + dp_id=114, # A * 1000 (milliampre) ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, endpoint_id=Channel.B, ) .tuya_dp( - dp_id=112, + dp_id=112, # V * 10 (decivolt) ep_attribute=TuyaElectricalMeasurement.ep_attribute, attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, ) .tuya_dp_attribute( - dp_id=102, + dp_id=102, # 0=Forward/1=Reverse attribute_name=ENERGY_DIRECTION, type=TuyaEnergyDirection, converter=lambda x: TuyaEnergyDirection(x), ) .tuya_dp_attribute( - dp_id=104, + dp_id=104, # 0=Forward/1=Reverse attribute_name=ENERGY_DIRECTION + Channel.attr_suffix(Channel.B), type=TuyaEnergyDirection, converter=lambda x: TuyaEnergyDirection(x), ) .tuya_number( - dp_id=129, + dp_id=129, # seconds attribute_name="reporting_interval", type=t.uint32_t_be, unit=UnitOfTime.SECONDS, @@ -859,7 +881,7 @@ def update_attribute(self, attr_name: str, value): entity_type=EntityType.CONFIG, ) .tuya_number( - dp_id=122, + dp_id=122, # % * 10 (1 decimal precision) attribute_name="ac_frequency_coefficient", type=t.uint32_t_be, unit=PERCENTAGE, @@ -873,7 +895,7 @@ def update_attribute(self, attr_name: str, value): initially_disabled=True, ) .tuya_number( - dp_id=119, + dp_id=119, # % * 10 (1 decimal precision) attribute_name="current_summ_delivered_coefficient", type=t.uint32_t_be, unit=PERCENTAGE, @@ -887,7 +909,7 @@ def update_attribute(self, attr_name: str, value): initially_disabled=True, ) .tuya_number( - dp_id=125, + dp_id=125, # % * 10 (1 decimal precision) attribute_name="current_summ_delivered_coefficient" + Channel.attr_suffix(Channel.B), type=t.uint32_t_be, @@ -902,7 +924,7 @@ def update_attribute(self, attr_name: str, value): initially_disabled=True, ) .tuya_number( - dp_id=127, + dp_id=127, # % * 10 (1 decimal precision) attribute_name="current_summ_received_coefficient", type=t.uint32_t_be, unit=PERCENTAGE, @@ -916,7 +938,7 @@ def update_attribute(self, attr_name: str, value): initially_disabled=True, ) .tuya_number( - dp_id=128, + dp_id=128, # % * 10 (1 decimal precision) attribute_name="current_summ_received_coefficient" + Channel.attr_suffix(Channel.B), type=t.uint32_t_be, @@ -931,7 +953,7 @@ def update_attribute(self, attr_name: str, value): initially_disabled=True, ) .tuya_number( - dp_id=118, + dp_id=118, # % * 10 (1 decimal precision) attribute_name="instantaneous_demand_coefficient", type=t.uint32_t_be, unit=PERCENTAGE, @@ -945,7 +967,7 @@ def update_attribute(self, attr_name: str, value): initially_disabled=True, ) .tuya_number( - dp_id=124, + dp_id=124, # % * 10 (1 decimal precision) attribute_name="instantaneous_demand_coefficient" + Channel.attr_suffix(Channel.B), type=t.uint32_t_be, @@ -960,7 +982,7 @@ def update_attribute(self, attr_name: str, value): initially_disabled=True, ) .tuya_number( - dp_id=117, + dp_id=117, # % * 10 (1 decimal precision) attribute_name="rms_current_coefficient", type=t.uint32_t_be, unit=PERCENTAGE, @@ -974,7 +996,7 @@ def update_attribute(self, attr_name: str, value): initially_disabled=True, ) .tuya_number( - dp_id=123, + dp_id=123, # % * 10 (1 decimal precision) attribute_name="rms_current_coefficient" + Channel.attr_suffix(Channel.B), type=t.uint32_t_be, unit=PERCENTAGE, @@ -988,7 +1010,7 @@ def update_attribute(self, attr_name: str, value): initially_disabled=True, ) .tuya_number( - dp_id=116, + dp_id=116, # % * 10 (1 decimal precision) attribute_name="rms_voltage_coefficient", type=t.uint32_t_be, unit=PERCENTAGE, @@ -1003,3 +1025,237 @@ def update_attribute(self, attr_name: str, value): ) .add_to_registry() ) + + +( + ### Tuya PC321-Z-TY 3 phase energy meter. + TuyaQuirkBuilder("_TZE200_nslr42tt", "TS0601") + .tuya_enchantment() + .adds_endpoint(Channel.B) + .adds_endpoint(Channel.C) + .adds_endpoint(Channel.Total) + .adds(EnergyMeterConfiguration) + .adds(TuyaElectricalMeasurement) + .adds(TuyaElectricalMeasurement, endpoint_id=Channel.Total) + .adds(TuyaMetering) + .adds(TuyaMetering, endpoint_id=Channel.B) + .adds(TuyaMetering, endpoint_id=Channel.C) + .adds(TuyaMetering, endpoint_id=Channel.Total) + .tuya_temperature(dp_id=133, scale=10) + .tuya_sensor( + dp_id=134, + attribute_name="device_status", + type=t.int32s, + translation_key="device_status", + fallback_name="Device status", + ) + .tuya_dp( + dp_id=101, # Wh (watt/hour) + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, + converter=lambda x: x * TuyaMetering.WATT_HOUR_MULTIPLIER, + ) + .tuya_dp( + dp_id=111, # Wh (watt/hour) + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, + converter=lambda x: x * TuyaMetering.WATT_HOUR_MULTIPLIER, + endpoint_id=Channel.B, + ) + .tuya_dp( + dp_id=121, # Wh (watt/hour) + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, + converter=lambda x: x * TuyaMetering.WATT_HOUR_MULTIPLIER, + endpoint_id=Channel.C, + ) + .tuya_dp( + dp_id=1, # Wh * 10 (deciwatt/hour) + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, + converter=lambda x: x * TuyaMetering.DECIWATT_HOUR_MULTIPLIER, + endpoint_id=Channel.Total, + ) + .tuya_dp( + dp_id=9, # W (watt) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.total_active_power.name, + converter=lambda x: x * TuyaElectricalMeasurement.WATT_MULTIPLIER, + ) + .tuya_dp( + dp_id=131, # A * 1000 (milliampre) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, + endpoint_id=Channel.Total, + ) + .tuya_dp_multi( + dp_id=6, + attribute_mapping=[ + DPToAttributeMapping( + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.active_power.name, + converter=lambda x: PowerPhaseVariant3.power_W(x) + * TuyaElectricalMeasurement.WATT_MULTIPLIER, # W (watt) + ), + DPToAttributeMapping( + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_voltage.name, + converter=PowerPhaseVariant2.voltage_dV, # V * 10 (decivolt) + ), + DPToAttributeMapping( + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, + converter=PowerPhaseVariant2.current_mA, # A * 1000 (milliampre) + ), + ], + ) + .tuya_dp_multi( + dp_id=7, + attribute_mapping=[ + DPToAttributeMapping( + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.active_power_ph_b.name, + converter=lambda x: PowerPhaseVariant3.power_W(x) + * TuyaElectricalMeasurement.WATT_MULTIPLIER, # W (watt) + ), + DPToAttributeMapping( + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_voltage_ph_b.name, + converter=PowerPhaseVariant2.voltage_dV, # V * 10 (decivolt) + ), + DPToAttributeMapping( + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current_ph_b.name, + converter=PowerPhaseVariant2.current_mA, # A * 1000 (milliampre) + ), + ], + ) + .tuya_dp_multi( + dp_id=7, + attribute_mapping=[ + DPToAttributeMapping( + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.active_power_ph_c.name, + converter=lambda x: PowerPhaseVariant3.power_W(x) + * TuyaElectricalMeasurement.WATT_MULTIPLIER, # W (watt) + ), + DPToAttributeMapping( + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_voltage_ph_c.name, + converter=PowerPhaseVariant2.voltage_dV, # V * 10 (decivolt) + ), + DPToAttributeMapping( + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current_ph_c.name, + converter=PowerPhaseVariant2.current_mA, # A * 1000 (milliampre) + ), + ], + ) + .tuya_dp( + dp_id=102, # % (power factor) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.power_factor.name, + ) + .tuya_dp( + dp_id=112, # % (power factor) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.power_factor_ph_b.name, + ) + .tuya_dp( + dp_id=122, # % (power factor) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.power_factor_ph_c.name, + ) + .skip_configuration() + .add_to_registry() +) + + +( + ### Tuya PC321-Z-TY 3 phase energy meter. + TuyaQuirkBuilder("_TZE204_v9hkz2yn", "TS0601") + .applies_to("_TZE200_v9hkz2yn", "TS0601") + .tuya_enchantment() + .adds(EnergyMeterConfiguration) + .adds(TuyaElectricalMeasurement) + .adds(TuyaMetering) + .tuya_dp( + dp_id=1, # Wh * 10 (deciwatt/hour) + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_delivered.name, + converter=lambda x: x * TuyaMetering.DECIWATT_HOUR_MULTIPLIER, + ) + .tuya_dp( + dp_id=2, # Wh * 10 (deciwatt/hour) + ep_attribute=TuyaMetering.ep_attribute, + attribute_name=TuyaMetering.AttributeDefs.current_summ_received.name, + converter=lambda x: x * TuyaMetering.DECIWATT_HOUR_MULTIPLIER, + ) + .tuya_dp( + dp_id=15, # % (power factor) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.power_factor.name, + ) + .tuya_dp( + dp_id=101, # Hz * 100 + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.ac_frequency.name, + ) + .tuya_dp( + dp_id=102, # V * 10 (decivolt) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.ac_frequency.name, + ) + .tuya_dp( + dp_id=103, # A * 1000 (milliampre) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current.name, + ) + .tuya_dp( + dp_id=104, # W (watt) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.active_power.name, + converter=lambda x: x * TuyaElectricalMeasurement.WATT_MULTIPLIER, + ) + .tuya_dp( + dp_id=105, # V * 10 (decivolt) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.ac_frequency_ph_b.name, + ) + .tuya_dp( + dp_id=106, # A * 1000 (milliampre) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current_ph_b.name, + ) + .tuya_dp( + dp_id=107, # W (watt) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.active_power_ph_b.name, + converter=lambda x: x * TuyaElectricalMeasurement.WATT_MULTIPLIER, + ) + .tuya_dp( + dp_id=108, # V * 10 (decivolt) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.ac_frequency_ph_c.name, + ) + .tuya_dp( + dp_id=109, # A * 1000 (milliampre) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.rms_current_ph_c.name, + endpoint_id=Channel.Total, + ) + .tuya_dp( + dp_id=110, # W (watt) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.active_power_ph_c.name, + converter=lambda x: x * TuyaElectricalMeasurement.WATT_MULTIPLIER, + ) + .tuya_dp( + dp_id=111, # W (watt) + ep_attribute=TuyaElectricalMeasurement.ep_attribute, + attribute_name=TuyaElectricalMeasurement.AttributeDefs.total_active_power.name, + converter=lambda x: x * TuyaElectricalMeasurement.WATT_MULTIPLIER, + ) + .skip_configuration() + .add_to_registry() +) From c5a7e7d00726a27987adb56a2d6c3fcdee4eb33a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Feb 2025 18:13:34 +0000 Subject: [PATCH 12/13] Apply pre-commit auto fixes --- zhaquirks/tuya/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zhaquirks/tuya/__init__.py b/zhaquirks/tuya/__init__.py index 2ab2025963..dd016d2a4a 100644 --- a/zhaquirks/tuya/__init__.py +++ b/zhaquirks/tuya/__init__.py @@ -1130,7 +1130,7 @@ def power_W(data: ByteString) -> int: class PowerPhaseVariant3: """Methods for extracting values from a Tuya Power Phase datapoint. - + 'TS0601': ( '_TZE204_ac0fhfiq', '_TZE200_qhlxve78', From f65dfadab638ae0846e28f2264a2c9cf08ca254d Mon Sep 17 00:00:00 2001 From: jeverley <46714706+jeverley@users.noreply.github.com> Date: Tue, 4 Feb 2025 18:47:43 +0000 Subject: [PATCH 13/13] Update test parameters for 3 phase --- tests/test_tuya_energy_meter.py | 35 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/tests/test_tuya_energy_meter.py b/tests/test_tuya_energy_meter.py index 528ff4c1af..8c5b4f0f6d 100644 --- a/tests/test_tuya_energy_meter.py +++ b/tests/test_tuya_energy_meter.py @@ -14,6 +14,21 @@ zhaquirks.setup() +ENERGY_DIRECTION_ATTR = "energy_direction" +ENERGY_DIRECTION_ATTR_B = "energy_direction_ch_b" +FORWARD = 0 +REVERSE = 1 + +CHANNEL_A = 1 +CHANNEL_B = 2 +CHANNEL_C = 3 +CHANNEL_TOTAL = 10 +CHANNEL_AB = 11 +CHANNEL_ABC = 12 + +UNSIGNED_ATTR_SUFFIX = "_attr_unsigned" + + @pytest.mark.parametrize( "model,manuf,channels,direction_attrs", [ @@ -26,8 +41,8 @@ ("_TZE204_ac0fhfiq", "TS0601", {1}, True), ("_TZE200_rks0sgb7", "TS0601", {1, 2, 11}, True), ("_TZE204_81yrt3lo", "TS0601", {1, 2, 11}, True), - ("_TZE200_nslr42tt", "TS0601", {1, 2, 3, 10}, True), - ("_TZE204_v9hkz2yn", "TS0601", {1}, True), + ("_TZE200_nslr42tt", "TS0601", {1, 2, 3, 10}, False), + ("_TZE204_v9hkz2yn", "TS0601", {1}, False), ], ) async def test_tuya_energy_meter_quirk_energy_direction_align( @@ -40,20 +55,6 @@ async def test_tuya_energy_meter_quirk_energy_direction_align( """Test Tuya Energy Meter Quirk energy direction align in ElectricalMeasurement and Metering clusters.""" quirked_device = zigpy_device_from_v2_quirk(model, manuf) - ENERGY_DIRECTION_ATTR = "energy_direction" - ENERGY_DIRECTION_ATTR_B = "energy_direction_ch_b" - FORWARD = 0 - REVERSE = 1 - - CHANNEL_A = 1 - CHANNEL_B = 2 - CHANNEL_C = 3 - CHANNEL_TOTAL = 10 - CHANNEL_AB = 11 - CHANNEL_ABC = 12 - - UNSIGNED_ATTR_SUFFIX = "_attr_unsigned" - CURRENT = 5 POWER = 100 VOLTAGE = 230 @@ -332,8 +333,6 @@ async def test_tuya_energy_meter_quirk_energy_direction_delay_mitigation( """Test Tuya Energy Meter Quirk energy direction report mitigation.""" quirked_device = zigpy_device_from_v2_quirk(model, manuf) - UNSIGNED_ATTR_SUFFIX = "_attr_unsigned" - POWER_1 = 100 POWER_2 = 200 POWER_3 = 300