Repository: mill1000/midea-msmart
Pull request #222 (2025-06-29 16:22:33)

Add support for Cascade function
Requested here: https://github.com/mill1000/midea-ac-py/issues/359

Still untested, needs tests with real device data too


Patch:
diff --git a/msmart/device/AC/command.py b/msmart/device/AC/command.py
index e842bf5..e62fdc0 100644
--- a/msmart/device/AC/command.py
+++ b/msmart/device/AC/command.py
@@ -41,7 +41,7 @@ class CapabilityId(IntEnum):
     FRESH_AIR = 0x004B
     PARENT_CONTROL = 0x0051  # ??
     PREVENT_STRAIGHT_WIND_SELECT = 0x0058  # ??
-    WIND_AROUND = 0x0059  # ??
+    CASCADE = 0x0059  # AKA "Wind Around"
     JET_COOL = 0x0067  # ??
     PRESET_IECO = 0x00E3
     ICHECK = 0x0091  # ??
@@ -82,6 +82,7 @@ class PropertyId(IntEnum):
     BREEZE_CONTROL = 0x0043  # AKA "FA No Wind Sense"
     RATE_SELECT = 0x0048
     FRESH_AIR = 0x004B
+    CASCADE = 0x0059  # AKA "Wind Around"
     JET_COOL = 0x0067  # AKA "Flash Cool"
     IECO = 0x00E3
     ANION = 0x021E
@@ -94,6 +95,7 @@ class PropertyId(IntEnum):
             PropertyId.BREEZE_CONTROL,
             PropertyId.BREEZELESS,
             PropertyId.BUZZER,
+            PropertyId.CASCADE,
             PropertyId.IECO,
             PropertyId.JET_COOL,
             PropertyId.RATE_SELECT,
@@ -116,6 +118,9 @@ class PropertyId(IntEnum):
         elif self == PropertyId.IECO:
             # data[0] - ieco_number, data[1] - ieco_switch
             return bool(data[1])
+        elif self == PropertyId.CASCADE:
+            # data[0] - wind_around, data[1] - wind_around_ud
+            return data[1] if data[0] else 0
         else:
             return data[0]
 
@@ -129,6 +134,9 @@ class PropertyId(IntEnum):
         elif self == PropertyId.IECO:
             # ieco_frame, ieco_number, ieco_switch, ...
             return bytes([0, 1, args[0]]) + bytes(10)
+        elif self == PropertyId.CASCADE:
+            # data[0] - wind_around, data[1] - wind_around_ud
+            return bytes([1 if args[0] else 0, args[0]])
         else:
             return bytes(args[0:1])
 
@@ -526,6 +534,7 @@ class CapabilitiesResponse(Response):
             CapabilityId.BREEZE_CONTROL: reader("breeze_control", get_value(1)),
             CapabilityId.BREEZELESS: reader("breezeless", get_value(1)),
             CapabilityId.BUZZER:  reader("buzzer", get_value(1)),
+            CapabilityId.CASCADE:  reader("cascade", get_value(1)),
             CapabilityId.DISPLAY_CONTROL: reader("display_control", lambda v: v in [1, 2, 100]),
             CapabilityId.ENERGY: [
                 reader("energy_stats", lambda v: v in [2, 3, 4, 5]),
@@ -722,6 +731,10 @@ class CapabilitiesResponse(Response):
     def breezeless(self) -> bool:
         return self._capabilities.get("breezeless", False)
 
+    @property
+    def cascade(self) -> bool:
+        return self._capabilities.get("cascade", False)
+
     @property
     def swing_horizontal_angle(self) -> bool:
         return self._capabilities.get("swing_horizontal_angle", False)
diff --git a/msmart/device/AC/device.py b/msmart/device/AC/device.py
index 994b7a8..86724b2 100644
--- a/msmart/device/AC/device.py
+++ b/msmart/device/AC/device.py
@@ -59,6 +59,13 @@ class AirConditioner(Device):
 
         DEFAULT = OFF
 
+    class CascadeMode(MideaIntEnum):
+        OFF = 0
+        UP = 1
+        DOWN = 2
+
+        DEFAULT = OFF
+
     class RateSelect(MideaIntEnum):
         OFF = 100
 
@@ -99,7 +106,8 @@ class AirConditioner(Device):
         PropertyId.JET_COOL: lambda s: s._flash_cool,
         PropertyId.RATE_SELECT: lambda s: s._rate_select,
         PropertyId.SWING_LR_ANGLE: lambda s: s._horizontal_swing_angle,
-        PropertyId.SWING_UD_ANGLE: lambda s: s._vertical_swing_angle
+        PropertyId.SWING_UD_ANGLE: lambda s: s._vertical_swing_angle,
+        PropertyId.CASCADE: lambda s: s._cascade_mode,
     }
 
     def __init__(self, ip: str, device_id: int,  port: int, **kwargs) -> None:
