Repository: mill1000/midea-msmart
Pull request #166 (2024-08-16 17:57:07)

Add device-level capabilities tests
Tidy up some capability processing and add device level test cases for capabilities.

Specifically we want to test the more complex device-level capabilities like 
* Breeze Control
* Rate Select
* Smart Dry



Patch:
diff --git a/msmart/device/AC/command.py b/msmart/device/AC/command.py
index 6be4b09..971e4db 100644
--- a/msmart/device/AC/command.py
+++ b/msmart/device/AC/command.py
@@ -476,13 +476,13 @@ class CapabilitiesResponse(Response):
                 reader("fan_custom", get_value(1)),
             ],
             CapabilityId.FILTER_REMIND: [
-                reader("filter_notice", lambda v: v == 1 or v == 2 or v == 4),
-                reader("filter_clean", lambda v: v == 3 or v == 4),
+                reader("filter_notice", lambda v: v in [1, 2, 4]),
+                reader("filter_clean", lambda v: v in [3, 4]),
             ],
             CapabilityId.HUMIDITY:
             [
-                reader("humidity_auto_set", lambda v: v == 1 or v == 2),
-                reader("humidity_manual_set", lambda v: v == 2 or v == 3),
+                reader("humidity_auto_set", lambda v: v in [1, 2]),
+                reader("humidity_manual_set", lambda v: v in [2, 3]),
             ],
             CapabilityId.MODES: [
                 reader("heat_mode", lambda v: v in [1, 2, 4, 6, 7, 9]),
@@ -495,13 +495,10 @@ class CapabilitiesResponse(Response):
                 reader("energy_setting", lambda v: v in [3, 5]),
                 reader("energy_bcd", lambda v: v in [2, 3]),
             ],
-            CapabilityId.PRESET_ECO: [
-                reader("eco_mode", get_value(1)),
-                reader("eco_mode_2", get_value(2)),
-            ],
+            CapabilityId.PRESET_ECO: reader("eco_mode", lambda v: v in [1, 2]),
             CapabilityId.PRESET_FREEZE_PROTECTION: reader("freeze_protection", get_value(1)),
             CapabilityId.PRESET_TURBO:  [
-                reader("turbo_heat", lambda v: v == 1 or v == 3),
+                reader("turbo_heat", lambda v: v in [1, 3]),
                 reader("turbo_cool", lambda v: v < 2),
             ],
             CapabilityId.RATE_SELECT:  [
@@ -514,7 +511,7 @@ class CapabilitiesResponse(Response):
             CapabilityId.SWING_LR_ANGLE: reader("swing_horizontal_angle", get_value(1)),
             CapabilityId.SWING_UD_ANGLE: reader("swing_vertical_angle", get_value(1)),
             CapabilityId.SWING_MODES: [
-                reader("swing_horizontal", lambda v: v == 1 or v == 3),
+                reader("swing_horizontal", lambda v: v in [1, 3]),
                 reader("swing_vertical", lambda v: v < 2),
             ],
             # CapabilityId.TEMPERATURES too complex to be handled here
@@ -546,7 +543,7 @@ class CapabilitiesResponse(Response):
                 capability_id = CapabilityId(raw_id)
             except ValueError:
                 _LOGGER.warning(
-                    "Unknown capability. ID: 0x%04X, Size: %d.", raw_id, size)
+                    "Unknown capability ID: 0x%04X, Size: %d.", raw_id, size)
                 # Advanced to next capability
                 caps = caps[3+size:]
                 continue
@@ -587,11 +584,11 @@ class CapabilitiesResponse(Response):
             elif capability_id == CapabilityId._UNKNOWN:
                 # Supress warnings from unknown capability
                 _LOGGER.debug(
-                    "Ignored unknown capability. ID: 0x%04X, Size: %d.", capability_id, size)
+                    "Ignored unknown capability ID: 0x%04X, Size: %d.", capability_id, size)
 
             else:
                 _LOGGER.info(
-                    "Unsupported capability. ID: 0x%04X, Size: %d.", capability_id, size)
+                    "Unsupported capability %r, Size: %d.", capability_id, size)
 
             # Advanced to next capability
             caps = caps[3+size:]
@@ -699,8 +696,7 @@ class CapabilitiesResponse(Response):
 
     @property
     def eco_mode(self) -> bool:
-        return (self._capabilities.get("eco_mode", False)
-                or self._capabilities.get("eco_mode_2", False))
+        return self._capabilities.get("eco_mode", False)
 
     @property
     def turbo_mode(self) -> bool:
