Repository: mill1000/midea-msmart
Pull request #114 (2024-03-14 15:40:07)

Accept reponses to properties commands with invalid CRCs
Originally reported here https://github.com/mill1000/midea-ac-py/issues/101, and also witnessed on a personal unit.

Some devices generate PropertiesResponses with invalid CRCs Likely a bug in the device itself.

This change attempts to detect these responses and disable CRC checks only when sending GetProperties and SetProperties commands.


Patch:
diff --git a/msmart/base_command.py b/msmart/base_command.py
deleted file mode 100644
index 80173cf..0000000
--- a/msmart/base_command.py
+++ /dev/null
@@ -1,71 +0,0 @@
-import logging
-from abc import ABC, abstractmethod
-
-import msmart.crc8 as crc8
-from msmart.const import DeviceType, FrameType
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class Command(ABC):
-    CONTROL_SOURCE = 0x2  # App control
-
-    _message_id = 0
-
-    def __init__(self, device_type: DeviceType, frame_type: FrameType) -> None:
-        self._device_type = device_type
-        self._frame_type = frame_type
-        self._protocol_version = 0
-
-    def tobytes(self) -> bytes:
-        # Create payload with message id
-        payload = self.payload + bytes([self._next_message_id()])
-
-        # Create payload with CRC appended
-        payload_crc = payload + bytes([crc8.calculate(payload)])
-
-        # Length includes header, payload and CRC
-        length = 10 + len(payload_crc)
-
-        # Build frame header
-        header = bytes([
-            # Start byte
-            0xAA,
-            # Length of payload and header
-            length,
-            # Device/appliance type
-            self._device_type,
-            # Frame checksum (sync?)
-            self._device_type ^ length,
-            # Reserved
-            0x00, 0x00,
-            # Frame ID
-            0x00,
-            # Frame protocol version
-            0x00,
-            # Device protocol version
-            self._protocol_version,
-            # Frame type
-            self._frame_type
-        ])
-
-        # Build frame from header and payload with CRC
-        frame = bytearray(header + payload_crc)
-
-        # Calculate total frame checksum
-        frame.append(Command.checksum(frame[1:]))
-
-        return bytes(frame)
-
-    def _next_message_id(self) -> int:
-        Command._message_id += 1
-        return Command._message_id & 0xFF
-
-    @property
-    @abstractmethod
-    def payload(self) -> bytes:
-        return bytes()
-
-    @classmethod
-    def checksum(cls, frame: bytes) -> int:
-        return (~sum(frame) + 1) & 0xFF
diff --git a/msmart/base_device.py b/msmart/base_device.py
index f99bdf1..235717b 100644
--- a/msmart/base_device.py
+++ b/msmart/base_device.py
@@ -2,8 +2,8 @@ import logging
 import time
 from typing import List, Optional
 
-from msmart.base_command import Command
 from msmart.const import DeviceType
+from msmart.frame import Frame
 from msmart.lan import LAN, AuthenticationError, Key, ProtocolError, Token
 
 _LOGGER = logging.getLogger(__name__)
@@ -25,7 +25,7 @@ class Device():
         self._supported = False
         self._online = False
 
-    async def _send_command(self, command: Command) -> Optional[List[bytes]]:
+    async def _send_command(self, command: Frame) -> Optional[List[bytes]]:
         """Send a command to the device and return any responses."""
 
         data = command.tobytes()
diff --git a/msmart/const.py b/msmart/const.py
index f1c125f..c76bce2 100644
--- a/msmart/const.py
+++ b/msmart/const.py
@@ -33,7 +33,7 @@ class DeviceType(IntEnum):
 
 class FrameType(IntEnum):
     UNKNOWN = 0
-    SET = 0x02
-    REQUEST = 0x03
+    CONTROL = 0x02
+    QUERY = 0x03
     REPORT = 0x04
     ABNORMAL_REPORT = 0x06