@@ -161,6 +169,7 @@ class AirConditioner(Device):
 
         self._horizontal_swing_angle = AirConditioner.SwingAngle.OFF
         self._vertical_swing_angle = AirConditioner.SwingAngle.OFF
+        self._cascade_mode = AirConditioner.CascadeMode.OFF
 
         self._self_clean_active = False
 
@@ -243,6 +252,11 @@ class AirConditioner(Device):
                     AirConditioner.SwingAngle,
                     AirConditioner.SwingAngle.get_from_value(angle))
 
+            if (cascade := res.get_property(PropertyId.CASCADE)) is not None:
+                self._cascade_mode = cast(
+                    AirConditioner.CascadeMode,
+                    AirConditioner.CascadeMode.get_from_value(cascade))
+
             if (value := res.get_property(PropertyId.SELF_CLEAN)) is not None:
                 self._self_clean_active = value
 
@@ -371,6 +385,9 @@ class AirConditioner(Device):
         if res.swing_horizontal_angle:
             self._supported_properties.add(PropertyId.SWING_LR_ANGLE)
 
+        if res.cascade:
+            self._supported_properties.add(PropertyId.CASCADE)
+
         if res.self_clean:
             self._supported_properties.add(PropertyId.SELF_CLEAN)
 
@@ -802,6 +819,19 @@ class AirConditioner(Device):
         self._vertical_swing_angle = angle
         self._updated_properties.add(PropertyId.SWING_UD_ANGLE)
 
+    @property
+    def supports_cascade(self) -> bool:
+        return PropertyId.CASCADE in self._supported_properties
+
+    @property
+    def cascade_mode(self) -> CascadeMode:
+        return self._cascade_mode
+
+    @cascade_mode.setter
+    def cascade_mode(self, mode: CascadeMode) -> None:
+        self._cascade_mode = mode
+        self._updated_properties.add(PropertyId.CASCADE)
+
     @property
     def supports_eco(self) -> bool:
         return self._supports_eco
@@ -997,6 +1027,7 @@ class AirConditioner(Device):
             "swing_mode": self.swing_mode,
             "horizontal_swing_angle": self.horizontal_swing_angle,
             "vertical_swing_angle": self.vertical_swing_angle,
+            "cascade_mode": self.cascade_mode,
             "target_temperature": self.target_temperature,
             "indoor_temperature": self.indoor_temperature,
             "outdoor_temperature": self.outdoor_temperature,


