Repository: mill1000/midea-msmart (python)

Issue:
Possible incomplete capabilities request
Based on [dudanov/MideaUART](https://github.com/dudanov/MideaUART), the Lua, and the Javascript it appears there is a second capabilities request command to fetch an additional "page" of capabilities.

This may explain why some users have a mismatch between what msmart-ng claims the device is capable of compared to the OEM app.

MideaUART uses the following **payloads**: The second is only sent if there are 2 bytes remaining and data[-2] != 0.
First: [0xB5, 0x01, 0x11]
Second: [0xB5, 0x01, 0x01, 0x00]

The Lua & Javascript refers to these requests as "all_first_frame", and "all_second_frame" and uses the following fixed **frames**:
First: [0xaa, 0x0e, 0xac, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, 0xb5, 0x01, 0x00, CRC, 0x3d]
Second: [0xaa, 0x0f, 0xac, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, 0xb5, 0x01, 0x01, 0x01, 0x21, 0x66]



Pull request description:
Implement additional capabilities request
Close #98 

Patch:
diff --git a/Dockerfile b/Dockerfile
index 3b98543..281803d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,7 +8,7 @@ RUN python -m build
 
 # Production step
 # Using base alpine package so we can utilize pycryptodome in package repo
-FROM alpine:latest
+FROM alpine:3.18
 RUN apk add --update python3 py3-pip py3-pycryptodome
 COPY --from=build /msmart-build/dist/msmart_ng-*.whl /tmp
 RUN pip install /tmp/msmart_ng-*.whl
diff --git a/msmart/device/AC/command.py b/msmart/device/AC/command.py
index a39334c..2e9e4d6 100644
--- a/msmart/device/AC/command.py
+++ b/msmart/device/AC/command.py
@@ -75,17 +75,19 @@ class TemperatureType(IntEnum):
 
 
 class GetCapabilitiesCommand(Command):
-    def __init__(self) -> None:
+    def __init__(self, additional: bool = False) -> None:
         super().__init__(DeviceType.AIR_CONDITIONER, frame_type=FrameType.REQUEST)
 
+        self._additional = additional
+
     @property
     def payload(self) -> bytes:
-        return bytes([
+        if not self._additional:
             # Get capabilities
-            0xB5,
-            # Unknown
-            0x01, 0x11,
-        ])
+            return bytes([0xB5, 0x01, 0x00])
+        else:
+            # Get more capabilities
+            return bytes([0xB5, 0x01, 0x01, 0x1])
 
 
 class GetStateCommand(Command):
@@ -287,12 +289,13 @@ class CapabilitiesResponse(Response):
         super().__init__(payload)
 
         self._capabilities = {}
+        self._additional_capabilities = False
 
         _LOGGER.debug("Capabilities response payload: %s", payload.hex())
 
         self._parse_capabilities(payload)
 
-        _LOGGER.debug("Supported capabilities: %s", self._capabilities)
+        _LOGGER.debug("Raw capabilities: %s", self._capabilities)
 
     def _parse_capabilities(self, payload: memoryview) -> None:
         # Clear existing capabilities
@@ -352,6 +355,7 @@ class CapabilitiesResponse(Response):
                 reader("turbo_heat", lambda v: v == 1 or v == 3),
                 reader("turbo_cool", lambda v: v < 2),
             ],
+            CapabilityId.ANION: reader("anion", get_value(1)),
             CapabilityId.HUMIDITY:
             [
                 reader("humidity_auto_set", lambda v: v == 1 or v == 2),
@@ -430,6 +434,20 @@ class CapabilitiesResponse(Response):
             # Advanced to next capability
             caps = caps[3+size:]
 
+        # Check if there are additional capabilities
+        if len(caps) > 1:
+            self._additional_capabilities = bool(caps[-2])
+
+    def merge(self, other: CapabilitiesResponse) -> None:
+        # Add other's capabiltiies to ours
+        self._capabilities.update(other._capabilities)
+
+        _LOGGER.debug("Merged raw capabilities: %s", self._capabilities)
+
+    @property
+    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):
diff --git a/msmart/device/AC/device.py b/msmart/device/AC/device.py
index 114ab72..a94b434 100644
--- a/msmart/device/AC/device.py
+++ b/msmart/device/AC/device.py
@@ -8,9 +8,8 @@ from msmart.base_device import Device
 from msmart.const import DeviceType
 
 from .command import (CapabilitiesResponse, GetCapabilitiesCommand,
-                      GetStateCommand, InvalidResponseException)
-from .command import Response as base_response
-from .command import (ResponseId, SetStateCommand, StateResponse,
+                      GetStateCommand, InvalidResponseException, Response,
+                      ResponseId, SetStateCommand, StateResponse,
                       ToggleDisplayCommand)
 
 _LOGGER = logging.getLogger(__name__)
