Repository: mill1000/midea-msmart
Pull request #121 (2024-04-30 22:21:36)

Add support for anion (ionizer)
In support of https://github.com/mill1000/midea-ac-py/issues/120


Patch:
diff --git a/msmart/device/AC/command.py b/msmart/device/AC/command.py
index 11f540d..80b616a 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 Callable, Collection, Mapping, Optional, Union
+from typing import Any, Callable, Collection, Mapping, Optional, Union
 
 import msmart.crc8 as crc8
 from msmart.const import DeviceType, FrameType
@@ -532,6 +532,15 @@ class CapabilitiesResponse(Response):
         if len(caps) > 1:
             self._additional_capabilities = bool(caps[-2])
 
+    def _get_fan_speed(self, speed) -> bool:
+        # If any fan_ capability was received, check against them
+        if any(k.startswith("fan_") for k in self._capabilities):
+            # Assume that a fan capable of custom speeds is capable of any speed
+            return self._capabilities.get(f"fan_{speed}", False) or self._capabilities.get("fan_custom", False)
+
+        # Otherwise return a default set for devices that don't send the capability
+        return speed in ["low", "medium", "high", "auto"]
+
     def merge(self, other: CapabilitiesResponse) -> None:
         # Add other's capabilities to ours
         self._capabilities.update(other._capabilities)
@@ -542,14 +551,9 @@ class CapabilitiesResponse(Response):
     def additional_capabilities(self) -> bool:
         return self._additional_capabilities
 
-    def _get_fan_speed(self, speed) -> bool:
-        # If any fan_ capability was received, check against them
-        if any(k.startswith("fan_") for k in self._capabilities):
-            # Assume that a fan capable of custom speeds is capable of any speed
-            return self._capabilities.get(f"fan_{speed}", False) or self._capabilities.get("fan_custom", False)
-
-        # Otherwise return a default set for devices that don't send the capability
-        return speed in ["low", "medium", "high", "auto"]
+    @property
+    def anion(self) -> bool:
+        return self._capabilities.get("anion", False)
 
     # TODO rethink these properties for fan speed, operation mode and swing mode
     # Surely there's a better way than define props for each possible cap
@@ -837,18 +841,5 @@ class PropertiesResponse(Response):
             # Advanced to next property
             props = props[4+size:]
 
-    @property
-    def indoor_humidity(self) -> Optional[int]:
-        return self._properties.get(PropertyId.INDOOR_HUMIDITY, None)
-
-    @property
-    def rate_select(self) -> Optional[int]:
-        return self._properties.get(PropertyId.RATE_SELECT, None)
-
-    @property
-    def swing_horizontal_angle(self) -> Optional[int]:
-        return self._properties.get(PropertyId.SWING_LR_ANGLE, None)
-
-    @property
-    def swing_vertical_angle(self) -> Optional[int]:
-        return self._properties.get(PropertyId.SWING_UD_ANGLE, None)
+    def get_property(self, id: PropertyId) -> Optional[Any]:
+        return self._properties.get(id, None)
diff --git a/msmart/device/AC/device.py b/msmart/device/AC/device.py
index 36ee5a3..8c8f2cb 100644
--- a/msmart/device/AC/device.py
+++ b/msmart/device/AC/device.py
@@ -57,6 +57,7 @@ class AirConditioner(Device):
 
     # Create a dict to map properties to attribute names
     _PROPERTY_MAP = {
+        PropertyId.ANION: "_anion",
         PropertyId.SWING_LR_ANGLE: "_horizontal_swing_angle",
         PropertyId.SWING_UD_ANGLE: "_vertical_swing_angle"
     }
@@ -108,6 +109,7 @@ class AirConditioner(Device):
 
         self._horizontal_swing_angle = AirConditioner.SwingAngle.OFF
         self._vertical_swing_angle = AirConditioner.SwingAngle.OFF
+        self._anion = False
 
     def _update_state(self, res: Union[StateResponse, PropertiesResponse]) -> None:
         if isinstance(res, StateResponse):
@@ -151,15 +153,18 @@ class AirConditioner(Device):
 
         elif isinstance(res, PropertiesResponse):
 
-            if res.swing_horizontal_angle:
+            if angle := res.get_property(PropertyId.SWING_LR_ANGLE):
                 self._horizontal_swing_angle = cast(
                     AirConditioner.SwingAngle,
-                    AirConditioner.SwingAngle.get_from_value(res.swing_horizontal_angle))
+                    AirConditioner.SwingAngle.get_from_value(angle))
 
-            if res.swing_vertical_angle:
+            if angle := res.get_property(PropertyId.SWING_UD_ANGLE):
                 self._vertical_swing_angle = cast(
                     AirConditioner.SwingAngle,
-                    AirConditioner.SwingAngle.get_from_value(res.swing_vertical_angle))
+                    AirConditioner.SwingAngle.get_from_value(angle))
+
+            if anion := res.get_property(PropertyId.ANION):
+                self._anion = anion
 
     def _update_capabilities(self, res: CapabilitiesResponse) -> None:
         # Build list of supported operation modes
@@ -220,6 +225,9 @@ class AirConditioner(Device):
         if res.swing_horizontal_angle:
             self._supported_properties.add(PropertyId.SWING_LR_ANGLE)
 
+        if res.anion:
+            self._supported_properties.add(PropertyId.ANION)
+
     def _process_state_response(self, response: Response) -> None:
         """Update the local state from a device state response."""
 
@@ -480,10 +488,6 @@ class AirConditioner(Device):
     def supports_horizontal_swing_angle(self) -> bool:
         return PropertyId.SWING_LR_ANGLE in self._supported_properties
 
