Repository: mill1000/midea-msmart (python)

Issue:
Format of real time power data is device dependent
It appears that the real time power (and maybe energy consumption) data can come in different data formats and scales.

This was always a risk based on the code in georgezhao2010/midea_ac_lan and the variability between the plugin and Lua.

This user reports needing a scaling of 1000 on the power data: https://github.com/mill1000/midea-ac-py/issues/194

This user needs the data to be processed as binary instead of BCD: https://github.com/mill1000/midea-msmart/pull/116#issuecomment-2241536091

Pull request description:
Add alternate energy/power response parsing
Parse energy data as both BCD and binary.

Add an option to the device class to switch between the 2.

Close #154

Patch:
diff --git a/msmart/device/AC/command.py b/msmart/device/AC/command.py
index 0a7396e..8040108 100644
--- a/msmart/device/AC/command.py
+++ b/msmart/device/AC/command.py
@@ -5,7 +5,7 @@ import math
 import struct
 from collections import namedtuple
 from enum import IntEnum
-from typing import Any, Callable, Collection, Mapping, Optional, Union
+from typing import Any, Callable, Collection, Mapping, Optional, Tuple, Union
 
 import msmart.crc8 as crc8
 from msmart.const import DeviceType, FrameType
@@ -1004,6 +1004,10 @@ class EnergyUsageResponse(Response):
         self.current_energy = None
         self.real_time_power = None
 
+        self.total_energy_binary = None
+        self.current_energy_binary = None
+        self.real_time_power_binary = None
+
         _LOGGER.debug("Energy response payload: %s", payload.hex())
 
         self._parse(payload)
@@ -1015,35 +1019,54 @@ class EnergyUsageResponse(Response):
         def decode_bcd(d: int) -> int:
             return 10 * (d >> 4) + (d & 0xF)
 
+        def parse_energy(d: bytes) -> Tuple[float, float]:
+            bcd = (10000 * decode_bcd(d[0]) +
+                   100 * decode_bcd(d[1]) +
+                   1 * decode_bcd(d[2]) +
+                   0.01 * decode_bcd(d[3]))
+            binary = ((d[0] << 24) +
+                      (d[1] << 16) +
+                      (d[2] << 8) +
+                      d[3]) / 10
+            return bcd, binary
+
+        def parse_power(d: bytes) -> Tuple[float, float]:
+            bcd = (1000 * decode_bcd(d[0]) +
+                   10 * decode_bcd(d[1]) +
+                   0.1 * decode_bcd(d[2]))
+            binary = ((d[0] << 16) +
+                      (d[1] << 8) +
+                      d[2]) / 10
+            return bcd, binary
+
         # Lua reference decodes real time power field in BCD and binary form
         # JS reference decodes multiple energy/power fields in BCD only.
 
-        # Total energy in bytes 4 - 8
-        total_energy = (10000 * decode_bcd(payload[4]) +
-                        100 * decode_bcd(payload[5]) +
-                        1 * decode_bcd(payload[6]) +
-                        0.01 * decode_bcd(payload[7]))
+        # Total energy in bytes 4 - 7
+        total_energy_bcd, total_energy_binary = parse_energy(
+            payload[4:8])
 
         # JS references decodes bytes 8 - 11 as "total running energy"
         # Older JS does not decode these bytes, and sample payloads contain bogus data
 
-        # Current run energy consumption bytes 12 - 16
-        current_energy = (10000 * decode_bcd(payload[12]) +
-                          100 * decode_bcd(payload[13]) +
-                          1 * decode_bcd(payload[14]) +
-                          0.01 * decode_bcd(payload[15]))
+        # Current run energy consumption bytes 12 - 15
+        current_energy_bcd, current_energy_binary = parse_energy(
+            payload[12:16])
 
         # Real time power usage bytes 16 - 18
-        real_time_power = (1000 * decode_bcd(payload[16]) +
-                           10 * decode_bcd(payload[17]) +
-                           0.1 * decode_bcd(payload[18]))
+        real_time_power_bcd, real_time_power_binary = parse_power(
+            payload[16:19])
+
+        # Assume energy monitory is valid if at least one stat is non zero
+        valid = total_energy_bcd or current_energy_bcd or real_time_power_bcd
 
-        # Assume energy monitory is valid if at least one stats is non zero
-        valid = total_energy or current_energy or real_time_power
+        self.total_energy = total_energy_bcd if valid else None
+        self.current_energy = current_energy_bcd if valid else None
+        self.real_time_power = real_time_power_bcd if valid else None
 
-        self.total_energy = total_energy if valid else None
-        self.current_energy = current_energy if valid else None
-        self.real_time_power = real_time_power if valid else None
+        self.total_energy_binary = total_energy_binary if valid else None
+        self.current_energy_binary = current_energy_binary if valid else None
+        self.real_time_power_binary = real_time_power_binary if valid else None
 
 
 class HumidityResponse(Response):
diff --git a/msmart/device/AC/device.py b/msmart/device/AC/device.py
index 24e7a33..1155403 100644
--- a/msmart/device/AC/device.py
+++ b/msmart/device/AC/device.py
@@ -145,6 +145,7 @@ class AirConditioner(Device):
         self._total_energy_usage = None
         self._current_energy_usage = None
         self._real_time_power_usage = None