@@ -43,7 +42,7 @@ class AirConditioner(Device):
 
     class FanSpeed(IntEnumHelper):
         AUTO = 102
-        HIGH = 100
+        HIGH = 80
         MEDIUM = 60
         LOW = 40
         SILENT = 20
@@ -92,9 +91,6 @@ class AirConditioner(Device):
         super().__init__(ip=ip, port=port, device_id=device_id,
                          device_type=DeviceType.AIR_CONDITIONER, **kwargs)
 
-        self._updating = False
-        self._defer_update = False
-
         self._beep_on = False
         self._power_state = False
         self._target_temperature = 17.0
@@ -221,113 +217,155 @@ class AirConditioner(Device):
         self._min_target_temperature = res.min_temperature
         self._max_target_temperature = res.max_temperature
 
-    def _process_response(self, data) -> None:
-        # Construct response from data
-        try:
-            response = base_response.construct(data)
-        except InvalidResponseException as e:
-            _LOGGER.error(e)
-            return
-
-        # Device is supported if we can process a response
-        self._supported = True
+    def _process_state_response(self, response: Response) -> None:
+        """Update the local state from a device state response."""
 
         if response.id == ResponseId.STATE:
             response = cast(StateResponse, response)
             self._update_state(response)
-        elif response.id == ResponseId.CAPABILITIES:
-            response = cast(CapabilitiesResponse, response)
-            self._update_capabilities(response)
         else:
             _LOGGER.debug("Ignored unknown response from %s:%d: %s",
                           self.ip, self.port, response.payload.hex())
 
-    async def _send_command(self, command, ignore_response=False) -> None:
-        responses = await super()._send_command(command)
+    async def _send_command_get_responses(self, command) -> List[Response]:
+        """Send a command and yield an iterator of valid response."""
 
-        # Ignore responses if requested
-        if ignore_response:
-            return
+        responses = await super()._send_command(command)
 
         # No response from device
         if responses is None:
             self._online = False
-            return
+            return []
 
         # Device is online if we received any response
         self._online = True
 
-        for response in responses:
-            self._process_response(response)
+        valid_responses = []
+        for data in responses:
+            try:
+                # Construct response from data
+                response = Response.construct(data)
+            except InvalidResponseException as e:
+                _LOGGER.error(e)
+                continue
+
+            # Device is supported if we can process a response
+            self._supported = True
+
+            valid_responses.append(response)
+
+        return valid_responses
+
+    async def _send_command_get_reponse_with_id(self, command, response_id: ResponseId) -> Optional[Response]:
+        """Send a command and return the first response with a matching ID."""
+        for response in await self._send_command_get_responses(command):
+            if response.id == response_id:
+                return response
+
+            _LOGGER.debug("Ignored response with ID %d from %s:%d: %s",
+                          response.id, self.ip, self.port, response.payload.hex())
+
+        return None
 
     async def get_capabilities(self) -> None:
+        """Fetch the device capabilities."""
+
+        # Send capabilities request and get a response
         cmd = GetCapabilitiesCommand()
-        await self._send_command(cmd)
+        response = await self._send_command_get_reponse_with_id(cmd, ResponseId.CAPABILITIES)
+        response = cast(CapabilitiesResponse, response)
+
+        if response is None:
+            _LOGGER.error("Failed to query device capabilities.")
+            return
+
+        # Send 2nd capabilities request if needed
+        if response.additional_capabilities:
+            cmd = GetCapabilitiesCommand(True)
+            additional_response = await self._send_command_get_reponse_with_id(cmd, ResponseId.CAPABILITIES)
+            additional_response = cast(
+                CapabilitiesResponse, additional_response)
+
+            if additional_response:
+                # Merge additional capabilities
+                response.merge(additional_response)
+            else:
+                _LOGGER.warning(
+                    "Failed to query additional device capabilities.")
+
+        # Update device capabilities
+        self._update_capabilities(response)
 
     async def toggle_display(self) -> None:
+        """Toggle the device display if the device supports it."""
+
         if not self._supports_display_control:
             _LOGGER.warning("Device is not capable of display control.")
 
         cmd = ToggleDisplayCommand()
         cmd.beep_on = self._beep_on