@@ -939,7 +935,7 @@ class PropertiesResponse(Response):
                 property = PropertyId(raw_id)
             except ValueError:
                 _LOGGER.warning(
-                    "Unknown property. ID: 0x%04X, Size: %d.", raw_id, size)
+                    "Unknown property ID 0x%04X, Size: %d.", raw_id, size)
                 # Advanced to next property
                 props = props[4+size:]
                 continue
@@ -947,15 +943,23 @@ class PropertiesResponse(Response):
             # Fetch parser for this property
             parser = parsers.get(property, None)
 
-            # Apply parser if it exists
-            if parser is not None:
-                # Parse the property
-                if (value := parser(props[4:])) is not None:
-                    self._properties.update({property: value})
-
-            else:
+            # Check if parser exists
+            if parser is None:
                 _LOGGER.warning(
-                    "Unsupported property. ID: 0x%04X, Size: %d.", property, size)
+                    "Unsupported property %r, Size: %d.", property, size)
+                # Advanced to next property
+                props = props[4+size:]
+                continue
+
+            # Check execution result and log any errors
+            error = props[2] & 0x10
+            if error:
+                _LOGGER.error(
+                    "Property %r failed, Result: 0x%02X.", property, props[2])
+
+            # Parse the property
+            if (value := parser(props[4:])) is not None:
+                self._properties.update({property: value})
 
             # Advanced to next property
             props = props[4+size:]
diff --git a/msmart/device/AC/device.py b/msmart/device/AC/device.py
index 02e7061..1c12155 100644
--- a/msmart/device/AC/device.py
+++ b/msmart/device/AC/device.py
@@ -80,7 +80,7 @@ class AirConditioner(Device):
         PropertyId.BREEZE_AWAY: lambda s: 2 if s._breeze_away else 1,
         PropertyId.BREEZE_CONTROL: lambda s: (4 if s._breezeless else
                                               (3 if s._breeze_mild else
-                                               (2 if s._breeze_away else 0))),
+                                               (2 if s._breeze_away else 1))),
         PropertyId.BREEZELESS: lambda s: s._breezeless,
         PropertyId.RATE_SELECT: lambda s: s._rate_select,
         PropertyId.SWING_LR_ANGLE: lambda s: s._horizontal_swing_angle,


Test patch:
diff --git a/msmart/device/AC/test_command.py b/msmart/device/AC/test_command.py
index 349d640..8514a99 100644
--- a/msmart/device/AC/test_command.py
+++ b/msmart/device/AC/test_command.py
@@ -272,15 +272,12 @@ class TestCapabilitiesResponse(_TestResponseBase):
         # e.g. eco_mode -> X == 1, eco_mode2 -> X == 2
         resp = _build_capability_response(CapabilityId.PRESET_ECO, 0)
         self.assertEqual(resp._capabilities["eco_mode"], False)
-        self.assertEqual(resp._capabilities["eco_mode_2"], False)
 
         resp = _build_capability_response(CapabilityId.PRESET_ECO, 1)
         self.assertEqual(resp._capabilities["eco_mode"], True)
-        self.assertEqual(resp._capabilities["eco_mode_2"], False)
 
         resp = _build_capability_response(CapabilityId.PRESET_ECO, 2)
-        self.assertEqual(resp._capabilities["eco_mode"], False)
-        self.assertEqual(resp._capabilities["eco_mode_2"], True)
+        self.assertEqual(resp._capabilities["eco_mode"], True)
 
         # Test PRESET_TURBO capability which uses 2 custom parsers.
         # e.g. turbo_heat -> X == 1 or X == 3, turbo_cool -> X < 2
