Skip to content

Commit

Permalink
Fix power measurement for Aqara MAEU01 plugs with newer firmware (#1656)
Browse files Browse the repository at this point in the history
* Add alternative Xiaomi MMEU01 plug signatures

* Improve custom Xiaomi ElectricalMeasurementCluster
- add lumi.plug.maeu01
- put default values for sensors
- switch to report voltage on rms_voltage instead of wrongly used instantaneous_voltage attribute
- set ac_power_divisor to 10 and multiple power_reported value to workaround a rounding error in ZHA

* Use custom Xiaomi ElectricalMeasurementCluster, BasicCluter for MAEU01 plug

* Rename quirk file to plug_eu.py

* Temporary test changes

* Add custom MeteringCluster for "current summation delivered" sensor in HA

* Fix docs

* Add OppleCluster to mmeu01, add alternative mmeu01 signatures, remove skip_config

* Simplify common signatures

* Change endpoint 21 replacement to more likely match actual signature (instead of US model)

* Remove stale import

* Simplify MAEU01 replacement to use MMEU01 replacement

* Add power_outage_memory

* Explicitly enable "OppleMode" now (set to 1 instead of 0)

On v41 firmware, this attribute doesn't exist anymore. The plug behaves like it's set to "1"
On v32 firmware, this attribute exists. Now, that we have a proper quirk that parses Xiaomi attributes, we want this set to "1" (enabled). This causes older firmware plugs to behave like newer plugs always do.

* Add some comments

* Add small test to validate OppleMode gets enabled on binding

* Remove stale comment

* Add Xiaomi EU plug current power consumption, total power consumption, and voltage test

* Also remove Xiaomi MMEU01 plug from group 0

The MMEU01 plug also has newer firmware versions (like the MAEU01) which causes them to trigger when a command gets sent to group 0. Although remotes routing through this plug will still control it, removing it from group 0 seems to help.

* Use create_catching_task from zigpy instead of asyncio.create_task

* Remove plug from group 0 when binding OppleCluster, update tests

* Use catching task for writing OppleMode attribute, update test

Writing the OppleMode, for some reason, always times out but still writes the attribute successfully.
  • Loading branch information
TheJulianJES authored Oct 17, 2022
1 parent 3ba3124 commit 8149efd
Show file tree
Hide file tree
Showing 3 changed files with 258 additions and 41 deletions.
82 changes: 82 additions & 0 deletions tests/test_xiaomi.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
ZONE_STATE,
)
from zhaquirks.xiaomi import (
CONSUMPTION_REPORTED,
LUMI,
POWER_REPORTED,
VOLTAGE_REPORTED,
XIAOMI_NODE_DESC,
BasicCluster,
XiaomiCustomDevice,
Expand All @@ -31,6 +34,7 @@
)
import zhaquirks.xiaomi.aqara.motion_aq2
import zhaquirks.xiaomi.aqara.motion_aq2b
import zhaquirks.xiaomi.aqara.plug_eu
import zhaquirks.xiaomi.mija.motion

from tests.common import ZCL_OCC_ATTR_RPT_OCC, ClusterListener
Expand Down Expand Up @@ -426,3 +430,81 @@ def test_attribute_parsing(raw_report):
# The only remaining data should be the data type and the length.
# Everything else is passed through unmodified.
assert len(raw_report) == 2 * len(reports[0])


@mock.patch("zigpy.zcl.Cluster.bind", mock.AsyncMock())
@pytest.mark.parametrize("quirk", (zhaquirks.xiaomi.aqara.plug_eu.PlugMAEU01,))
async def test_xiaomi_eu_plug_binding(zigpy_device_from_quirk, quirk):
"""Test binding Xiaomi EU plug sets OppleMode to True and removes the plug from group 0."""

device = zigpy_device_from_quirk(quirk)
opple_cluster = device.endpoints[1].opple_cluster

p1 = mock.patch.object(opple_cluster, "create_catching_task")
p2 = mock.patch.object(opple_cluster.endpoint, "request", mock.AsyncMock())

with p1 as mock_task, p2 as request_mock:
request_mock.return_value = (foundation.Status.SUCCESS, "done")

await opple_cluster.bind()

# Only removed the plug from group 0 so far
assert len(request_mock.mock_calls) == 1
assert mock_task.call_count == 1

assert request_mock.mock_calls[0][1] == (
4,
1,
b"\x01\x01\x03\x00\x00",
)

# Await call writing OppleMode attribute
await mock_task.call_args[0][0]

assert len(request_mock.mock_calls) == 2
assert request_mock.mock_calls[1][1] == (
64704,
2,
b"\x04_\x11\x02\x02\t\x00 \x01",
)


@pytest.mark.parametrize("quirk", (zhaquirks.xiaomi.aqara.plug_eu.PlugMAEU01,))
async def test_xiaomi_eu_plug_power(zigpy_device_from_quirk, quirk):
"""Test current power consumption, total power consumption, and current voltage on Xiaomi EU plug."""

device = zigpy_device_from_quirk(quirk)

em_cluster = device.endpoints[1].electrical_measurement
em_listener = ClusterListener(em_cluster)