diff --git a/msmart/crc8.py b/msmart/crc8.py
index 5cc5eb6..69f8eec 100644
--- a/msmart/crc8.py
+++ b/msmart/crc8.py
@@ -37,5 +37,10 @@ _CRC8_854_TABLE = [
 def calculate(data: bytes) -> int:
     crc_value = 0
     for m in data:
-        crc_value = _CRC8_854_TABLE[(crc_value ^ m) & 0xFF]
+        k = crc_value ^ m
+        if k > 256:
+            k -= 256
+        if k < 0:
+            k += 256
+        crc_value = _CRC8_854_TABLE[k]
     return crc_value
diff --git a/msmart/device/AC/command.py b/msmart/device/AC/command.py
index 35d8943..6e838e6 100644
--- a/msmart/device/AC/command.py
+++ b/msmart/device/AC/command.py
@@ -8,8 +8,8 @@ from enum import IntEnum
 from typing import Callable, Collection, Mapping, Optional, Union
 
 import msmart.crc8 as crc8
-from msmart.base_command import Command
 from msmart.const import DeviceType, FrameType
+from msmart.frame import Frame
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -85,31 +85,57 @@ class TemperatureType(IntEnum):
     OUTDOOR = 0x3
 
 
+class Command(Frame):
+    """Base class for AC commands."""
+
+    CONTROL_SOURCE = 0x2  # App control
+
+    _message_id = 0
+
+    def __init__(self, frame_type: FrameType) -> None:
+        super().__init__(DeviceType.AIR_CONDITIONER, frame_type)
+
+    def tobytes(self, data: Union[bytes, bytearray] = bytes()) -> bytes:
+        # Append message ID to payload
+        # TODO Message ID in reference is just a random value
+        payload = data + bytes([self._next_message_id()])
+
+        # Append CRC
+        return super().tobytes(payload + bytes([crc8.calculate(payload)]))
+
+    def _next_message_id(self) -> int:
+        Command._message_id += 1
+        return Command._message_id & 0xFF
+
+
 class GetCapabilitiesCommand(Command):
+    """Command to query capabilities of the device."""
+
     def __init__(self, additional: bool = False) -> None:
-        super().__init__(DeviceType.AIR_CONDITIONER, frame_type=FrameType.REQUEST)
+        super().__init__(frame_type=FrameType.QUERY)
 
         self._additional = additional
 
-    @property
-    def payload(self) -> bytes:
+    def tobytes(self) -> bytes:  # pyright: ignore[reportIncompatibleMethodOverride] # nopep8
         if not self._additional:
             # Get capabilities
-            return bytes([0xB5, 0x01, 0x00])
+            payload = bytes([0xB5, 0x01, 0x00])
         else:
             # Get more capabilities
-            return bytes([0xB5, 0x01, 0x01, 0x1])
+            payload = bytes([0xB5, 0x01, 0x01, 0x1])
+        return super().tobytes(payload)
 
 
 class GetStateCommand(Command):
+    """Command to query basic state of the device."""
+
     def __init__(self) -> None:
-        super().__init__(DeviceType.AIR_CONDITIONER, frame_type=FrameType.REQUEST)
+        super().__init__(frame_type=FrameType.QUERY)
 
         self.temperature_type = TemperatureType.INDOOR
 
-    @property
-    def payload(self) -> bytes:
-        return bytes([
+    def tobytes(self) -> bytes:  # pyright: ignore[reportIncompatibleMethodOverride] # nopep8
+        return super().tobytes(bytes([
             # Get state
             0x41,
             # Unknown
@@ -122,12 +148,14 @@ class GetStateCommand(Command):
             0x00, 0x00, 0x00, 0x00,
             # Unknown
             0x03,
-        ])
+        ]))
 
 
 class SetStateCommand(Command):
+    """Command to set basic state of the device."""
+
     def __init__(self) -> None:
-        super().__init__(DeviceType.AIR_CONDITIONER, frame_type=FrameType.SET)
+        super().__init__(frame_type=FrameType.CONTROL)
 
         self.beep_on = True
         self.power_on = False
@@ -142,8 +170,7 @@ class SetStateCommand(Command):
         self.freeze_protection_mode = False
         self.follow_me = False
 
-    @property
-    def payload(self) -> bytes:
+    def tobytes(self) -> bytes:  # pyright: ignore[reportIncompatibleMethodOverride] # nopep8
         # Build beep and power status bytes
         beep = 0x40 if self.beep_on else 0
         power = 0x1 if self.power_on else 0
@@ -185,7 +212,7 @@ class SetStateCommand(Command):
         # Build alternate turbo byte
         freeze_protect = 0x80 if self.freeze_protection_mode else 0
 
-        return bytes([
+        return super().tobytes(bytes([
             # Set state
             0x40,
             # Beep and power state
@@ -215,22 +242,23 @@ class SetStateCommand(Command):
             freeze_protect,
             # Unknown
             0x00, 0x00,
-        ])
+        ]))
 
 
 class ToggleDisplayCommand(Command):
+    """Command to toggle the LED display of the device."""
+
     def __init__(self) -> None:
         # For whatever reason, toggle display uses a request type...
-        super().__init__(DeviceType.AIR_CONDITIONER, frame_type=FrameType.REQUEST)
+        super().__init__(frame_type=FrameType.QUERY)
 
         self.beep_on = True
 
-    @property
-    def payload(self) -> bytes:
+    def tobytes(self) -> bytes:  # pyright: ignore[reportIncompatibleMethodOverride] # nopep8
         # Set beep bit
         beep = 0x40 if self.beep_on else 0
 
-        return bytes([
+        return super().tobytes(bytes([
             # Get state
             0x41,
             # Beep and other flags
@@ -241,19 +269,18 @@ class ToggleDisplayCommand(Command):
             0x00, 0x00, 0x00, 0x00,
             0x00, 0x00, 0x00, 0x00,
             0x00, 0x00, 0x00, 0x00,
-        ])
+        ]))
 
 
 class GetPropertiesCommand(Command):
     """Command to query specific properties from the device."""
 
     def __init__(self, props: Collection[PropertyId]) -> None:
-        super().__init__(DeviceType.AIR_CONDITIONER, frame_type=FrameType.REQUEST)
+        super().__init__(frame_type=FrameType.QUERY)
 
         self._properties = props
 
-    @property
-    def payload(self) -> bytes:
+    def tobytes(self) -> bytes:  # pyright: ignore[reportIncompatibleMethodOverride] # nopep8
         payload = bytearray([
             0xB1,  # Property request
             len(self._properties),
@@ -262,19 +289,18 @@ class GetPropertiesCommand(Command):
         for prop in self._properties:
             payload += struct.pack("<H", prop)
 
-        return payload
+        return super().tobytes(payload)
 
 
 class SetPropertiesCommand(Command):
     """Command to set specific properties of the device."""
 
     def __init__(self, props: Mapping[PropertyId, Union[bytes, int]]) -> None:
-        super().__init__(DeviceType.AIR_CONDITIONER, frame_type=FrameType.SET)
+        super().__init__(frame_type=FrameType.CONTROL)
 
         self._properties = props
 
-    @property
-    def payload(self) -> bytes:
+    def tobytes(self) -> bytes:  # pyright: ignore[reportIncompatibleMethodOverride] # nopep8
         payload = bytearray([
             0xB0,  # Property request
             len(self._properties),
@@ -289,10 +315,12 @@ class SetPropertiesCommand(Command):
             payload += bytes([len(value)])
             payload += value
 
-        return payload
+        return super().tobytes(payload)
 
 
 class Response():
+    """Base class for AC responses."""
+
     def __init__(self, payload: memoryview) -> None:
         # Set ID and copy the payload
         self._id = payload[0]
@@ -307,30 +335,35 @@ class Response():
         return self._payload
 
     @classmethod
-    def validate(cls, frame: memoryview) -> None:
-        # Validate frame checksum
-        frame_checksum = Command.checksum(frame[1:-1])
-        if frame_checksum != frame[-1]:
-            raise InvalidResponseException(
-                f"Frame '{frame.hex()}' failed checksum. Received: 0x{frame[-1]:X}, Expected: 0x{frame_checksum:X}.")
+    def validate(cls, frame: memoryview, skip_crc: bool = False) -> None:
+        """Validate a response by checking the frame checksum and payload CRC."""
+
+        # Attempt to validate the frame
+        Frame.validate(frame)
+
+        # Exit early if not checking CRC
+        if skip_crc:
+            return
 
         # Extract frame payload to validate CRC/checksum
         payload = frame[10:-1]
 
         # Some devices use a CRC others seem to use a 2nd checksum
         payload_crc = crc8.calculate(payload[0:-1])
-        payload_checksum = Command.checksum(payload[0:-1])
+        payload_checksum = Frame.checksum(payload[0:-1])
 
         if payload_crc != payload[-1] and payload_checksum != payload[-1]:
             raise InvalidResponseException(
                 f"Payload '{payload.hex()}' failed CRC and checksum. Received: 0x{payload[-1]:X}, Expected: 0x{payload_crc:X} or 0x{payload_checksum:X}.")
 
     @classmethod
-    def construct(cls, frame: bytes) -> Union[StateResponse, CapabilitiesResponse, Response]:
+    def construct(cls, frame: bytes, skip_crc: bool = False) -> Response:
+        """Construct a response object from raw data."""
+
         # Build a memoryview of the frame for zero-copy slicing
         with memoryview(frame) as frame_mv:
             # Ensure frame is valid before parsing
-            Response.validate(frame_mv)
+            Response.validate(frame_mv, skip_crc)
 
             # Parse frame depending on id
             response_id = frame_mv[10]
@@ -346,6 +379,8 @@ class Response():
 
 
 class CapabilitiesResponse(Response):
+    """Response to capabilities query."""
+
     def __init__(self, payload: memoryview) -> None:
         super().__init__(payload)
 
@@ -614,6 +649,8 @@ class CapabilitiesResponse(Response):
 
 
 class StateResponse(Response):
+    """Response to state query."""
+
     def __init__(self, payload: memoryview) -> None:
         super().__init__(payload)
 
diff --git a/msmart/device/AC/device.py b/msmart/device/AC/device.py
index 504e4eb..d92d923 100644
--- a/msmart/device/AC/device.py
+++ b/msmart/device/AC/device.py
@@ -5,6 +5,7 @@ from typing import Any, List, Optional, Union, cast
 
 from msmart.base_device import Device
 from msmart.const import DeviceType
+from msmart.frame import InvalidFrameException
 from msmart.utils import MideaIntEnum
 
 from .command import (CapabilitiesResponse, GetCapabilitiesCommand,
@@ -67,6 +68,8 @@ class AirConditioner(Device):
         super().__init__(ip=ip, port=port, device_id=device_id,
                          device_type=DeviceType.AIR_CONDITIONER, **kwargs)
 
+        self._disable_properties_crc_check = False
+
         self._beep_on = False
         self._power_state = False
         self._target_temperature = 17.0
@@ -228,7 +231,7 @@ class AirConditioner(Device):
                           self.ip, self.port, response.payload.hex())
 
     async def _send_command_get_responses(self, command) -> List[Response]:
-        """Send a command and yield an iterator of valid response."""
+        """Send a command and return all valid responses."""
 
         responses = await super()._send_command(command)
 
@@ -240,12 +243,32 @@ class AirConditioner(Device):
         # Device is online if we received any response
         self._online = True
 
+        # Determine if CRCs will be checked
+        properties_command = isinstance(command,
+                                        (GetPropertiesCommand, SetPropertiesCommand))
+        skip_crc = self._disable_properties_crc_check and properties_command
+
         valid_responses = []
         for data in responses:
             try:
-                # Construct response from data
-                response = Response.construct(data)
-            except InvalidResponseException as e:
+                try:
+                    # Construct response from data
+                    response = Response.construct(data, skip_crc=skip_crc)
+                except InvalidResponseException as e:
+                    if not properties_command:
+                        _LOGGER.error(e)
+                        continue
+
+                    # Some devices return invalid CRCs on properties responses
+                    # Detect and disable CRC checks on these responses
+                    _LOGGER.info(
+                        "Disabling CRC check for properties commands.")
+                    self._disable_properties_crc_check = True
+
+                    # Construct the failing response again without CRC check
+                    response = Response.construct(data, skip_crc=True)
+
+            except InvalidFrameException as e:
                 _LOGGER.error(e)
                 continue
 
diff --git a/msmart/frame.py b/msmart/frame.py
new file mode 100644
index 0000000..459024c
--- /dev/null
+++ b/msmart/frame.py
@@ -0,0 +1,56 @@
+from typing import Union
+
+from msmart.const import DeviceType, FrameType
+
+
+class InvalidFrameException(Exception):
+    pass
+
+
+class Frame():
+
+    _HEADER_LENGTH = 10
+
+    def __init__(self, device_type: DeviceType, frame_type: FrameType) -> None:
+        self._device_type = device_type
+        self._frame_type = frame_type
+        self._protocol_version = 0
+
+    def tobytes(self, data: Union[bytes, bytearray] = bytes()) -> bytes:
+        # Build frame header
+        header = bytearray(self._HEADER_LENGTH)
+
+        # Start byte
+        header[0] = 0xAA
+
+        # Length of header and data
+        header[1] = len(data) + self._HEADER_LENGTH
+
+        # Device/appliance type
+        header[2] = self._device_type
+
+        # Device protocol version
+        header[8] = self._protocol_version
+
+        # Frame type
+        header[9] = self._frame_type
+
+        # Build frame from header and data
+        frame = bytearray(header + data)
+
+        # Calculate total frame checksum
+        frame.append(Frame.checksum(frame[1:]))
+
+        return bytes(frame)
+
+    @classmethod
+    def checksum(cls, frame: bytes) -> int:
+        return (~sum(frame) + 1) & 0xFF
+
+    @classmethod
+    def validate(cls, frame: memoryview) -> None:
+        # Validate frame checksum
+        checksum = Frame.checksum(frame[1:-1])
+        if checksum != frame[-1]:
+            raise InvalidFrameException(
+                f"Frame '{frame.hex()}' failed checksum. Received: 0x{frame[-1]:X}, Expected: 0x{checksum:X}.")


Test patch:
diff --git a/msmart/device/AC/test_command.py b/msmart/device/AC/test_command.py
index c963530..7c8752e 100644
--- a/msmart/device/AC/test_command.py
+++ b/msmart/device/AC/test_command.py
@@ -1,9 +1,14 @@
+import logging
 import unittest
 from typing import Union, cast
 
-from .command import (CapabilitiesResponse, CapabilityId, GetPropertiesCommand,
-                      PropertiesResponse, PropertyId, Response,
-                      SetPropertiesCommand, StateResponse)
+from msmart.const import DeviceType, FrameType
+from msmart.frame import Frame
+
+from .command import (CapabilitiesResponse, CapabilityId, Command,
+                      GetPropertiesCommand, GetStateCommand,
+                      InvalidResponseException, PropertiesResponse, PropertyId,
+                      Response, SetPropertiesCommand, StateResponse)
 
 
 class _TestResponseBase(unittest.TestCase):
@@ -26,6 +31,40 @@ class _TestResponseBase(unittest.TestCase):
             self.assertHasAttr(obj, attr)
 
 
+class TestCommand(unittest.TestCase):
+
+    def test_frame(self) -> None:
+        """Test that we frame a command properly."""
+
+        EXPECTED_PAYLOAD = bytes.fromhex(
+            "418100ff03ff00020000000000000000000000000311f4")
+
+        # Override message id to match test data
+        Command._message_id = 0x10
+
+        # Build frame from command
+        command = GetStateCommand()
+        frame = command.tobytes()
+        self.assertIsNotNone(frame)
+
+        # Assert that frame is valid
+        with memoryview(frame) as frame_mv:
+            Frame.validate(frame_mv)
+
+        # Check frame payload to ensure it matches expected
+        self.assertEqual(frame[10:-1], EXPECTED_PAYLOAD)
+
+        # Check length byte
+        self.assertEqual(frame[1], len(
+            EXPECTED_PAYLOAD) + Frame._HEADER_LENGTH)
+
+        # Check device type
+        self.assertEqual(frame[2], DeviceType.AIR_CONDITIONER)
+
+        # Check frame type
+        self.assertEqual(frame[9], FrameType.QUERY)
+
+
 class TestStateResponse(_TestResponseBase):
     """Test device state response messages."""
 
@@ -307,13 +346,18 @@ class TestCapabilitiesResponse(_TestResponseBase):
     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
+        # Test case includes an unknown capability 0x40
+        # Suppress any warnings from capability parsing
+        level = logging.getLogger("msmart").getEffectiveLevel()
+        logging.getLogger("msmart").setLevel(logging.ERROR)
+
         TEST_CAPABILITIES_RESPONSE = bytes.fromhex(
             "aa3dac00000000000203b50a12020101180001001402010115020101160201001a020101100201011f020100250207203c203c203c00400001000100c83a")
+        resp = self._test_build_response(TEST_CAPABILITIES_RESPONSE)
+        resp = cast(CapabilitiesResponse, resp)
 
-        # Test case includes an unknown capability 0x40 that generates a warning
-        with self.assertLogs("msmart") as log:
-            resp = self._test_build_response(TEST_CAPABILITIES_RESPONSE)
-            resp = cast(CapabilitiesResponse, resp)
+        # Restore original level
+        logging.getLogger("msmart").setLevel(level)
 
         EXPECTED_RAW_CAPABILITIES = {
             "eco_mode": True, "eco_mode_2": False, "silky_cool": False,
@@ -431,13 +475,18 @@ class TestCapabilitiesResponse(_TestResponseBase):
         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 case includes an unknown capability 0x40
+        # Suppress any warnings from capability parsing
+        level = logging.getLogger("msmart").getEffectiveLevel()
+        logging.getLogger("msmart").setLevel(logging.ERROR)
+
         TEST_CAPABILITIES_RESPONSE = bytes.fromhex(
             "aa3dac00000000000303b50a12020101430001011402010115020101160201001a020101100201011f020103250207203c203c203c05400001000100c805")
+        resp = self._test_build_response(TEST_CAPABILITIES_RESPONSE)
+        resp = cast(CapabilitiesResponse, resp)
 
-        # Test case includes an unknown capability 0x40 that generates a warning
-        with self.assertLogs("msmart") as log:
-            resp = self._test_build_response(TEST_CAPABILITIES_RESPONSE)
-            resp = cast(CapabilitiesResponse, resp)
+        # Restore original level
+        logging.getLogger("msmart").setLevel(level)
 
         EXPECTED_RAW_CAPABILITIES = {
             "eco_mode": True, "eco_mode_2": False,
@@ -531,7 +580,7 @@ class TestGetPropertiesCommand(unittest.TestCase):
         command = GetPropertiesCommand(PROPS)
 
         # Fetch payload
-        payload = command.payload
+        payload = command.tobytes()[10:-1]
 
         # Assert payload header looks correct
         self.assertEqual(payload[0], 0xB1)
@@ -554,7 +603,7 @@ class TestSetPropertiesCommand(unittest.TestCase):
         command = SetPropertiesCommand(PROPS)
 
         # Fetch payload
-        payload = command.payload
+        payload = command.tobytes()[10:-1]
 
         # Assert payload header looks correct
         self.assertEqual(payload[0], 0xB0)
@@ -619,6 +668,32 @@ class TestPropertiesResponse(_TestResponseBase):
         # Check state
         self.assertEqual(resp.swing_horizontal_angle, 50)
 
+    def test_properties_response_invalid_crc(self) -> None:
+        """Test we decode a properties response sent with an invalid CRC correctly."""
+        # https://github.com/mill1000/midea-ac-py/issues/101#issuecomment-1994824924
+        TEST_RESPONSE = bytes.fromhex(
+            "aa14ac00000000000303b10109000001003c000042")
+
+        # Assert that constructing with CRC checks results in an exception
+        with self.assertRaises(InvalidResponseException):
+            resp = Response.construct(TEST_RESPONSE, skip_crc=False)
+
+        # Create response without checking CRC and assert it's expected type
+        resp = Response.construct(TEST_RESPONSE, skip_crc=True)
+
+        self.assertIsNotNone(resp)
+        self.assertEqual(type(resp), PropertiesResponse)
+        resp = cast(PropertiesResponse, resp)
+
+        EXPECTED_RAW_PROPERTIES = {
+            PropertyId.SWING_UD_ANGLE: 0,
+        }
+        # Ensure raw decoded properties match
+        self.assertEqual(resp._properties, EXPECTED_RAW_PROPERTIES)
+
+        # Check state
+        self.assertEqual(resp.swing_vertical_angle, 0)
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/msmart/device/AC/test_device.py b/msmart/device/AC/test_device.py
index 65ba3d0..41578de 100644
--- a/msmart/device/AC/test_device.py
+++ b/msmart/device/AC/test_device.py
@@ -1,6 +1,8 @@
 import unittest
+from unittest.mock import patch
 
-from .command import PropertiesResponse, Response, StateResponse
+from .command import (GetPropertiesCommand, GetStateCommand,
+                      PropertiesResponse, PropertyId, Response, StateResponse)
 from .device import AirConditioner as AC
 
 
@@ -183,5 +185,68 @@ class TestUpdateStateFromResponse(unittest.TestCase):
         self.assertEqual(device.vertical_swing_angle, AC.SwingAngle.OFF)
 
 
+class TestSendCommand(unittest.IsolatedAsyncioTestCase):
+
+    @patch("msmart.device.Device._send_command")
+    async def test_response_ignore_bad_crc(self, mock_send_command) -> None:
+        """Test that bad CRCs are ignored when in response to properties commands."""
+        TEST_RESPONSE_BAD_CRC = bytes.fromhex(
+            "aa14ac00000000000303b10109000001003c000042")
+        TEST_RESPONSE_BAD_CHECKSUM = bytes.fromhex(
+            "aa14ac00000000000303b10109000001003c0000FF")
+
+        # Set return value of mocked method
+        mock_send_command.return_value = [TEST_RESPONSE_BAD_CRC]
+
+        # Create a dummy device
+        device = AC(0, 0, 0)
+
+        # Assert we start not ignoring CRCs
+        self.assertEqual(device._disable_properties_crc_check, False)
+
+        # Send a properties command
+        command = GetPropertiesCommand([PropertyId.SWING_UD_ANGLE])
+        responses = await device._send_command_get_responses(command)
+
+        # Assert that responses are received
+        self.assertTrue(responses)
+
+        # Assert we start are now ignoring CRCs
+        self.assertEqual(device._disable_properties_crc_check, True)
+
+        # Check that we still check CRCs on non-properties commands
+        with self.assertLogs("msmart") as log:
+            command = GetStateCommand()
+            responses = await device._send_command_get_responses(command)
+
+            # Assert that no responses received
+            self.assertFalse(responses)
+
+        # Assert that an error was generated
+        self.assertRegex(log.output[0], "^ERROR:.*Payload.*failed CRC")
+
+        # Change mocked return to an invalid frame
+        mock_send_command.return_value = [TEST_RESPONSE_BAD_CHECKSUM]
+
+        # Assert that checksums are always checked regardless of command
+        with self.assertLogs("msmart") as log:
+            command = GetStateCommand()
+            responses = await device._send_command_get_responses(command)
+
+            # Assert that no responses received
+            self.assertFalse(responses)
+
+            # Send a properties command
+            command = GetPropertiesCommand([PropertyId.SWING_UD_ANGLE])
+            responses = await device._send_command_get_responses(command)
+
+            # Assert that no responses received
+            self.assertFalse(responses)
+
+        # Assert that an error was generated
+        self.assertRegex(log.output[0], "^ERROR:.*Frame.*failed checksum")
+        self.assertRegex(log.output[1], "^ERROR:.*Frame.*failed checksum")
+
+
 if __name__ == "__main__":
     unittest.main()