Test patch:
diff --git a/msmart/device/AC/test_command.py b/msmart/device/AC/test_command.py
index 3765751..88fbe0c 100644
--- a/msmart/device/AC/test_command.py
+++ b/msmart/device/AC/test_command.py
@@ -250,7 +250,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
     EXPECTED_ATTRS = [
         "anion",
         "fan_silent", "fan_low", "fan_medium", "fan_high", "fan_auto", "fan_custom",
-        "breeze_away", "breeze_control", "breezeless",
+        "breeze_away", "breeze_control", "breezeless", "cascade",
         "swing_horizontal_angle", "swing_vertical_angle",
         "swing_horizontal", "swing_vertical", "swing_both",
         "dry_mode", "cool_mode", "heat_mode", "auto_mode",
@@ -349,7 +349,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
             "fan_low": True, "fan_medium": True,
             "fan_high": True, "fan_auto": True,
             "fan_custom": False, "breeze_away": False,
-            "breeze_control": False, "breezeless": False,
+            "breeze_control": False, "breezeless": False, "cascade": False,
             "swing_horizontal_angle": False, "swing_vertical_angle": False,
             "swing_horizontal": True, "swing_vertical": True,
             "swing_both": True,
@@ -411,7 +411,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
             "fan_low": True, "fan_medium": True,
             "fan_high": True, "fan_auto": True,
             "fan_custom": True, "breeze_away": False,
-            "breeze_control": False, "breezeless": False,
+            "breeze_control": False, "breezeless": False, "cascade": False,
             "swing_horizontal_angle": False, "swing_vertical_angle": False,
             "swing_horizontal": True, "swing_vertical": True,
             "swing_both": True,
@@ -462,7 +462,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
             "fan_low": True, "fan_medium": True,
             "fan_high": True, "fan_auto": True,
             "fan_custom": False, "breeze_away": False,
-            "breeze_control": False, "breezeless": False,
+            "breeze_control": False, "breezeless": False, "cascade": False,
             "swing_horizontal_angle": False, "swing_vertical_angle": False,
             "swing_horizontal": False, "swing_vertical": False,
             "swing_both": False,
@@ -515,7 +515,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
             "fan_low": True, "fan_medium": True,
             "fan_high": True, "fan_auto": True,
             "fan_custom": True, "breeze_away": False,
-            "breeze_control": False, "breezeless": False,
+            "breeze_control": False, "breezeless": False, "cascade": False,
             "swing_horizontal_angle": False, "swing_vertical_angle": False,
             "swing_horizontal": False, "swing_vertical": True,
             "swing_both": False,
@@ -628,7 +628,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
             "fan_low": True, "fan_medium": True,
             "fan_high": True, "fan_auto": True,
             "fan_custom": True, "breeze_away": False,
-            "breeze_control": True, "breezeless": False,
+            "breeze_control": True, "breezeless": False, "cascade": False,
             "swing_horizontal_angle": False, "swing_vertical_angle": False,
             "swing_horizontal": True, "swing_vertical": True,
             "swing_both": True,
@@ -743,7 +743,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
             "fan_low": True, "fan_medium": True,
             "fan_high": True, "fan_auto": True,
             "fan_custom": False, "breeze_away": False,
-            "breeze_control": False, "breezeless": False,
+            "breeze_control": False, "breezeless": False, "cascade": False,
             "swing_horizontal_angle": False, "swing_vertical_angle": False,
             "swing_horizontal": False, "swing_vertical": False,
             "swing_both": False,
@@ -855,7 +855,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
             "fan_low": True, "fan_medium": True,
             "fan_high": True, "fan_auto": True,
             "fan_custom": True, "breeze_away": False,
-            "breeze_control": False, "breezeless": False,
+            "breeze_control": False, "breezeless": False, "cascade": False,
             "swing_horizontal_angle": False, "swing_vertical_angle": False,
             "swing_horizontal": False, "swing_vertical": True,
             "swing_both": False,
@@ -877,6 +877,123 @@ class TestCapabilitiesResponse(_TestResponseBase):
             self.assertEqual(getattr(resp, prop),
                              EXPECTED_CAPABILITIES[prop], prop)
 
+    def test_capabilities_cascade(self) -> None:
+        """Test that we decode capabilities that include cascade support."""
+        self.maxDiff = None
+
+        # https://github.com/mill1000/midea-ac-py/issues/359#issuecomment-3028509967
+        TEST_CAPABILITIES_RESPONSE = bytes.fromhex(
+            "aa3dac00000000000303b50a12020101430001001402010115020101160201001a020101100201011f020103250207203c203c203c05400001000100e1ed")
+
+        resp = self._test_build_response(TEST_CAPABILITIES_RESPONSE)
+        resp = cast(CapabilitiesResponse, resp)
+
+        EXPECTED_RAW_CAPABILITIES = {
+            'eco': True, 'heat_mode': True,
+            'cool_mode': True, 'dry_mode': True,
+            'auto_mode': True, 'aux_heat_mode': False,
+            'aux_mode': False, 'breeze_control': False,
+            'swing_horizontal': True, 'swing_vertical': True,
+            'turbo_heat': True, 'turbo_cool': True,
+            'fan_silent': False, 'fan_low': False,
+            'fan_medium': False, 'fan_high': False,
+            'fan_auto': False, 'fan_custom': True,
+            'humidity_auto_set': False, 'humidity_manual_set': True,
+            'cool_min_temperature': 16.0, 'cool_max_temperature': 30.0,
+            'auto_min_temperature': 16.0, 'auto_max_temperature': 30.0,
+            'heat_min_temperature': 16.0, 'heat_max_temperature': 30.0,
+            'decimals': True,
+            'energy_bcd': False, 'energy_setting': False, 'energy_stats': False,
+        }
+        # Ensure raw decoded capabilities match
+        self.assertEqual(resp._capabilities, EXPECTED_RAW_CAPABILITIES)
+
+        # Check if there are additional capabilities
+        self.assertEqual(resp.additional_capabilities, True)
+
+        # Additional capabilities response
+        TEST_ADDITIONAL_CAPABILITIES_RESPONSE = bytes.fromhex(
+            "aa3bac00000000000303b50a1e02010113020101220201001902010039000101580001024200010159000101090001010a000101000000000000cfbf")
+
+        # Test case includes an unsupported capability
+        with self.assertLogs("msmart", logging.DEBUG) as log:
+            additional_resp = self._test_build_response(
+                TEST_ADDITIONAL_CAPABILITIES_RESPONSE)
+            additional_resp = cast(CapabilitiesResponse, additional_resp)
+
+            # Check debug message is generated for some unsupported capabilities
+            self.assertRegex("\n".join(log.output),
+                             "Unsupported capability <CapabilityId.PREVENT_STRAIGHT_WIND_SELECT: 88>, Size: 1.")
+
+        EXPECTED_ADDITIONAL_RAW_CAPABILITIES = {
+            'anion': True, 'aux_electric_heat': False,
+            'breeze_away': True, 'cascade': True,
+            'fahrenheit': True, 'freeze_protection': True, 'self_clean': True,
+            'swing_horizontal_angle': True, 'swing_vertical_angle': True
+        }
+
+        # Ensure raw decoded capabilities match
+        self.assertEqual(additional_resp._capabilities,
+                         EXPECTED_ADDITIONAL_RAW_CAPABILITIES)
+
+        # Ensure the additional capabilities response doesn't also want more capabilities
+        self.assertEqual(additional_resp.additional_capabilities, False)
+
+        # Check that merging the capabilities produced expected results
+        resp.merge(additional_resp)
+
+        EXPECTED_MERGED_RAW_CAPABILITIES = {
+            'eco': True, 'heat_mode': True,
+            'cool_mode': True, 'dry_mode': True,
+            'auto_mode': True, 'aux_heat_mode': False,
+            'aux_mode': False, 'swing_horizontal': True,
+            'swing_vertical': True, 'anion': True,
+            'turbo_heat': True, 'turbo_cool': True,
+            'fan_silent': False, 'fan_low': False,
+            'fan_medium': False, 'fan_high': False,
+            'fan_auto': False, 'fan_custom': True,
+            'cool_min_temperature': 16.0, 'cool_max_temperature': 30.0,
+            'auto_min_temperature': 16.0, 'auto_max_temperature': 30.0,
+            'heat_min_temperature': 16.0, 'heat_max_temperature': 30.0,
+            'decimals': True,
+            'humidity_auto_set': False, 'humidity_manual_set': True,
+            'energy_stats': False, 'energy_setting': False, 'energy_bcd': False,
+            'aux_electric_heat': False,
+            'breeze_control': False,
+            'breeze_away': True, 'cascade': True,
+            'fahrenheit': True, 'freeze_protection': True, 'self_clean': True,
+            'swing_horizontal_angle': True, 'swing_vertical_angle': True
+        }
+        # Ensure raw decoded capabilities match
+        self.assertEqual(resp._capabilities, EXPECTED_MERGED_RAW_CAPABILITIES)
+
+        EXPECTED_CAPABILITIES = {
+            "anion": True, "fan_silent": True,
+            "fan_low": True, "fan_medium": True,
+            "fan_high": True, "fan_auto": True,
+            "fan_custom": True, "breeze_away": True,
+            "breeze_control": False, "breezeless": False, "cascade": True,
+            "swing_horizontal_angle": True, "swing_vertical_angle": True,
+            "swing_horizontal": True, "swing_vertical": True,
+            "swing_both": True,
+            "dry_mode": True, "cool_mode": True,
+            "heat_mode": True, "auto_mode": True,
+            "aux_heat_mode": False, "aux_mode": False,
+            "aux_electric_heat": False,
+            "eco": True, "ieco": False,
+            "jet_cool": True, "turbo": True,
+            "freeze_protection": True,
+            "display_control": False, "filter_reminder": False,
+            "min_temperature": 16.0, "max_temperature": 30.0,
+            "energy_stats": False, "humidity": True,
+            "target_humidity": True, "self_clean": True,
+            "rate_select_levels": None,
+        }
+        # Check capabilities properties match
+        for prop in self.EXPECTED_ATTRS:
+            self.assertEqual(getattr(resp, prop),
+                             EXPECTED_CAPABILITIES[prop], prop)
+
 
 class TestGetPropertiesCommand(unittest.TestCase):
 
@@ -920,6 +1037,11 @@ class TestSetPropertiesCommand(unittest.TestCase):
             # IECO: 13 bytes ieco_frame, ieco_number, ieco_switch, ...
             (PropertyId.IECO, True): bytes([0, 1, 1]) + bytes(10),
             (PropertyId.IECO, False): bytes([0, 1, 0]) + bytes(10),
+
+            # Cascade: 2 bytes wind_around, wind_around_ud
+            (PropertyId.CASCADE, 0): bytes([0, 0]),
+            (PropertyId.CASCADE, 1): bytes([1, 1]),
+            (PropertyId.CASCADE, 2): bytes([1, 2]),
         }
 
         for (prop, value), expected_data in TEST_ENCODES.items():