# Test voltage on ElectricalMeasurement cluster
em_cluster.endpoint.device.voltage_bus.listener_event(VOLTAGE_REPORTED, 230)
assert len(em_listener.attribute_updates) == 1
assert em_listener.attribute_updates[0][0] == 1285
assert em_listener.attribute_updates[0][1] == 230

# Test current power consumption on ElectricalMeasurement cluster
em_cluster.endpoint.device.power_bus.listener_event(POWER_REPORTED, 15)
assert len(em_listener.attribute_updates) == 2
assert em_listener.attribute_updates[1][0] == 1291
assert em_listener.attribute_updates[1][1] == 150 # multiplied by 10

# Test total power consumption on ElectricalMeasurement cluster
em_cluster.endpoint.device.consumption_bus.listener_event(
CONSUMPTION_REPORTED, 0.001
)
assert len(em_listener.attribute_updates) == 3
assert em_listener.attribute_updates[2][0] == 772
assert em_listener.attribute_updates[2][1] == 1 # multiplied by 1000

# Test total power consumption on SmartEnergy cluster
se_cluster = device.endpoints[1].smartenergy_metering
se_listener = ClusterListener(se_cluster)

se_cluster.endpoint.device.consumption_bus.listener_event(
CONSUMPTION_REPORTED, 0.001
)
assert len(se_listener.attribute_updates) == 1
assert se_listener.attribute_updates[0][0] == 0
assert se_listener.attribute_updates[0][1] == 1 # multiplied by 1000
45 changes: 41 additions & 4 deletions zhaquirks/xiaomi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,8 @@ def _parse_aqara_attributes(self, value):
)
elif self.endpoint.device.model in [
"lumi.plug.maus01",
"lumi.plug.maeu01",
"lumi.plug.mmeu01",
"lumi.relay.c2acn01",
]:
attribute_names.update({149: CONSUMPTION, 150: VOLTAGE, 152: POWER})
Expand Down Expand Up @@ -552,13 +554,13 @@ class ElectricalMeasurementCluster(LocalDataCluster, ElectricalMeasurement):

cluster_id = ElectricalMeasurement.cluster_id
POWER_ID = 0x050B
VOLTAGE_ID = 0x0500
VOLTAGE_ID = 0x0505
CONSUMPTION_ID = 0x0304
_CONSTANT_ATTRIBUTES = {
0x0402: 1, # power_multiplier
0x0403: 1, # power_divisor
0x0604: 1, # ac_power_multiplier
0x0605: 1, # ac_power_divisor
0x0605: 10, # ac_power_divisor
}

def __init__(self, *args, **kwargs):
Expand All @@ -568,17 +570,52 @@ def __init__(self, *args, **kwargs):
self.endpoint.device.consumption_bus.add_listener(self)
self.endpoint.device.power_bus.add_listener(self)

# put a default value so the sensors are created
if self.POWER_ID not in self._attr_cache:
self._update_attribute(self.POWER_ID, 0)
if self.VOLTAGE_ID not in self._attr_cache:
self._update_attribute(self.VOLTAGE_ID, 0)
if self.CONSUMPTION_ID not in self._attr_cache:
self._update_attribute(self.CONSUMPTION_ID, 0)

def power_reported(self, value):
"""Power reported."""
self._update_attribute(self.POWER_ID, value)
self._update_attribute(self.POWER_ID, value * 10)

This comment has been minimized.

Copy link
@hakong

hakong Feb 6, 2023

This is wrong for LLKZMK11LM (lumi.relay.c2acn01). It's showing 103W for a 10W device.


def voltage_reported(self, value):
"""Voltage reported."""
self._update_attribute(self.VOLTAGE_ID, value)

def consumption_reported(self, value):
"""Consumption reported."""
self._update_attribute(self.CONSUMPTION_ID, value)
self._update_attribute(self.CONSUMPTION_ID, value * 1000)


class MeteringCluster(LocalDataCluster, Metering):
"""Metering cluster to receive reports that are sent to the basic cluster."""

cluster_id = Metering.cluster_id
CURRENT_SUMM_DELIVERED_ID = 0x0000
_CONSTANT_ATTRIBUTES = {
0x0300: 0, # unit_of_measure: kWh
0x0301: 1, # multiplier
0x0302: 1000, # divisor
0x0303: 0b0_0100_011, # summation_formatting (read from plug)
0x0306: 0, # metering_device_type: electric
}

def __init__(self, *args, **kwargs):
"""Init."""
super().__init__(*args, **kwargs)
self.endpoint.device.consumption_bus.add_listener(self)

# put a default value so the sensor is created
if self.CURRENT_SUMM_DELIVERED_ID not in self._attr_cache:
self._update_attribute(self.CURRENT_SUMM_DELIVERED_ID, 0)

def consumption_reported(self, value):
"""Consumption reported."""
self._update_attribute(self.CURRENT_SUMM_DELIVERED_ID, value * 1000)


class IlluminanceMeasurementCluster(CustomCluster, IlluminanceMeasurement):
Expand Down
Loading

0 comments on commit 8149efd

Please sign in to comment.