-        await self._send_command(cmd, True)
+        # Send the command and ignore all responses
+        await self._send_command_get_responses(cmd)
 
         # Force a refresh to get the updated display state
         await self.refresh()
 
-    async def refresh(self):
+    async def refresh(self) -> None:
+        """Refresh the local copy of the device state by sending a GetState command."""
+
         cmd = GetStateCommand()
-        await self._send_command(cmd)
+        # Process any state responses from the device
+        for response in await self._send_command_get_responses(cmd):
+            self._process_state_response(response)
 
     async def apply(self) -> None:
-        self._updating = True
-        try:
-            # Warn if trying to apply unsupported modes
-            if self._operational_mode not in self._supported_op_modes:
-                _LOGGER.warning(
-                    "Device is not capable of operational mode %s.", self._operational_mode)
+        """Apply the local state to the device."""
 
-            if (self._fan_speed not in self._supported_fan_speeds
-                    and not self._supports_custom_fan_speed):
-                _LOGGER.warning(
-                    "Device is not capable of fan speed %s.", self._fan_speed)
+        # Warn if trying to apply unsupported modes
+        if self._operational_mode not in self._supported_op_modes:
+            _LOGGER.warning(
+                "Device is not capable of operational mode %s.", self._operational_mode)
 
-            if self._swing_mode not in self._supported_swing_modes:
-                _LOGGER.warning(
-                    "Device is not capable of swing mode %s.", self._swing_mode)
-
-            if self._turbo_mode and not self._supports_turbo_mode:
-                _LOGGER.warning("Device is not capable of turbo mode.")
-
-            if self._eco_mode and not self._supports_eco_mode:
-                _LOGGER.warning("Device is not capable of eco mode.")
-
-            if self._freeze_protection_mode and not self._supports_freeze_protection_mode:
-                _LOGGER.warning("Device is not capable of freeze protection.")
-
-            # Define function to return value or a default if value is None
-            def or_default(v, d) -> Any: return v if v is not None else d
-
-            cmd = SetStateCommand()
-            cmd.beep_on = self._beep_on
-            cmd.power_on = or_default(self._power_state, False)
-            cmd.target_temperature = or_default(
-                self._target_temperature, 25)  # TODO?
-            cmd.operational_mode = self._operational_mode
-            cmd.fan_speed = self._fan_speed
-            cmd.swing_mode = self._swing_mode
-            cmd.eco_mode = or_default(self._eco_mode, False)
-            cmd.turbo_mode = or_default(self._turbo_mode, False)
-            cmd.freeze_protection_mode = or_default(
-                self._freeze_protection_mode, False)
-            cmd.sleep_mode = or_default(self._sleep_mode, False)
-            cmd.fahrenheit = or_default(self._fahrenheit_unit, False)
-            cmd.follow_me = or_default(self._follow_me, False)
-
-            await self._send_command(cmd, self._defer_update)
-        finally:
-            self._updating = False
-            self._defer_update = False
+        if (self._fan_speed not in self._supported_fan_speeds
+                and not self._supports_custom_fan_speed):
+            _LOGGER.warning(
+                "Device is not capable of fan speed %s.", self._fan_speed)
+
+        if self._swing_mode not in self._supported_swing_modes:
+            _LOGGER.warning(
+                "Device is not capable of swing mode %s.", self._swing_mode)
+
+        if self._turbo_mode and not self._supports_turbo_mode:
+            _LOGGER.warning("Device is not capable of turbo mode.")
+
+        if self._eco_mode and not self._supports_eco_mode:
+            _LOGGER.warning("Device is not capable of eco mode.")
+
+        if self._freeze_protection_mode and not self._supports_freeze_protection_mode:
+            _LOGGER.warning("Device is not capable of freeze protection.")
+
+        # Define function to return value or a default if value is None
+        def or_default(v, d) -> Any: return v if v is not None else d
+
+        cmd = SetStateCommand()
+        cmd.beep_on = self._beep_on
+        cmd.power_on = or_default(self._power_state, False)
+        cmd.target_temperature = or_default(
+            self._target_temperature, 25)  # TODO?
+        cmd.operational_mode = self._operational_mode
+        cmd.fan_speed = self._fan_speed
+        cmd.swing_mode = self._swing_mode
+        cmd.eco_mode = or_default(self._eco_mode, False)
+        cmd.turbo_mode = or_default(self._turbo_mode, False)
+        cmd.freeze_protection_mode = or_default(
+            self._freeze_protection_mode, False)
+        cmd.sleep_mode = or_default(self._sleep_mode, False)
+        cmd.fahrenheit = or_default(self._fahrenheit_unit, False)
+        cmd.follow_me = or_default(self._follow_me, False)
+
+        # Process any state responses from the device
+        for response in await self._send_command_get_responses(cmd):
+            self._process_state_response(response)
 
     @property
     def beep(self) -> bool:
@@ -335,8 +373,6 @@ class AirConditioner(Device):
 
     @beep.setter
     def beep(self, tone: bool) -> None:
-        if self._updating:
-            self._defer_update = True
         self._beep_on = tone
 
     @property
@@ -345,8 +381,6 @@ class AirConditioner(Device):
 
     @power_state.setter
     def power_state(self, state: bool) -> None:
-        if self._updating:
-            self._defer_update = True
         self._power_state = state
 
     @property
@@ -363,8 +397,6 @@ class AirConditioner(Device):
 
     @target_temperature.setter
     def target_temperature(self, temperature_celsius: float) -> None:
-        if self._updating:
-            self._defer_update = True
         self._target_temperature = temperature_celsius
 
     @property
@@ -377,8 +409,6 @@ class AirConditioner(Device):
 
     @operational_mode.setter
     def operational_mode(self, mode: OperationalMode) -> None:
-        if self._updating:
-            self._defer_update = True
         self._operational_mode = mode
 
     @property
@@ -395,9 +425,6 @@ class AirConditioner(Device):
 
     @fan_speed.setter
     def fan_speed(self, speed: FanSpeed | int | float) -> None:
-        if self._updating:
-            self._defer_update = True
-
         # Convert float as needed
         if isinstance(speed, float):
             speed = int(speed)
@@ -414,8 +441,6 @@ class AirConditioner(Device):
 
     @swing_mode.setter
     def swing_mode(self, mode: SwingMode) -> None:
-        if self._updating:
-            self._defer_update = True
         self._swing_mode = mode
 
     @property
@@ -428,8 +453,6 @@ class AirConditioner(Device):
 
     @eco_mode.setter
     def eco_mode(self, enabled: bool) -> None:
-        if self._updating:
-            self._defer_update = True
         self._eco_mode = enabled
 
     @property
@@ -442,8 +465,6 @@ class AirConditioner(Device):
 
     @turbo_mode.setter
     def turbo_mode(self, enabled: bool) -> None:
-        if self._updating:
-            self._defer_update = True
         self._turbo_mode = enabled
 
     @property
@@ -456,8 +477,6 @@ class AirConditioner(Device):
 
     @freeze_protection_mode.setter
     def freeze_protection_mode(self, enabled: bool) -> None:
-        if self._updating:
-            self._defer_update = True
         self._freeze_protection_mode = enabled
 
     @property
@@ -466,8 +485,6 @@ class AirConditioner(Device):
 
     @sleep_mode.setter
     def sleep_mode(self, enabled: bool) -> None:
-        if self._updating:
-            self._defer_update = True
         self._sleep_mode = enabled
 
     @property
@@ -476,8 +493,6 @@ class AirConditioner(Device):
 
     @fahrenheit.setter
     def fahrenheit(self, enabled: bool) -> None:
-        if self._updating:
-            self._defer_update = True
         self._fahrenheit_unit = enabled
 
     @property
@@ -486,8 +501,6 @@ class AirConditioner(Device):
 
     @follow_me.setter
     def follow_me(self, enabled: bool) -> None:
-        if self._updating:
-            self._defer_update = True
         self._follow_me = enabled
 
     @property
diff --git a/pyproject.toml b/pyproject.toml
index 14dd594..88d8edf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,6 +36,11 @@ midea-discover = "msmart.cli:_legacy_main"
 msmart-ng = "msmart.cli:main"
 
 [tool.setuptools]
-packages=["msmart"]
+include-package-data = false
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["msmart", "msmart.*"]
+exclude = ["msmart.tests"]
 
 [tool.setuptools_scm]
\ No newline at end of file


Test patch:
diff --git a/msmart/device/AC/test_command.py b/msmart/device/AC/test_command.py
index c860d96..b364e8e 100644
--- a/msmart/device/AC/test_command.py
+++ b/msmart/device/AC/test_command.py
@@ -309,6 +309,9 @@ class TestCapabilitiesResponse(_TestResponseBase):
             self.assertEqual(getattr(resp, prop),
                              EXPECTED_CAPABILITIES[prop], prop)
 
+        # Check if there are additional capabilities
+        self.assertEqual(resp.additional_capabilities, False)
+
     def test_capabilities_2(self) -> None:
         """Test that we decode capabilities responses as expected."""
         # https://github.com/mac-zhou/midea-ac-py/pull/177#issuecomment-1259772244