@@ -979,6 +1101,11 @@ class TestPropertiesResponse(_TestResponseBase):
             # IECO: 2 bytes
             (PropertyId.IECO, bytes([0x00, 0x00])): False,
             (PropertyId.IECO, bytes([0x00, 0x01])): True,
+
+            # Cascade: 2 bytes
+            (PropertyId.CASCADE, bytes([0x00, 0x00])): 0,
+            (PropertyId.CASCADE, bytes([0x01, 0x01])): 1,
+            (PropertyId.CASCADE, bytes([0x01, 0x02])): 2,
         }
 
         for (prop, data), expected_value in TEST_DECODES.items():
diff --git a/msmart/device/AC/test_device.py b/msmart/device/AC/test_device.py
index ab7392b..59e7a67 100644
--- a/msmart/device/AC/test_device.py
+++ b/msmart/device/AC/test_device.py
@@ -570,6 +570,22 @@ class TestSetState(unittest.TestCase):
         # Assert correct property is being updated
         self.assertIn(PropertyId.JET_COOL, device._updated_properties)
 
+    def test_properties_cascade(self) -> None:
+        """Test setting cascade property."""
+
+        # Create dummy device with cascade
+        device = AC(0, 0, 0)
+        device._supported_properties.add(PropertyId.CASCADE)
+
+        # Enable a cascade mode
+        device.cascade_mode = AC.CascadeMode.LOWER
+
+        # Assert state is expected
+        self.assertEqual(device.cascade_mode, AC.CascadeMode.LOWER)
+
+        # Assert correct property is being updated
+        self.assertIn(PropertyId.CASCADE, device._updated_properties)
+
 
 class TestSendCommandGetResponse(unittest.IsolatedAsyncioTestCase):
     # pylint: disable=protected-access