+        self._use_binary_energy = False
 
         # Default to assuming device can't handle any properties
         self._supported_properties = set()
@@ -242,9 +243,9 @@ class AirConditioner(Device):
                 self._ieco = value
 
         elif isinstance(res, EnergyUsageResponse):
-            self._total_energy_usage = res.total_energy
-            self._current_energy_usage = res.current_energy
-            self._real_time_power_usage = res.real_time_power
+            self._total_energy_usage = res.total_energy_binary if self._use_binary_energy else res.total_energy
+            self._current_energy_usage = res.current_energy_binary if self._use_binary_energy else res.current_energy
+            self._real_time_power_usage = res.real_time_power_binary if self._use_binary_energy else res.real_time_power
 
         elif isinstance(res, HumidityResponse):
             self._indoor_humidity = res.humidity
@@ -827,6 +828,14 @@ class AirConditioner(Device):
     def filter_alert(self) -> Optional[bool]:
         return self._filter_alert
 
+    @property
+    def use_alternate_energy_format(self) -> bool:
+        return self._use_binary_energy
+
+    @use_alternate_energy_format.setter
+    def use_alternate_energy_format(self, enable: bool) -> None:
+        self._use_binary_energy = enable
+
     @property
     def enable_energy_usage_requests(self) -> bool:
         return self._request_energy_usage


Test patch:
diff --git a/msmart/device/AC/test_command.py b/msmart/device/AC/test_command.py
index 4786141..28eacb1 100644
--- a/msmart/device/AC/test_command.py
+++ b/msmart/device/AC/test_command.py
@@ -840,6 +840,29 @@ class TestGroupDataResponse(_TestResponseBase):
             self.assertEqual(resp.current_energy, current)
             self.assertEqual(resp.real_time_power, real_time)
 
+    def test_binary_energy_usage(self) -> None:
+        """Test we decode binary energy usage responses correctly."""
+        TEST_RESPONSES = {
+            # https://github.com/mill1000/midea-ac-py/issues/204#issuecomment-2314705021
+            (150.4, .6, 279.5): bytes.fromhex("aa22ac00000000000803c1210144000005e00000000000000006000aeb000000487a5e"),
+
+            # https://github.com/mill1000/midea-msmart/pull/116#issuecomment-2218753545
+            (None, None, None): bytes.fromhex("aa20ac00000000000303c1210144000000000000000000000000000000000843bc"),
+        }
+
+        for power, response in TEST_RESPONSES.items():
+            resp = self._test_build_response(response)
+
+            # Assert response is a correct type
+            self.assertEqual(type(resp), EnergyUsageResponse)
+            resp = cast(EnergyUsageResponse, resp)
+
+            total, current, real_time = power
+
+            self.assertEqual(resp.total_energy_binary, total)
+            self.assertEqual(resp.current_energy_binary, current)
+            self.assertEqual(resp.real_time_power_binary, real_time)
+
     def test_humidity(self) -> None:
         """Test we decode humidity responses correctly."""
         TEST_RESPONSES = {
diff --git a/msmart/device/AC/test_device.py b/msmart/device/AC/test_device.py
index 7bf576e..dc82018 100644
--- a/msmart/device/AC/test_device.py
+++ b/msmart/device/AC/test_device.py
@@ -293,6 +293,39 @@ class TestUpdateStateFromResponse(unittest.TestCase):
             self.assertEqual(device.current_energy_usage, current)
             self.assertEqual(device.real_time_power_usage, real_time)
 
+    def test_binary_energy_usage_response(self) -> None:
+        """Test parsing of EnergyUsageResponses into device state with binary format."""
+        TEST_RESPONSES = {
+            # https://github.com/mill1000/midea-ac-py/issues/204#issuecomment-2314705021
+            (150.4, .6, 279.5): bytes.fromhex("aa22ac00000000000803c1210144000005e00000000000000006000aeb000000487a5e"),
+
+            # https://github.com/mill1000/midea-msmart/pull/116#issuecomment-2218753545
+            (None, None, None): bytes.fromhex("aa20ac00000000000303c1210144000000000000000000000000000000000843bc"),
+        }
+
+        for power, response in TEST_RESPONSES.items():
+            resp = Response.construct(response)
+            self.assertIsNotNone(resp)
+
+            # Assert response is a state response
+            self.assertEqual(type(resp), EnergyUsageResponse)
+
+            # Create a dummy device and process the response
+            device = AC(0, 0, 0)
+
+            # Switch to binary format
+            device.use_alternate_energy_format = True
+
+            # Update state with response
+            device._update_state(resp)
+
+            total, current, real_time = power
+
+            # Assert state is expected
+            self.assertEqual(device.total_energy_usage, total)
+            self.assertEqual(device.current_energy_usage, current)
+            self.assertEqual(device.real_time_power_usage, real_time)
+
     def test_humidity_response(self) -> None:
         """Test parsing of HumidityResponses into device state."""
         TEST_RESPONSES = {