-    @property
-    def supports_vertical_swing_angle(self) -> bool:
-        return PropertyId.SWING_UD_ANGLE in self._supported_properties
-
     @property
     def horizontal_swing_angle(self) -> SwingAngle:
         return self._horizontal_swing_angle
@@ -493,6 +497,10 @@ class AirConditioner(Device):
         self._horizontal_swing_angle = angle
         self._updated_properties.add(PropertyId.SWING_LR_ANGLE)
 
+    @property
+    def supports_vertical_swing_angle(self) -> bool:
+        return PropertyId.SWING_UD_ANGLE in self._supported_properties
+
     @property
     def vertical_swing_angle(self) -> SwingAngle:
         return self._vertical_swing_angle
@@ -502,6 +510,19 @@ class AirConditioner(Device):
         self._vertical_swing_angle = angle
         self._updated_properties.add(PropertyId.SWING_UD_ANGLE)
 
+    @property
+    def supports_anion(self) -> bool:
+        return PropertyId.ANION in self._supported_properties
+
+    @property
+    def anion(self) -> bool:
+        return self._anion
+
+    @anion.setter
+    def anion(self, enabled: bool) -> None:
+        self._anion = enabled
+        self._updated_properties.add(PropertyId.ANION)
+
     @property
     def supports_eco_mode(self) -> Optional[bool]:
         return self._supports_eco_mode


Test patch:
diff --git a/msmart/device/AC/test_command.py b/msmart/device/AC/test_command.py
index 14d4a9c..cc171e9 100644
--- a/msmart/device/AC/test_command.py
+++ b/msmart/device/AC/test_command.py
@@ -626,14 +626,17 @@ class TestPropertiesResponse(_TestResponseBase):
 
         EXPECTED_RAW_PROPERTIES = {
             PropertyId.INDOOR_HUMIDITY: 43,
-            PropertyId.SWING_UD_ANGLE: 0,
             PropertyId.SWING_LR_ANGLE: 0,
+            PropertyId.SWING_UD_ANGLE: 0,
         }
         # Ensure raw decoded properties match
         self.assertEqual(resp._properties, EXPECTED_RAW_PROPERTIES)
 
         # Check state
-        self.assertEqual(resp.indoor_humidity, 43)
+        self.assertIsNone(resp.get_property(PropertyId.ANION))
+        self.assertEqual(resp.get_property(PropertyId.INDOOR_HUMIDITY), 43)
+        self.assertEqual(resp.get_property(PropertyId.SWING_LR_ANGLE), 0)
+        self.assertEqual(resp.get_property(PropertyId.SWING_UD_ANGLE), 0)
 
     def test_properties_ack(self) -> None:
         """Test we decode an acknowledgement from a set properties command correctly."""
@@ -648,14 +651,16 @@ class TestPropertiesResponse(_TestResponseBase):
         self.assertEqual(type(resp), PropertiesResponse)
 
         EXPECTED_RAW_PROPERTIES = {
-            PropertyId.SWING_UD_ANGLE: 0,
             PropertyId.SWING_LR_ANGLE: 50,
+            PropertyId.SWING_UD_ANGLE: 0,
         }
         # Ensure raw decoded properties match
         self.assertEqual(resp._properties, EXPECTED_RAW_PROPERTIES)
 
         # Check state
-        self.assertEqual(resp.swing_horizontal_angle, 50)
+        self.assertIsNone(resp.get_property(PropertyId.ANION))
+        self.assertEqual(resp.get_property(PropertyId.SWING_LR_ANGLE), 50)
+        self.assertEqual(resp.get_property(PropertyId.SWING_UD_ANGLE), 0)
 
 
 class TestResponseConstruct(_TestResponseBase):
diff --git a/msmart/device/AC/test_device.py b/msmart/device/AC/test_device.py
index 6b8712b..6fdccc8 100644
--- a/msmart/device/AC/test_device.py
+++ b/msmart/device/AC/test_device.py
@@ -159,6 +159,7 @@ class TestUpdateStateFromResponse(unittest.TestCase):
 
         # Assert state is expected
         # TODO Test cases doesn't test values that differ from defaults
+        self.assertEqual(device.anion, False)
         self.assertEqual(device.horizontal_swing_angle, AC.SwingAngle.OFF)
         self.assertEqual(device.vertical_swing_angle, AC.SwingAngle.OFF)
 
@@ -179,6 +180,7 @@ class TestUpdateStateFromResponse(unittest.TestCase):
         device._process_state_response(resp)
 
         # Assert state is expected
+        self.assertEqual(device.anion, False)
         self.assertEqual(device.horizontal_swing_angle, AC.SwingAngle.POS_3)
         self.assertEqual(device.vertical_swing_angle, AC.SwingAngle.OFF)
 
@@ -192,6 +194,7 @@ class TestUpdateStateFromResponse(unittest.TestCase):
         device = AC(0, 0, 0)
 
         # Set some properties
+        device.anion = True
         device.horizontal_swing_angle = AC.SwingAngle.POS_5
         device.vertical_swing_angle = AC.SwingAngle.POS_5
 
@@ -207,6 +210,7 @@ class TestUpdateStateFromResponse(unittest.TestCase):
         self.assertEqual(device.horizontal_swing_angle, AC.SwingAngle.POS_3)
 
         # Assert other properties are untouched
+        self.assertEqual(device.anion, True)
         self.assertEqual(device.vertical_swing_angle, AC.SwingAngle.POS_5)