@@ -356,6 +359,9 @@ class TestCapabilitiesResponse(_TestResponseBase):
             self.assertEqual(getattr(resp, prop),
                              EXPECTED_CAPABILITIES[prop], prop)
 
+        # Check if there are additional capabilities
+        self.assertEqual(resp.additional_capabilities, True)
+
     def test_capabilities_3(self) -> None:
         """Test that we decode capabilities responses as expected."""
         # Toshiba Smart Window Unit (2019)
@@ -391,6 +397,9 @@ class TestCapabilitiesResponse(_TestResponseBase):
             self.assertEqual(getattr(resp, prop),
                              EXPECTED_CAPABILITIES[prop], prop)
 
+        # Check if there are additional capabilities
+        self.assertEqual(resp.additional_capabilities, False)
+
     def test_capabilities_4(self) -> None:
         """Test that we decode capabilities responses as expected."""
         # Midea U-shaped Window Unit (2022)
@@ -428,6 +437,98 @@ class TestCapabilitiesResponse(_TestResponseBase):
             self.assertEqual(getattr(resp, prop),
                              EXPECTED_CAPABILITIES[prop], prop)
 
+        # Check if there are additional capabilities
+        self.assertEqual(resp.additional_capabilities, True)
+
+    def test_additional_capabilities(self) -> None:
+        self.maxDiff = None
+        """Test that we decode capabilities and additional capabilities responses as expected."""
+        # https://github.com/mill1000/midea-ac-py/issues/60#issuecomment-1867498321
+        TEST_CAPABILITIES_RESPONSE = bytes.fromhex(
+            "aa3dac00000000000303b50a12020101430001011402010115020101160201001a020101100201011f020103250207203c203c203c05400001000100c805")
+        resp = self._test_build_response(TEST_CAPABILITIES_RESPONSE)
+        resp = cast(CapabilitiesResponse, resp)
+
+        EXPECTED_RAW_CAPABILITIES = {
+            "eco_mode": True, "eco_mode_2": False,
+            "breeze_control": True,
+            "heat_mode": True, "cool_mode": True, "dry_mode": True, "auto_mode": True,
+            "swing_horizontal": True, "swing_vertical": True,
+            "power_stats": False, "power_setting": False, "power_bcd": False,
+            "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
+        }
+        # 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(
+            "aa23ac00000000000303b5051e020101130201012202010019020100390001010000febe")
+        additional_resp = self._test_build_response(
+            TEST_ADDITIONAL_CAPABILITIES_RESPONSE)
+        additional_resp = cast(CapabilitiesResponse, additional_resp)
+
+        EXPECTED_ADDITIONAL_RAW_CAPABILITIES = {
+            "freeze_protection": True,
+            "fahrenheit": True,
+            "aux_electric_heat": False,
+            "self_clean": True,
+            "anion": 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 capabiltiies produced expected results
+        resp.merge(additional_resp)
+
+        EXPECTED_MERGED_RAW_CAPABILITIES = {
+            "eco_mode": True, "eco_mode_2": False,
+            "breeze_control": True,
+            "heat_mode": True, "cool_mode": True, "dry_mode": True, "auto_mode": True,
+            "swing_horizontal": True, "swing_vertical": True,
+            "power_stats": False, "power_setting": False, "power_bcd": False,
+            "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,
+            "freeze_protection": True,
+            "fahrenheit": True,
+            "aux_electric_heat": False,
+            "self_clean": True,
+            "anion": True
+        }
+        # Ensure raw decoded capabilities match
+        self.assertEqual(resp._capabilities, EXPECTED_MERGED_RAW_CAPABILITIES)
+
+        EXPECTED_CAPABILITIES = {
+            "swing_horizontal": True, "swing_vertical": True, "swing_both": True,
+            "dry_mode": True, "heat_mode": True, "cool_mode": True, "auto_mode": True,
+            "eco_mode": True, "turbo_mode": True, "freeze_protection_mode": True,
+            "fan_custom": True, "fan_silent": True, "fan_low": True,
+            "fan_medium": True,  "fan_high": True, "fan_auto": True,
+            "min_temperature": 16, "max_temperature": 30,
+            "display_control": False, "filter_reminder": False
+        }
+        # Check capabilities properties match
+        for prop in self.EXPECTED_PROPERTIES:
+            self.assertEqual(getattr(resp, prop),
+                             EXPECTED_CAPABILITIES[prop], prop)
+
 
 if __name__ == "__main__":
     unittest.main()