@@ -311,7 +308,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
         resp = cast(CapabilitiesResponse, resp)
 
         EXPECTED_RAW_CAPABILITIES = {
-            "eco_mode": True, "eco_mode_2": False,
+            "eco_mode": True,
             "freeze_protection": True, "heat_mode": True,
             "cool_mode": True, "dry_mode": True,
             "auto_mode": True,
@@ -354,10 +351,10 @@ class TestCapabilitiesResponse(_TestResponseBase):
 
             # Check debug message is generated for ID 0x0040
             self.assertRegex("\n".join(log.output),
-                             "Ignored unknown capability. ID: 0x0040")
+                             "Ignored unknown capability ID: 0x0040")
 
         EXPECTED_RAW_CAPABILITIES = {
-            "eco_mode": True, "eco_mode_2": False, "breezeless": False,
+            "eco_mode": True, "breezeless": False,
             "heat_mode": True, "cool_mode": True, "dry_mode": True,
             "auto_mode": True, "swing_horizontal": True, "swing_vertical": True,
             "energy_stats": False, "energy_setting": False, "energy_bcd": False,
@@ -400,7 +397,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
         resp = cast(CapabilitiesResponse, resp)
 
         EXPECTED_RAW_CAPABILITIES = {
-            "eco_mode": False, "eco_mode_2": True, "heat_mode": False,
+            "eco_mode": True, "heat_mode": False,
             "cool_mode": True, "dry_mode": True, "auto_mode": True,
             "swing_horizontal": False, "swing_vertical": False,
             "filter_notice": True, "filter_clean": False, "turbo_heat": False,
@@ -439,7 +436,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
         resp = cast(CapabilitiesResponse, resp)
 
         EXPECTED_RAW_CAPABILITIES = {
-            "eco_mode": False, "eco_mode_2": True, "freeze_protection": False,
+            "eco_mode": True, "freeze_protection": False,
             "heat_mode": False, "cool_mode": True, "dry_mode": True, "auto_mode": True,
             "swing_horizontal": False, "swing_vertical": True, "filter_notice": True,
             "filter_clean": False, "turbo_heat": False, "turbo_cool": True,
@@ -485,10 +482,10 @@ class TestCapabilitiesResponse(_TestResponseBase):
 
             # Check debug message is generated for ID 0x0040
             self.assertRegex("\n".join(log.output),
-                             "Ignored unknown capability. ID: 0x0040")
+                             "Ignored unknown capability ID: 0x0040")
 
         EXPECTED_RAW_CAPABILITIES = {
-            "eco_mode": True, "eco_mode_2": False,
+            "eco_mode": True,
             "breeze_control": True,
             "heat_mode": True, "cool_mode": True, "dry_mode": True, "auto_mode": True,
             "swing_horizontal": True, "swing_vertical": True,
@@ -532,7 +529,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
         resp.merge(additional_resp)
 
         EXPECTED_MERGED_RAW_CAPABILITIES = {
-            "eco_mode": True, "eco_mode_2": False,
+            "eco_mode": True,
             "breeze_control": True,
             "heat_mode": True, "cool_mode": True, "dry_mode": True, "auto_mode": True,
             "swing_horizontal": True, "swing_vertical": True,
@@ -654,8 +651,13 @@ class TestPropertiesResponse(_TestResponseBase):
         TEST_RESPONSE = bytes.fromhex(
             "aa18ac00000000000302b0020a0000013209001101000089a4")
 
-        resp = self._test_build_response(TEST_RESPONSE)
-        resp = cast(PropertiesResponse, resp)
+        # Device did not support SWING_UD_ANGLE, check that an error was reported
+        with self.assertLogs("msmart", logging.WARNING) as log:
+            resp = self._test_build_response(TEST_RESPONSE)
+            resp = cast(PropertiesResponse, resp)
+
+            self.assertRegex(
+                log.output[0], "Property .*SWING_UD_ANGLE.* failed, Result: 0x11.")
 
         # Assert response is a correct type
         self.assertEqual(type(resp), PropertiesResponse)
@@ -693,7 +695,7 @@ class TestPropertiesResponse(_TestResponseBase):
             resp = cast(PropertiesResponse, resp)
 
             # Check warning is generated for ID 0x001E
-            self.assertRegex(log.output[0], "Unknown property. ID: 0x001E")
+            self.assertRegex(log.output[0], "Unknown property ID 0x001E")
 
         # Assert response is a correct type
         self.assertEqual(type(resp), PropertiesResponse)
@@ -701,6 +703,22 @@ class TestPropertiesResponse(_TestResponseBase):
         # Assert that the buzzer property is not decoded
         self.assertIsNone(resp.get_property(PropertyId.BUZZER))
 
+    def test_properties_execution_failed(self) -> None:
+        """Test we error when decoding properties that had an execution error."""
+        # https://github.com/mill1000/midea-msmart/issues/161#issuecomment-2282839178
+        TEST_RESPONSE = bytes.fromhex(
+            "aa18ac00000000000302b00243001101041a00000100002ce5")
+
+        with self.assertLogs("msmart", logging.WARNING) as log:
+            resp = self._test_build_response(TEST_RESPONSE)
+            resp = cast(PropertiesResponse, resp)
+
+            self.assertRegex(
+                log.output[0], "Property .*BREEZE_CONTROL.* failed, Result: 0x11.")
+
+        # Assert response is a correct type
+        self.assertEqual(type(resp), PropertiesResponse)
+
 
 class TestResponseConstruct(_TestResponseBase):
     """Test construction of responses from raw data."""
diff --git a/msmart/device/AC/test_device.py b/msmart/device/AC/test_device.py
index cdc5988..d9a2bd7 100644
--- a/msmart/device/AC/test_device.py
+++ b/msmart/device/AC/test_device.py
@@ -1,7 +1,9 @@
+import logging
 import unittest
 
-from .command import (EnergyUsageResponse, HumidityResponse,
-                      PropertiesResponse, Response, StateResponse)
+from .command import (CapabilitiesResponse, EnergyUsageResponse,
+                      HumidityResponse, PropertiesResponse, Response,
+                      StateResponse)
 from .device import AirConditioner as AC
 from .device import PropertyId
 
@@ -175,8 +177,13 @@ class TestUpdateStateFromResponse(unittest.TestCase):
         device.horizontal_swing_angle = AC.SwingAngle.OFF
         device.vertical_swing_angle = AC.SwingAngle.OFF
 
-        resp = Response.construct(TEST_RESPONSE)
-        self.assertIsNotNone(resp)
+        # Device did not support SWING_UD_ANGLE, check that an error was reported
+        with self.assertLogs("msmart", logging.WARNING) as log:
+            resp = Response.construct(TEST_RESPONSE)
+            self.assertIsNotNone(resp)
+
+            self.assertRegex(
+                log.output[0], "Property .*SWING_UD_ANGLE.* failed, Result: 0x11.")
 
         # Assert response is a state response
         self.assertEqual(type(resp), PropertiesResponse)
@@ -308,6 +315,149 @@ class TestUpdateStateFromResponse(unittest.TestCase):
             self.assertEqual(device.indoor_humidity, humidity)
 
 
+class TestCapabilities(unittest.TestCase):
+    """Test parsing of CapabilitiesResponse into device capabilities."""
+
+    def test_general_capabilities(self) -> None:
+        """Test general device capabilities."""
+        # Device with numerous supported features
+        # https://github.com/mill1000/midea-msmart/issues/150#issuecomment-2276158338
+        CAPABILITIES_PAYLOAD_0 = bytes.fromhex(
+            "b50a12020101430001011402010115020101160201001a020101100201011f020103250207203c203c203c05400001000100")
+        CAPABILITIES_PAYLOAD_1 = bytes.fromhex(
+            "b5051e020101130201012202010019020100390001010000")
+
+        # Create a dummy device and process the response
+        device = AC(0, 0, 0)
+
+        # Parse capability payloads
+        with memoryview(CAPABILITIES_PAYLOAD_0) as payload0, memoryview(CAPABILITIES_PAYLOAD_1) as payload1:
+            resp0 = CapabilitiesResponse(payload0)
+            resp1 = CapabilitiesResponse(payload1)
+
+            resp0.merge(resp1)
+            device._update_capabilities(resp0)
+
+        self.assertCountEqual(device.supported_operation_modes, [AC.OperationalMode.AUTO,
+                                                                 AC.OperationalMode.COOL,
+                                                                 AC.OperationalMode.DRY,
+                                                                 AC.OperationalMode.FAN_ONLY,
+                                                                 AC.OperationalMode.HEAT,
+                                                                 AC.OperationalMode.SMART_DRY])
+
+        self.assertCountEqual(device.supported_swing_modes, [AC.SwingMode.OFF,
+                                                             AC.SwingMode.BOTH,
+                                                             AC.SwingMode.HORIZONTAL,
+                                                             AC.SwingMode.VERTICAL])
+
+        self.assertEqual(device.supports_custom_fan_speed, True)
+        self.assertCountEqual(device.supported_fan_speeds, [AC.FanSpeed.SILENT,
+                                                            AC.FanSpeed.LOW,
+                                                            AC.FanSpeed.MEDIUM,
+                                                            AC.FanSpeed.HIGH,
+                                                            AC.FanSpeed.MAX,  # Supports custom
+                                                            AC.FanSpeed.AUTO,
+                                                            ])
+
+        self.assertEqual(device.supports_humidity, True)
+        self.assertEqual(device.supports_target_humidity, True)
+
+        self.assertEqual(device.supports_purifier, True)
+        self.assertEqual(device.supports_self_clean, True)
+
+        self.assertEqual(device.supports_eco_mode, True)
+        self.assertEqual(device.supports_freeze_protection_mode, True)
+        self.assertEqual(device.supports_turbo_mode, True)
+
+    def test_rate_select(self) -> None:
+        """Test rate select device capability."""
+        # https://github.com/mill1000/midea-msmart/issues/148#issuecomment-2273549806
+        CAPABILITIES_PAYLOAD_0 = bytes.fromhex(
+            "b50a1202010114020101150201001e020101170201021a02010110020101250207203c203c203c0024020101480001010101")
+        CAPABILITIES_PAYLOAD_1 = bytes.fromhex(
+            "b5071f0201002c020101160201043900010151000101e3000101130201010002")
+
+        # Create a dummy device and process the response
+        device = AC(0, 0, 0)
+
+        # Parse capability payloads
+        with memoryview(CAPABILITIES_PAYLOAD_0) as payload0, memoryview(CAPABILITIES_PAYLOAD_1) as payload1:
+            resp0 = CapabilitiesResponse(payload0)
+            resp1 = CapabilitiesResponse(payload1)
+
+            resp0.merge(resp1)
+            device._update_capabilities(resp0)
+
+        self.assertCountEqual(device.supported_rate_selects, [AC.RateSelect.OFF,
+                                                              AC.RateSelect.GEAR_75,
+                                                              AC.RateSelect.GEAR_50
+                                                              ])
+
+        # TODO find device with 5 levels of rate select
+
+    def test_breeze_modes(self) -> None:
+        """Test breeze mode capabilities."""
+        # "Modern" breezeless device with "breeze control" i.e. breeze away, breeze mild and breezeless.
+        # https://github.com/mill1000/midea-msmart/issues/150#issuecomment-2276158338
+        CAPABILITIES_PAYLOAD_0 = bytes.fromhex(
+            "b50a12020101430001011402010115020101160201001a020101100201011f020103250207203c203c203c05400001000100")
+        CAPABILITIES_PAYLOAD_1 = bytes.fromhex(
+            "b5051e020101130201012202010019020100390001010000")
+
+        # Create a dummy device and process the response
+        device = AC(0, 0, 0)
+
+        # Parse capability payloads
+        with memoryview(CAPABILITIES_PAYLOAD_0) as payload0, memoryview(CAPABILITIES_PAYLOAD_1) as payload1:
+            resp0 = CapabilitiesResponse(payload0)
+            resp1 = CapabilitiesResponse(payload1)
+
+            resp0.merge(resp1)
+            device._update_capabilities(resp0)
+
+        self.assertEqual(device.supports_breeze_away, True)
+        self.assertEqual(device.supports_breeze_mild, True)
+        self.assertEqual(device.supports_breezeless, True)
+
+        # Device with only breeze away
+        # https://github.com/mill1000/midea-msmart/issues/150#issuecomment-2259796473
+        CAPABILITIES_PAYLOAD_0 = bytes.fromhex(
+            "b50912020101180001001402010115020101160201001a020101100201011f020103250207203c203c203c050100")
+        CAPABILITIES_PAYLOAD_1 = bytes.fromhex(
+            "b5091e0201011302010122020100190201003900010142000101090001010a000101300001010000")
+
+        # Parse capability payloads
+        with memoryview(CAPABILITIES_PAYLOAD_0) as payload0, memoryview(CAPABILITIES_PAYLOAD_1) as payload1:
+            resp0 = CapabilitiesResponse(payload0)
+            resp1 = CapabilitiesResponse(payload1)
+
+            resp0.merge(resp1)
+            device._update_capabilities(resp0)
+
+        self.assertEqual(device.supports_breeze_away, True)
+        self.assertEqual(device.supports_breeze_mild, False)
+        self.assertEqual(device.supports_breezeless, False)
+
+        # "Legacy" breezeless device with only breezeless.
+        # https://github.com/mill1000/midea-ac-py/issues/186#issuecomment-2249023972
+        CAPABILITIES_PAYLOAD_0 = bytes.fromhex(
+            "b50912020101180001011402010115020101160201001a020101100201011f020103250207203c203c203c050100")
+        CAPABILITIES_PAYLOAD_1 = bytes.fromhex(
+            "b5041e0201011302010122020100190201000000")
+
+        # Parse capability payloads
+        with memoryview(CAPABILITIES_PAYLOAD_0) as payload0, memoryview(CAPABILITIES_PAYLOAD_1) as payload1:
+            resp0 = CapabilitiesResponse(payload0)
+            resp1 = CapabilitiesResponse(payload1)
+
+            resp0.merge(resp1)
+            device._update_capabilities(resp0)
+
+        self.assertEqual(device.supports_breeze_away, False)
+        self.assertEqual(device.supports_breeze_mild, False)
+        self.assertEqual(device.supports_breezeless, True)
+
+
 class TestSetState(unittest.TestCase):
     """Test setting device state."""