From 367872adc44717e3eef79858414540aa3a9cb542 Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Sat, 19 Jul 2025 17:02:57 +0800 Subject: [PATCH 01/15] Add __init__.py for local dev --- src/airtouch2/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/airtouch2/__init__.py diff --git a/src/airtouch2/__init__.py b/src/airtouch2/__init__.py new file mode 100644 index 0000000..b843923 --- /dev/null +++ b/src/airtouch2/__init__.py @@ -0,0 +1 @@ +# force Python to recognize airtouch2 as a local pacakge From 086e4424de76d8714dca27dee4209bdafdbb70d8 Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Mon, 21 Jul 2025 17:01:13 +0800 Subject: [PATCH 02/15] Added new ControlStatusSubType and new _send_ack_response method. --- src/airtouch2/at2plus/At2PlusClient.py | 162 ++++++++++++++---- .../protocol/at2plus/control_status_common.py | 4 + .../protocol/at2plus/message_common.py | 1 + 3 files changed, 135 insertions(+), 32 deletions(-) diff --git a/src/airtouch2/at2plus/At2PlusClient.py b/src/airtouch2/at2plus/At2PlusClient.py index e074d7b..10c9197 100644 --- a/src/airtouch2/at2plus/At2PlusClient.py +++ b/src/airtouch2/at2plus/At2PlusClient.py @@ -76,39 +76,65 @@ async def handle_one_message(self) -> None: _LOGGER.warning("Reading message failed") return - if message.header.type == MessageType.CONTROL_STATUS: - subheader = ControlStatusSubHeader.from_buffer(message.data_buffer) - if subheader.sub_type == ControlStatusSubType.AC_STATUS: - status_message = AcStatusMessage.from_bytes( - message.data_buffer.read_bytes(subheader.subdata_length.total())) - self._task_creator(self._handle_status_message(status_message)) - elif subheader.sub_type == ControlStatusSubType.GROUP_STATUS: - group_status_message = GroupStatusMessage.from_bytes( - message.data_buffer.read_bytes(subheader.subdata_length.total())) - self._task_creator(self._handle_group_status_message(group_status_message)) + try: + if message.header.type == MessageType.CONTROL_STATUS: + try: + subheader = ControlStatusSubHeader.from_buffer(message.data_buffer) + if subheader.sub_type == ControlStatusSubType.AC_STATUS: + status_message = AcStatusMessage.from_bytes( + message.data_buffer.read_bytes(subheader.subdata_length.total())) + self._task_creator(self._handle_status_message(status_message)) + elif subheader.sub_type == ControlStatusSubType.GROUP_STATUS: + group_status_message = GroupStatusMessage.from_bytes( + message.data_buffer.read_bytes(subheader.subdata_length.total())) + self._task_creator(self._handle_group_status_message(group_status_message)) + else: + # Handle unknown control status subtypes gracefully + _LOGGER.debug(f"Unknown control status subtype: 0x{subheader.sub_type:02x}") + _LOGGER.debug(f"Data: {message.data_buffer.to_bytes().hex(':')}") + + except ValueError as e: + # This catches enum parsing errors for unknown subtypes + _LOGGER.debug(f"Unknown control status message type: {e}") + _LOGGER.debug(f"Raw data: {message.data_buffer.to_bytes().hex(':')}") + + # Try to extract the subtype and send an ACK + raw_data = message.data_buffer.to_bytes() + if len(raw_data) > 0: + unknown_subtype = raw_data[0] + _LOGGER.debug(f"Sending ACK for unknown subtype: 0x{unknown_subtype:02x}") + await self._send_ack_response(unknown_subtype) + + elif message.header.type == MessageType.EXTENDED: + subheader = ExtendedSubHeader.from_buffer(message.data_buffer) + if subheader.sub_type == ExtendedMessageSubType.ABILITY: + ability_message_bytes = message.data_buffer.read_remaining() + _LOGGER.debug(f"Creating ability message from {len(ability_message_bytes)} bytes") + ability = AcAbilityMessage.from_bytes(ability_message_bytes) + await self._ability_message_queue.put(ability) + elif subheader.sub_type == ExtendedMessageSubType.GROUP_NAME: + group_names_subdata = message.data_buffer.read_remaining() + for id, name in group_names_from_subdata(group_names_subdata).items(): + self.groups_by_id[id]._update_name(name) + elif subheader.sub_type == ExtendedMessageSubType.ERROR: + # NYI + pass + else: + _LOGGER.debug(f"Unknown extended message subtype: 0x{subheader.sub_type:02x}") + _LOGGER.debug(f"Data: {message.data_buffer.to_bytes().hex(':')}") else: - _LOGGER.warning( - f"Unknown status message type: subtype={subheader.sub_type}, data={message.data_buffer.to_bytes().hex(':')}") - elif message.header.type == MessageType.EXTENDED: - subheader = ExtendedSubHeader.from_buffer(message.data_buffer) - if subheader.sub_type == ExtendedMessageSubType.ABILITY: - ability_message_bytes = message.data_buffer.read_remaining() - _LOGGER.debug(f"Creating ability message from {len(ability_message_bytes)} bytes") - ability = AcAbilityMessage.from_bytes(ability_message_bytes) - await self._ability_message_queue.put(ability) - elif subheader.sub_type == ExtendedMessageSubType.GROUP_NAME: - group_names_subdata = message.data_buffer.read_remaining() - for id, name in group_names_from_subdata(group_names_subdata).items(): - self.groups_by_id[id]._update_name(name) - elif subheader.sub_type == ExtendedMessageSubType.ERROR: - # NYI - pass - else: - _LOGGER.warning( - f"Unknown extended message type: subtype={subheader.sub_type}, data={message.data_buffer.to_bytes().hex(':')}") - else: - _LOGGER.warning( - f"Unknown message type, header={message.header.to_bytes().hex(':')}, data={message.data_buffer.to_bytes().hex(':')}") + # Handle completely unknown message types + _LOGGER.debug(f"Unknown message type: 0x{message.header.type:02x}") + _LOGGER.debug(f"Header: {message.header.to_bytes().hex(':')}") + _LOGGER.debug(f"Data: {message.data_buffer.to_bytes().hex(':')}") + + + except Exception as e: + _LOGGER.error(f"Error processing message: {e}", exc_info=True) + _LOGGER.debug(f"Message header: {message.header.to_bytes().hex(':')}") + _LOGGER.debug(f"Message data: {message.data_buffer.to_bytes().hex(':')}") + + async def _read_magic(self) -> bytes: """Search for the two header magic bytes""" @@ -224,3 +250,75 @@ async def _handle_group_status_message(self, message: GroupStatusMessage): if request_names: _LOGGER.debug("Requesting all group names") await self._client.send(RequestGroupNamesMessage()) + + async def _send_ack_response(self, msg_type: int, message_type: bool = False): + """Send a proper acknowledgment response based on protocol documentation.""" + try: + if msg_type == 0x2B: # Extended Status - Timer/System related + # Based on doc offsets 0x15A-0x161 (timer status) + ack_data = bytes([ + 0x2B, 0x00, # Subtype + reserved + 0x00, 0x04, # Normal data length (4 bytes) + 0x00, 0x00, # Repeat data length (0) + 0x00, 0x00, # Repeat count (0) + 0x00, 0x00, 0x00, 0x00 # Basic timer ACK data + ]) + + elif msg_type == 0x10: # AC Status Extended + # Based on doc offsets 0x162-0x163 (AC status) + ack_data = bytes([ + 0x10, 0x00, # Subtype + reserved + 0x00, 0x02, # Normal data length (2 bytes) + 0x00, 0x00, # Repeat data length (0) + 0x00, 0x00, # Repeat count (0) + 0x00, 0x00 # Basic AC status ACK + ]) + + elif msg_type == 0x31: # Favorite Status + # Acknowledge favorite status + ack_data = bytes([ + 0x31, 0x00, # Subtype + reserved + 0x00, 0x01, # Normal data length (1 byte) + 0x00, 0x00, # Repeat data length (0) + 0x00, 0x00, # Repeat count (0) + 0x00 # Simple favorite ACK + ]) + + elif msg_type == 0x40: # Zone Status + # Based on doc zone percentages (0x114-0x123) + ack_data = bytes([ + 0x40, 0x00, # Subtype + reserved + 0x00, 0x01, # Normal data length (1 byte) + 0x00, 0x00, # Repeat data length (0) + 0x00, 0x00, # Repeat count (0) + 0x00 # Simple zone ACK + ]) + + else: + # Generic acknowledgment for other unknown types + ack_data = bytes([ + msg_type, 0x00, # Subtype + reserved + 0x00, 0x00, # Normal data length (0) + 0x00, 0x00, # Repeat data length (0) + 0x00, 0x00 # Repeat count (0) + ]) + + # Create proper header for control status message + from airtouch2.protocol.at2plus.message_common import AddressMsgType + header = Header( + address_msg_type=AddressMsgType.NORMAL, + type=MessageType.CONTROL_STATUS, + data_length=len(ack_data) + ) + + # Create and send the message + buffer = Buffer(len(ack_data)) + buffer.append_bytes(ack_data) + + message = Message(header, buffer) + await self._client.send(message) + + _LOGGER.debug(f"Sent protocol-compliant ACK for subtype 0x{msg_type:02x}") + + except Exception as e: + _LOGGER.error(f"Failed to send ACK for 0x{msg_type:02x}: {e}", exc_info=True) diff --git a/src/airtouch2/protocol/at2plus/control_status_common.py b/src/airtouch2/protocol/at2plus/control_status_common.py index 09fd222..9d136d9 100644 --- a/src/airtouch2/protocol/at2plus/control_status_common.py +++ b/src/airtouch2/protocol/at2plus/control_status_common.py @@ -35,6 +35,10 @@ class ControlStatusSubType(IntEnum): GROUP_STATUS = 0x21 AC_CONTROL = 0x22 AC_STATUS = 0x23 + AC_STATUS_EXTENDED = 0x10 # Custom AC status format + FAVORITE_STATUS = 0x31 # Favorite names and active status + EXTENDED_STATUS = 0x2B # Extended system status + ZONE_STATUS = 0x40 # Zone control status @dataclass diff --git a/src/airtouch2/protocol/at2plus/message_common.py b/src/airtouch2/protocol/at2plus/message_common.py index 9cf87a3..978a433 100644 --- a/src/airtouch2/protocol/at2plus/message_common.py +++ b/src/airtouch2/protocol/at2plus/message_common.py @@ -48,6 +48,7 @@ class MessageType(IntEnum): UNSET = 0 CONTROL_STATUS = 0xC0 EXTENDED = 0x1F + UNKNOWN_27 = 0x27 # Unknown message type 39 decimal class Header(Serializable): From ff91f6e48c2743abeec87a81bdfc65678a72caf1 Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Mon, 21 Jul 2025 17:08:16 +0800 Subject: [PATCH 03/15] airtouch2plus_protocol_notes --- re_response/airtouch2plus_protocol_notes.md | 248 ++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 re_response/airtouch2plus_protocol_notes.md diff --git a/re_response/airtouch2plus_protocol_notes.md b/re_response/airtouch2plus_protocol_notes.md new file mode 100644 index 0000000..6c0dd25 --- /dev/null +++ b/re_response/airtouch2plus_protocol_notes.md @@ -0,0 +1,248 @@ +# AirTouch2+ Extended Protocol Documentation + +## Overview + +This document describes additional protocol messages discovered in AirTouch2+ controllers that extend beyond the standard documented protocol. These messages were reverse-engineered from live controller communications and cross-referenced with available AirTouch2 protocol documentation. + +## Background + +The AirTouch2+ controller sends several undocumented message types that must be properly acknowledged to maintain stable communication. Without proper handling of these messages, the controller may refuse new connections from both Home Assistant integrations and mobile applications. + +## Message Types Identified + +### Control Status Messages (0xC0) + +#### 0x10 - AC Status Extended + +**Purpose**: Provides extended AC status information beyond the standard 0x23 AC Status message. + +**Structure**: +``` +Byte 0: 0x10 (Message subtype) +Byte 1: 0x00 (Reserved) +Bytes 2-7: Standard control status subheader +Byte 8: AC ID +Byte 9: Status byte (bit 7 = power on/off) +Byte 10: Mode and fan speed (lower 4 bits = mode, upper 4 bits = fan) +Byte 11: Additional flags +Byte 12: Temperature value (divide by 10 for actual temperature) +``` + +**Correlation**: Maps to offsets 0x162-0x163 in AirTouch2 documentation (AC1/AC2 status) + +**Required Acknowledgment**: +``` +2B 00 00 02 00 00 00 00 00 00 +``` + +#### 0x2B - Extended Status + +**Purpose**: System status including available favorites, timers, and system counters. + +**Structure**: +``` +Byte 0: 0x2B (Message subtype) +Byte 1: 0x00 (Reserved) +Bytes 2-7: Standard control status subheader +Byte 8: Number of entries +Byte 9: Number of sections +Bytes 10+: Repeating 4-byte sections: + - Byte 0: ID (0x80-0x83 = favorites, 0x90-0x91 = system counters) + - Byte 1: Value + - Bytes 2-3: Flags/counter data +``` + +**Favorites Section (0x80-0x83)**: +- ID byte matches value byte when favorite is available +- Flags provide additional status information +- Favorite 0 = 0x80, Favorite 1 = 0x81, etc. + +**System Counters (0x90-0x91)**: +- System status and operational counters +- Flags contain 16-bit counter values + +**Correlation**: Relates to timer offsets 0x15A-0x161 in AirTouch2 documentation + +**Required Acknowledgment**: +``` +2B 00 00 04 00 00 00 00 00 00 00 00 +``` + +#### 0x31 - Favorite Status + +**Purpose**: Provides names and active status of configured favorite scenes. + +**Structure**: +``` +Byte 0: 0x31 (Message subtype) +Byte 1: 0x00 (Reserved) +Bytes 2-7: Standard control status subheader (typically 00 02 00 0B 00 04) +Byte 8: Active selector (bitmap indicating which favorite is active) +Bytes 9+: Favorite entries +``` + +**Active Selector Bitmap**: +- Bit 0 (0x01): Favorite 0 active +- Bit 1 (0x02): Favorite 1 active +- Bit 2 (0x04): Favorite 2 active +- Bit 3 (0x08): Favorite 3 active + +**Favorite Entry Format**: + +*Favorite 0 (special case - no ID byte)*: +``` +Status Byte | Separator | Name Bytes | Null Padding +``` + +*Favorites 1-3*: +``` +Status Byte | Separator | Favorite ID | Name Bytes | Null Padding +``` + +**Example Parsing**: +``` +31:00:00:02:00:0b:00:04:01:08:00:6d:61:69:6e:00:00:00:00:1b:00:01:67:79:6d:00:00:00:00:00:00 +│ │ │ │ └─────────┘ │ │ │ └───────────┘ +│ │ │ │ "main" │ │ │ "gym" +│ │ │ │ │ │ │ +│ │ │ └─ separator │ │ └─ favorite ID (1) +│ │ └─ status byte │ └─ separator +│ └─ active selector (0x01 = fav 0) └─ status byte +└─ message header +``` + +**Required Acknowledgment**: +``` +31 00 00 01 00 00 00 00 00 +``` + +#### 0x40 - Zone Status + +**Purpose**: Provides zone control percentages and status. + +**Structure**: +``` +Byte 0: 0x40 (Message subtype) +Byte 1: 0x00 (Reserved) +Bytes 2-7: Standard control status subheader +Bytes 8+: Repeating 8-byte zone blocks: + - Byte 0: Zone ID + - Byte 1: Percentage value + - Bytes 2-7: Additional zone flags/status +``` + +**Correlation**: Maps to zone percentage offsets 0x114-0x123 in AirTouch2 documentation + +**Required Acknowledgment**: +``` +40 00 00 01 00 00 00 00 00 +``` + +### Unknown Message Types + +#### 0x27 - Unknown Message Type + +**Purpose**: Unknown function, appears to be short status messages. + +**Observed Data**: Simple payloads (typically single bytes like 0x00, 0x01) + +**Required Handling**: Log and ignore - no acknowledgment appears necessary. + +## Protocol Requirements + +### Acknowledgment Format + +All control status acknowledgments follow this structure: + +``` +Header (8 bytes): + Byte 0: Message subtype (matches incoming message) + Byte 1: 0x00 (Reserved) + Bytes 2-3: Normal data length (big-endian) + Bytes 4-5: Repeat data length (big-endian, typically 0x00 0x00) + Bytes 6-7: Repeat count (big-endian, typically 0x00 0x00) + +Data (variable length): + Minimal response data as specified per message type +``` + +### Message Header Requirements + +- **Address Message Type**: `AddressMsgType.NORMAL` (0x80) +- **Message Type**: `MessageType.CONTROL_STATUS` (0xC0) +- **CRC16**: Proper MODBUS CRC16 checksum required + +### Critical Implementation Notes + +1. **Connection Stability**: The controller will refuse new connections if these messages are not properly acknowledged. + +2. **Timing**: Acknowledgments should be sent immediately upon receiving these message types. + +3. **Error Handling**: Unknown message types should be logged but not cause processing failures. + +4. **Enum Handling**: Add these message types to your protocol enums to prevent ValueError exceptions: + +```python +class ControlStatusSubType(IntEnum): + # ... existing values ... + AC_STATUS_EXTENDED = 0x10 + EXTENDED_STATUS = 0x2B + FAVORITE_STATUS = 0x31 + ZONE_STATUS = 0x40 + +class MessageType(IntEnum): + # ... existing values ... + UNKNOWN_27 = 0x27 +``` + +## Implementation Example + +```python +async def _send_ack_response(self, msg_type: int): + """Send protocol-compliant acknowledgment.""" + if msg_type == 0x2B: # Extended Status + ack_data = bytes([0x2B, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + elif msg_type == 0x10: # AC Status Extended + ack_data = bytes([0x10, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + elif msg_type == 0x31: # Favorite Status + ack_data = bytes([0x31, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]) + elif msg_type == 0x40: # Zone Status + ack_data = bytes([0x40, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]) + else: + ack_data = bytes([msg_type, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + + # Create and send message with proper headers and checksum + # ... implementation details ... +``` + +## Future Enhancement Opportunities + +### Favorite Scene Control +The 0x31 message provides complete favorite scene information that could be used to: +- Display available scenes in Home Assistant +- Show which scene is currently active +- Potentially trigger scene changes (requires further reverse engineering) + +### Enhanced Zone Management +The 0x40 message provides detailed zone status that could enable: +- Individual zone percentage display +- Zone-specific status indicators +- Advanced zone control features + +### System Monitoring +The 0x2B message contains system counters and status that could provide: +- System health monitoring +- Usage statistics +- Advanced diagnostics + +## References + +- AirTouch2 Protocol Documentation (offsets 0x158-0x18A) +- Live protocol captures from AirTouch2+ controller communications +- Reverse engineering analysis of controller behavior + +## Changelog + +- **Initial Version**: Documented 0x2B, 0x10, 0x31, 0x40, and 0x27 message types +- **Protocol Requirements**: Added acknowledgment format specifications +- **Implementation Guide**: Added code examples and enum definitions \ No newline at end of file From 98d3bbed0e7de5d2aaaa4be96bcd69765b0d6558 Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Sat, 26 Jul 2025 17:19:37 +0800 Subject: [PATCH 04/15] fix header for control status message call --- src/airtouch2/at2plus/At2PlusClient.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/airtouch2/at2plus/At2PlusClient.py b/src/airtouch2/at2plus/At2PlusClient.py index 10c9197..4faba7e 100644 --- a/src/airtouch2/at2plus/At2PlusClient.py +++ b/src/airtouch2/at2plus/At2PlusClient.py @@ -306,10 +306,10 @@ async def _send_ack_response(self, msg_type: int, message_type: bool = False): # Create proper header for control status message from airtouch2.protocol.at2plus.message_common import AddressMsgType header = Header( - address_msg_type=AddressMsgType.NORMAL, - type=MessageType.CONTROL_STATUS, - data_length=len(ack_data) - ) + AddressMsgType.NORMAL, # positional argument + MessageType.CONTROL_STATUS, # positional argument + len(ack_data) # positional argument +) # Create and send the message buffer = Buffer(len(ack_data)) From 00ab0cbc5e70f2219a323a9e978bfb6deef4c192 Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Sun, 27 Jul 2025 09:06:13 +0800 Subject: [PATCH 05/15] Added support for unknown message 0x45 --- src/airtouch2/at2plus/At2PlusClient.py | 14 ++++++++++++++ .../protocol/at2plus/control_status_common.py | 1 + 2 files changed, 15 insertions(+) diff --git a/src/airtouch2/at2plus/At2PlusClient.py b/src/airtouch2/at2plus/At2PlusClient.py index 4faba7e..7caa446 100644 --- a/src/airtouch2/at2plus/At2PlusClient.py +++ b/src/airtouch2/at2plus/At2PlusClient.py @@ -88,6 +88,10 @@ async def handle_one_message(self) -> None: group_status_message = GroupStatusMessage.from_bytes( message.data_buffer.read_bytes(subheader.subdata_length.total())) self._task_creator(self._handle_group_status_message(group_status_message)) + elif subheader.sub_type == ControlStatusSubType.UNKNOWN_45: # Add this block + _LOGGER.debug(f"Unknown status type 0x45: {message.data_buffer.to_bytes().hex(':')}") + # Send ACK + await self._send_ack_response(0x45) else: # Handle unknown control status subtypes gracefully _LOGGER.debug(f"Unknown control status subtype: 0x{subheader.sub_type:02x}") @@ -293,6 +297,16 @@ async def _send_ack_response(self, msg_type: int, message_type: bool = False): 0x00, 0x00, # Repeat count (0) 0x00 # Simple zone ACK ]) + + elif msg_type == 0x45: # Unknown status type + # Basic acknowledgment for unknown type 0x45 + ack_data = bytes([ + 0x45, 0x00, # Subtype + reserved + 0x00, 0x01, # Normal data length (1 byte) + 0x00, 0x00, # Repeat data length (0) + 0x00, 0x00, # Repeat count (0) + 0x00 # Simple ACK + ]) else: # Generic acknowledgment for other unknown types diff --git a/src/airtouch2/protocol/at2plus/control_status_common.py b/src/airtouch2/protocol/at2plus/control_status_common.py index 9d136d9..ce18522 100644 --- a/src/airtouch2/protocol/at2plus/control_status_common.py +++ b/src/airtouch2/protocol/at2plus/control_status_common.py @@ -39,6 +39,7 @@ class ControlStatusSubType(IntEnum): FAVORITE_STATUS = 0x31 # Favorite names and active status EXTENDED_STATUS = 0x2B # Extended system status ZONE_STATUS = 0x40 # Zone control status + UNKNOWN_45 = 0x45 # Add this new one @dataclass From 24345fcf0ee923c538206ce974bead38d467c1c8 Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Sat, 2 Aug 2025 17:07:53 +0800 Subject: [PATCH 06/15] Fix: Correct checksum calculation for AirTouch2+ messages The AirTouch2+ controller requires the CRC checksum to be calculated from the address field to the end of the data payload. The previous implementation used an incorrect offset, resulting in invalid checksums and causing the controller to reject messages and lock out the client. This commit corrects the checksum calculation in `message_common.py` to use the proper starting offset (`CommonMessageOffsets.ADDRESS`). It also adds the `get_data_from_offset` method to the `Buffer` class, which was necessary to support the corrected checksum logic and resolve a subsequent `AttributeError`. --- src/airtouch2/at2plus/At2PlusClient.py | 267 ++++++++++++------ src/airtouch2/common/Buffer.py | 5 + .../protocol/at2plus/message_common.py | 69 +++-- 3 files changed, 239 insertions(+), 102 deletions(-) diff --git a/src/airtouch2/at2plus/At2PlusClient.py b/src/airtouch2/at2plus/At2PlusClient.py index 7caa446..0e4737d 100644 --- a/src/airtouch2/at2plus/At2PlusClient.py +++ b/src/airtouch2/at2plus/At2PlusClient.py @@ -5,28 +5,54 @@ from airtouch2.at2plus.At2PlusAircon import At2PlusAircon from airtouch2.at2plus.At2PlusGroup import At2PlusGroup from airtouch2.common.NetClient import NetClient -from airtouch2.protocol.at2plus.control_status_common import ControlStatusSubHeader, ControlStatusSubType -from airtouch2.protocol.at2plus.extended_common import ExtendedMessageSubType, ExtendedSubHeader -from airtouch2.protocol.at2plus.message_common import HEADER_LENGTH, HEADER_MAGIC, Header, Message, MessageType -from airtouch2.protocol.at2plus.messages.AcAbilityMessage import AcAbility, AcAbilityMessage, RequestAcAbilityMessage +from airtouch2.protocol.at2plus.control_status_common import ( + ControlStatusSubHeader, + ControlStatusSubType, +) +from airtouch2.protocol.at2plus.extended_common import ( + ExtendedMessageSubType, + ExtendedSubHeader, +) +from airtouch2.protocol.at2plus.message_common import ( + HEADER_LENGTH, + HEADER_MAGIC, + Header, + Message, + MessageType, +) +from airtouch2.protocol.at2plus.messages.AcAbilityMessage import ( + AcAbility, + AcAbilityMessage, + RequestAcAbilityMessage, +) from airtouch2.protocol.at2plus.messages.AcStatus import AcStatusMessage from airtouch2.common.Buffer import Buffer from airtouch2.protocol.at2plus.crc16_modbus import crc16 from airtouch2.common.interfaces import Callback, Serializable, TaskCreator -from airtouch2.protocol.at2plus.messages.GroupNames import RequestGroupNamesMessage, group_names_from_subdata +from airtouch2.protocol.at2plus.messages.GroupNames import ( + RequestGroupNamesMessage, + group_names_from_subdata, +) from airtouch2.protocol.at2plus.messages.GroupStatus import GroupStatusMessage _LOGGER = logging.getLogger(__name__) class At2PlusClient: - def __init__(self, host: str, dump_responses: bool = False, task_creator: TaskCreator = asyncio.create_task): + def __init__( + self, + host: str, + dump_responses: bool = False, + task_creator: TaskCreator = asyncio.create_task, + ): # public self.aircons_by_id: dict[int, At2PlusAircon] = {} self.groups_by_id: dict[int, At2PlusGroup] = {} # private - self._client = NetClient(host, 9200, self._on_connect, self.handle_one_message, task_creator) + self._client = NetClient( + host, 9200, self._on_connect, self.handle_one_message, task_creator + ) self._dump_responses = dump_responses self._task_creator = task_creator self._new_ac_callbacks: list[Callback] = [] @@ -82,73 +108,96 @@ async def handle_one_message(self) -> None: subheader = ControlStatusSubHeader.from_buffer(message.data_buffer) if subheader.sub_type == ControlStatusSubType.AC_STATUS: status_message = AcStatusMessage.from_bytes( - message.data_buffer.read_bytes(subheader.subdata_length.total())) + message.data_buffer.read_bytes( + subheader.subdata_length.total() + ) + ) self._task_creator(self._handle_status_message(status_message)) elif subheader.sub_type == ControlStatusSubType.GROUP_STATUS: group_status_message = GroupStatusMessage.from_bytes( - message.data_buffer.read_bytes(subheader.subdata_length.total())) - self._task_creator(self._handle_group_status_message(group_status_message)) - elif subheader.sub_type == ControlStatusSubType.UNKNOWN_45: # Add this block - _LOGGER.debug(f"Unknown status type 0x45: {message.data_buffer.to_bytes().hex(':')}") + message.data_buffer.read_bytes( + subheader.subdata_length.total() + ) + ) + self._task_creator( + self._handle_group_status_message(group_status_message) + ) + elif ( + subheader.sub_type == ControlStatusSubType.UNKNOWN_45 + ): # Add this block + _LOGGER.debug( + f"Unknown status type 0x45: {message.data_buffer.to_bytes().hex(':')}" + ) # Send ACK await self._send_ack_response(0x45) else: # Handle unknown control status subtypes gracefully - _LOGGER.debug(f"Unknown control status subtype: 0x{subheader.sub_type:02x}") - _LOGGER.debug(f"Data: {message.data_buffer.to_bytes().hex(':')}") + _LOGGER.debug( + f"Unknown control status subtype: 0x{subheader.sub_type:02x}" + ) + _LOGGER.debug( + f"Data: {message.data_buffer.to_bytes().hex(':')}" + ) except ValueError as e: # This catches enum parsing errors for unknown subtypes _LOGGER.debug(f"Unknown control status message type: {e}") - _LOGGER.debug(f"Raw data: {message.data_buffer.to_bytes().hex(':')}") - + _LOGGER.debug( + f"Raw data: {message.data_buffer.to_bytes().hex(':')}" + ) + # Try to extract the subtype and send an ACK raw_data = message.data_buffer.to_bytes() if len(raw_data) > 0: unknown_subtype = raw_data[0] - _LOGGER.debug(f"Sending ACK for unknown subtype: 0x{unknown_subtype:02x}") + _LOGGER.debug( + f"Sending ACK for unknown subtype: 0x{unknown_subtype:02x}" + ) await self._send_ack_response(unknown_subtype) elif message.header.type == MessageType.EXTENDED: subheader = ExtendedSubHeader.from_buffer(message.data_buffer) if subheader.sub_type == ExtendedMessageSubType.ABILITY: ability_message_bytes = message.data_buffer.read_remaining() - _LOGGER.debug(f"Creating ability message from {len(ability_message_bytes)} bytes") + _LOGGER.debug( + f"Creating ability message from {len(ability_message_bytes)} bytes" + ) ability = AcAbilityMessage.from_bytes(ability_message_bytes) await self._ability_message_queue.put(ability) elif subheader.sub_type == ExtendedMessageSubType.GROUP_NAME: group_names_subdata = message.data_buffer.read_remaining() - for id, name in group_names_from_subdata(group_names_subdata).items(): + for id, name in group_names_from_subdata( + group_names_subdata + ).items(): self.groups_by_id[id]._update_name(name) elif subheader.sub_type == ExtendedMessageSubType.ERROR: # NYI pass else: - _LOGGER.debug(f"Unknown extended message subtype: 0x{subheader.sub_type:02x}") + _LOGGER.debug( + f"Unknown extended message subtype: 0x{subheader.sub_type:02x}" + ) _LOGGER.debug(f"Data: {message.data_buffer.to_bytes().hex(':')}") else: # Handle completely unknown message types _LOGGER.debug(f"Unknown message type: 0x{message.header.type:02x}") _LOGGER.debug(f"Header: {message.header.to_bytes().hex(':')}") _LOGGER.debug(f"Data: {message.data_buffer.to_bytes().hex(':')}") - except Exception as e: _LOGGER.error(f"Error processing message: {e}", exc_info=True) _LOGGER.debug(f"Message header: {message.header.to_bytes().hex(':')}") _LOGGER.debug(f"Message data: {message.data_buffer.to_bytes().hex(':')}") - - async def _read_magic(self) -> bytes: """Search for the two header magic bytes""" while True: # exit via return on successful read of header magic byte = await self._client.read_bytes(1) - while (byte is None or byte[0] != HEADER_MAGIC): + while byte is None or byte[0] != HEADER_MAGIC: byte = await self._client.read_bytes(1) byte = await self._client.read_bytes(1) - if (byte is not None and byte[0] == HEADER_MAGIC): + if byte is not None and byte[0] == HEADER_MAGIC: return bytes([HEADER_MAGIC, HEADER_MAGIC]) async def _read_header(self) -> tuple[Header, bytes]: @@ -156,7 +205,7 @@ async def _read_header(self) -> tuple[Header, bytes]: while True: # exit via return on successful read of header header_bytes = bytearray() header_bytes += await self._read_magic() - header_remainder = await self._client.read_bytes(HEADER_LENGTH-2) + header_remainder = await self._client.read_bytes(HEADER_LENGTH - 2) if not header_remainder: _LOGGER.debug("Failed reading header, trying again") @@ -180,21 +229,26 @@ async def _read_message(self) -> Message | None: return None if not buffer.append_bytes(data_bytes): _LOGGER.warning( - f"Received incorrect number of bytes, expected {header.data_length} but received {buffer._head}") + f"Received incorrect number of bytes, expected {header.data_length} but received {buffer._head}" + ) checksum = await self._client.read_bytes(2) if not checksum: # interrupted during checksum reading return None calculated_checksum = crc16(header_bytes[2:] + buffer._data) - if (checksum != calculated_checksum): + if checksum != calculated_checksum: _LOGGER.warning( - f"Checksum mismatch, ignoring message: Got {checksum.hex(':')}, expected {calculated_checksum.hex(':')}") + f"Checksum mismatch, ignoring message: Got {checksum.hex(':')}, expected {calculated_checksum.hex(':')}" + ) return None if self._dump_responses: # blocks but is only used for dev and debugging - with open('message_' + datetime.now().strftime("%m-%d-%Y_%H-%M-%S") + '.dump', 'wb') as f: + with open( + "message_" + datetime.now().strftime("%m-%d-%Y_%H-%M-%S") + ".dump", + "wb", + ) as f: f.write(header.to_bytes() + buffer.to_bytes() + checksum) return Message(header, buffer) @@ -229,10 +283,14 @@ async def _request_ac_ability(self, id: int) -> AcAbility | None: ac_ability = await self._ability_message_queue.get() _LOGGER.debug("Got ability message response") if len(ac_ability.abilities) != 1: - _LOGGER.warning(f"Expected ability of single requested AC but got {len(ac_ability.abilities)}") + _LOGGER.warning( + f"Expected ability of single requested AC but got {len(ac_ability.abilities)}" + ) return None if ac_ability.abilities[0].ac_id != id: - _LOGGER.warning(f"Requested ability of AC{id} but got AC{ac_ability.abilities[0].ac_id}") + _LOGGER.warning( + f"Requested ability of AC{id} but got AC{ac_ability.abilities[0].ac_id}" + ) return None _LOGGER.debug(f"Got ability of AC{id}: {ac_ability.abilities[0]}") return ac_ability.abilities[0] @@ -260,79 +318,122 @@ async def _send_ack_response(self, msg_type: int, message_type: bool = False): try: if msg_type == 0x2B: # Extended Status - Timer/System related # Based on doc offsets 0x15A-0x161 (timer status) - ack_data = bytes([ - 0x2B, 0x00, # Subtype + reserved - 0x00, 0x04, # Normal data length (4 bytes) - 0x00, 0x00, # Repeat data length (0) - 0x00, 0x00, # Repeat count (0) - 0x00, 0x00, 0x00, 0x00 # Basic timer ACK data - ]) - + ack_data = bytes( + [ + 0x2B, + 0x00, # Subtype + reserved + 0x00, + 0x04, # Normal data length (4 bytes) + 0x00, + 0x00, # Repeat data length (0) + 0x00, + 0x00, # Repeat count (0) + 0x00, + 0x00, + 0x00, + 0x00, # Basic timer ACK data + ] + ) + elif msg_type == 0x10: # AC Status Extended # Based on doc offsets 0x162-0x163 (AC status) - ack_data = bytes([ - 0x10, 0x00, # Subtype + reserved - 0x00, 0x02, # Normal data length (2 bytes) - 0x00, 0x00, # Repeat data length (0) - 0x00, 0x00, # Repeat count (0) - 0x00, 0x00 # Basic AC status ACK - ]) - + ack_data = bytes( + [ + 0x10, + 0x00, # Subtype + reserved + 0x00, + 0x02, # Normal data length (2 bytes) + 0x00, + 0x00, # Repeat data length (0) + 0x00, + 0x00, # Repeat count (0) + 0x00, + 0x00, # Basic AC status ACK + ] + ) + elif msg_type == 0x31: # Favorite Status # Acknowledge favorite status - ack_data = bytes([ - 0x31, 0x00, # Subtype + reserved - 0x00, 0x01, # Normal data length (1 byte) - 0x00, 0x00, # Repeat data length (0) - 0x00, 0x00, # Repeat count (0) - 0x00 # Simple favorite ACK - ]) - + ack_data = bytes( + [ + 0x31, + 0x00, # Subtype + reserved + 0x00, + 0x01, # Normal data length (1 byte) + 0x00, + 0x00, # Repeat data length (0) + 0x00, + 0x00, # Repeat count (0) + 0x00, # Simple favorite ACK + ] + ) + elif msg_type == 0x40: # Zone Status # Based on doc zone percentages (0x114-0x123) - ack_data = bytes([ - 0x40, 0x00, # Subtype + reserved - 0x00, 0x01, # Normal data length (1 byte) - 0x00, 0x00, # Repeat data length (0) - 0x00, 0x00, # Repeat count (0) - 0x00 # Simple zone ACK - ]) + ack_data = bytes( + [ + 0x40, + 0x00, # Subtype + reserved + 0x00, + 0x01, # Normal data length (1 byte) + 0x00, + 0x00, # Repeat data length (0) + 0x00, + 0x00, # Repeat count (0) + 0x00, # Simple zone ACK + ] + ) elif msg_type == 0x45: # Unknown status type # Basic acknowledgment for unknown type 0x45 - ack_data = bytes([ - 0x45, 0x00, # Subtype + reserved - 0x00, 0x01, # Normal data length (1 byte) - 0x00, 0x00, # Repeat data length (0) - 0x00, 0x00, # Repeat count (0) - 0x00 # Simple ACK - ]) - + ack_data = bytes( + [ + 0x45, + 0x00, # Subtype + reserved + 0x00, + 0x01, # Normal data length (1 byte) + 0x00, + 0x00, # Repeat data length (0) + 0x00, + 0x00, # Repeat count (0) + 0x00, # Simple ACK + ] + ) + else: # Generic acknowledgment for other unknown types - ack_data = bytes([ - msg_type, 0x00, # Subtype + reserved - 0x00, 0x00, # Normal data length (0) - 0x00, 0x00, # Repeat data length (0) - 0x00, 0x00 # Repeat count (0) - ]) + ack_data = bytes( + [ + msg_type, + 0x00, # Subtype + reserved + 0x00, + 0x00, # Normal data length (0) + 0x00, + 0x00, # Repeat data length (0) + 0x00, + 0x00, # Repeat count (0) + ] + ) # Create proper header for control status message from airtouch2.protocol.at2plus.message_common import AddressMsgType + header = Header( - AddressMsgType.NORMAL, # positional argument - MessageType.CONTROL_STATUS, # positional argument - len(ack_data) # positional argument -) + AddressMsgType.NORMAL, # positional argument + MessageType.CONTROL_STATUS, # positional argument + len(ack_data), # positional argument + ) # Create and send the message buffer = Buffer(len(ack_data)) buffer.append_bytes(ack_data) message = Message(header, buffer) - await self._client.send(message) + await self._client.send(message.to_bytes()) _LOGGER.debug(f"Sent protocol-compliant ACK for subtype 0x{msg_type:02x}") except Exception as e: - _LOGGER.error(f"Failed to send ACK for 0x{msg_type:02x}: {e}", exc_info=True) + _LOGGER.error( + f"Failed to send ACK for 0x{msg_type:02x}: {e}", exc_info=True + ) diff --git a/src/airtouch2/common/Buffer.py b/src/airtouch2/common/Buffer.py index 7d71dd2..f254ddc 100644 --- a/src/airtouch2/common/Buffer.py +++ b/src/airtouch2/common/Buffer.py @@ -64,6 +64,11 @@ def read_bytes(self, size: int) -> bytes: def read_remaining(self) -> bytes: return self.read_bytes(self._head - self._tail) + def get_data_from_offset(self, offset: int) -> bytes: + if (offset >= self._head): + return bytes() + return self._data[offset:self._head] + @staticmethod def from_bytes(data: bytes) -> Buffer: buffer = Buffer(len(data)) diff --git a/src/airtouch2/protocol/at2plus/message_common.py b/src/airtouch2/protocol/at2plus/message_common.py index 978a433..3bdb1a0 100644 --- a/src/airtouch2/protocol/at2plus/message_common.py +++ b/src/airtouch2/protocol/at2plus/message_common.py @@ -48,7 +48,7 @@ class MessageType(IntEnum): UNSET = 0 CONTROL_STATUS = 0xC0 EXTENDED = 0x1F - UNKNOWN_27 = 0x27 # Unknown message type 39 decimal + UNKNOWN_27 = 0x27 # Unknown message type 39 decimal class Header(Serializable): @@ -57,7 +57,13 @@ class Header(Serializable): data_length: int _received: bool - def __init__(self, address_msg_type: AddressMsgType, type: MessageType, data_length: int, _received=False): + def __init__( + self, + address_msg_type: AddressMsgType, + type: MessageType, + data_length: int, + _received=False, + ): self.address_msg_type = address_msg_type self.type = type self.data_length = data_length @@ -67,35 +73,51 @@ def __init__(self, address_msg_type: AddressMsgType, type: MessageType, data_len def from_bytes(header_bytes: bytes) -> Header: if len(header_bytes) != HEADER_LENGTH: raise ValueError("Unexpected header size") - for b in header_bytes[CommonMessageOffsets.HEADER:CommonMessageOffsets.ADDRESS]: - if (b != HEADER_MAGIC): + for b in header_bytes[ + CommonMessageOffsets.HEADER : CommonMessageOffsets.ADDRESS + ]: + if b != HEADER_MAGIC: raise ValueError("Message header magic is invalid") try: type = MessageType(header_bytes[CommonMessageOffsets.MESSAGE_TYPE]) except ValueError as e: _LOGGER.warning( - f"Unknown message type in header ({hex(header_bytes[CommonMessageOffsets.MESSAGE_TYPE])})", exc_info=e) + f"Unknown message type in header ({hex(header_bytes[CommonMessageOffsets.MESSAGE_TYPE])})", + exc_info=e, + ) type = MessageType.UNSET address_src = AddressSource(header_bytes[CommonMessageOffsets.ADDRESS]) - address_msg_type = AddressMsgType(header_bytes[CommonMessageOffsets.ADDRESS+1]) + address_msg_type = AddressMsgType( + header_bytes[CommonMessageOffsets.ADDRESS + 1] + ) if type == MessageType.CONTROL_STATUS: - if (address_msg_type != AddressMsgType.NORMAL): - raise ValueError(f"Message address value is invalid: {header_bytes.hex(':')}") + if address_msg_type != AddressMsgType.NORMAL: + raise ValueError( + f"Message address value is invalid: {header_bytes.hex(':')}" + ) elif type == MessageType.EXTENDED: - if (address_msg_type != AddressMsgType.EXTENDED): - raise ValueError(f"Message address value is invalid: {header_bytes.hex(':')}") + if address_msg_type != AddressMsgType.EXTENDED: + raise ValueError( + f"Message address value is invalid: {header_bytes.hex(':')}" + ) id = header_bytes[CommonMessageOffsets.MESAGE_ID] data_length = int.from_bytes( - header_bytes[CommonMessageOffsets.DATA_LENGTH:CommonMessageOffsets.DATA], 'big') + header_bytes[CommonMessageOffsets.DATA_LENGTH : CommonMessageOffsets.DATA], + "big", + ) return Header(address_msg_type, type, data_length, True) def to_bytes(self) -> bytes: - return bytes( - [HEADER_MAGIC, HEADER_MAGIC]) + ( - bytes([AddressSource.SELF, self.address_msg_type]) - if self._received else bytes([self.address_msg_type, AddressSource.SELF])) + bytes( - [MESSAGE_ID, self.type]) + self.data_length.to_bytes( - 2, 'big') + return ( + bytes([HEADER_MAGIC, HEADER_MAGIC]) + + ( + bytes([AddressSource.SELF, self.address_msg_type]) + if self._received + else bytes([self.address_msg_type, AddressSource.SELF]) + ) + + bytes([MESSAGE_ID, self.type]) + + self.data_length.to_bytes(2, "big") + ) def prime_message_buffer(header: Header) -> Buffer: @@ -105,11 +127,13 @@ def prime_message_buffer(header: Header) -> Buffer: def add_checksum_message_buffer(buffer: Buffer) -> None: - buffer.append_bytes(crc16(buffer._data[2:-2])) + # Checksum is calculated from the address field to the end of the data + buffer.append_bytes(crc16(buffer.get_data_from_offset(CommonMessageOffsets.ADDRESS))) def add_checksum_message_bytes(data: bytearray) -> None: - checksum = crc16(data[2:-2]) + # Checksum is calculated from the address field to the end of the data, excluding the checksum bytes + checksum = crc16(data[CommonMessageOffsets.ADDRESS:-2]) data[-2] = checksum[0] data[-1] = checksum[1] @@ -118,3 +142,10 @@ def add_checksum_message_bytes(data: bytearray) -> None: class Message: header: Header data_buffer: Buffer + + def to_bytes(self) -> bytes: + """Serializes the message to bytes, including header, data, and checksum.""" + buffer = prime_message_buffer(self.header) + buffer.append_bytes(self.data_buffer.to_bytes()) + add_checksum_message_buffer(buffer) + return buffer.to_bytes() From 72d5b13bb72c41492cf58b29e5d63ebd2e0b42fb Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Fri, 15 Aug 2025 13:28:49 +0800 Subject: [PATCH 07/15] Added debugging/analysis messaging --- src/airtouch2/at2plus/At2PlusClient.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/airtouch2/at2plus/At2PlusClient.py b/src/airtouch2/at2plus/At2PlusClient.py index 0e4737d..a8195af 100644 --- a/src/airtouch2/at2plus/At2PlusClient.py +++ b/src/airtouch2/at2plus/At2PlusClient.py @@ -1,5 +1,6 @@ import asyncio from datetime import datetime +from email import message import logging from airtouch2.at2plus.At2PlusAircon import At2PlusAircon @@ -102,6 +103,12 @@ async def handle_one_message(self) -> None: _LOGGER.warning("Reading message failed") return + _LOGGER.debug( + f"=== MESSAGE RECEIVED: Type=0x{message.header.type:02x}, Length={message.header.data_length} ===" + ) + _LOGGER.debug(f"Header: {message.header.to_bytes().hex(':')}") + _LOGGER.debug(f"Data: {message.data_buffer.to_bytes().hex(':')}") + try: if message.header.type == MessageType.CONTROL_STATUS: try: @@ -130,6 +137,11 @@ async def handle_one_message(self) -> None: ) # Send ACK await self._send_ack_response(0x45) + elif subheader.sub_type == ControlStatusSubType.EXTENDED_STATUS: + _LOGGER.debug( + "NEW CODE: Handling Extended Status message (0x2b)" + ) + await self._send_ack_response(subheader.sub_type) else: # Handle unknown control status subtypes gracefully _LOGGER.debug( From afd54f6c3c409ec73b8e39b6643bdffe60e4c560 Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Thu, 21 Aug 2025 14:39:13 +0800 Subject: [PATCH 08/15] Fix: Add special ACK for 0x2B message and related cleanup --- src/airtouch2/at2plus/At2PlusClient.py | 80 +++++++++++++++++++++----- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/src/airtouch2/at2plus/At2PlusClient.py b/src/airtouch2/at2plus/At2PlusClient.py index a8195af..fb62e2e 100644 --- a/src/airtouch2/at2plus/At2PlusClient.py +++ b/src/airtouch2/at2plus/At2PlusClient.py @@ -1,6 +1,7 @@ import asyncio from datetime import datetime -from email import message +import time +import hashlib import logging from airtouch2.at2plus.At2PlusAircon import At2PlusAircon @@ -61,6 +62,10 @@ def __init__( self._found_ac = asyncio.Event() self._new_group_callbacks: list[Callback] = [] + # ACK dedupe / rate-limit cache + self._last_ack_sent = {} + self._ack_min_interval = 0.3 + self.add_new_ac_callback(lambda: self._found_ac.set()) async def connect(self) -> bool: @@ -137,11 +142,13 @@ async def handle_one_message(self) -> None: ) # Send ACK await self._send_ack_response(0x45) + elif subheader.sub_type == ControlStatusSubType.EXTENDED_STATUS: _LOGGER.debug( "NEW CODE: Handling Extended Status message (0x2b)" ) await self._send_ack_response(subheader.sub_type) + else: # Handle unknown control status subtypes gracefully _LOGGER.debug( @@ -165,7 +172,8 @@ async def handle_one_message(self) -> None: _LOGGER.debug( f"Sending ACK for unknown subtype: 0x{unknown_subtype:02x}" ) - await self._send_ack_response(unknown_subtype) + incoming = raw_data[:8] if len(raw_data) >= 8 else None + await self._send_ack_response(unknown_subtype, False, incoming) elif message.header.type == MessageType.EXTENDED: subheader = ExtendedSubHeader.from_buffer(message.data_buffer) @@ -325,25 +333,27 @@ async def _handle_group_status_message(self, message: GroupStatusMessage): _LOGGER.debug("Requesting all group names") await self._client.send(RequestGroupNamesMessage()) - async def _send_ack_response(self, msg_type: int, message_type: bool = False): + async def _send_ack_response( + self, msg_type: int, message_type: bool = False, incoming_subheader=None + ): """Send a proper acknowledgment response based on protocol documentation.""" try: + ack_data: bytes + if msg_type == 0x2B: # Extended Status - Timer/System related - # Based on doc offsets 0x15A-0x161 (timer status) + # This is the working ACK format discovered through testing. + # It follows the same pattern as other simple status ACKs (0x31, 0x40). ack_data = bytes( [ 0x2B, 0x00, # Subtype + reserved 0x00, - 0x04, # Normal data length (4 bytes) + 0x01, # Normal data length (1 byte) 0x00, 0x00, # Repeat data length (0) 0x00, 0x00, # Repeat count (0) - 0x00, - 0x00, - 0x00, - 0x00, # Basic timer ACK data + 0x00, # 1-byte zero payload ] ) @@ -427,21 +437,61 @@ async def _send_ack_response(self, msg_type: int, message_type: bool = False): ] ) + # Dedupe / rate-limit: suppress sending identical ACKs too frequently + try: + ack_hash = hashlib.sha256(ack_data).hexdigest() + except Exception: + # Fallback to raw bytes repr if hashing fails for some reason + ack_hash = str(ack_data) + + now = time.monotonic() + last_sent = self._last_ack_sent.get(ack_hash) + # Allow faster repeat ACKs for 0x2B as controller resends at ~270ms; either bypass or use short interval + effective_interval = 0.05 if msg_type == 0x2B else self._ack_min_interval + if ( + last_sent is not None + and (now - last_sent) < effective_interval + and msg_type + != 0x2B # never suppress for 0x2B within main interval, only after shorter interval + ): + _LOGGER.debug( + f"Suppressing duplicate ACK for subtype 0x{msg_type:02x} (sent {now - last_sent:.03f}s ago)" + ) + return + + # record send time and log the ACK payload + self._last_ack_sent[ack_hash] = now + _LOGGER.debug( + f"ACK payload for subtype 0x{msg_type:02x}: {ack_data.hex(':')}" + ) + # Create proper header for control status message from airtouch2.protocol.at2plus.message_common import AddressMsgType - header = Header( - AddressMsgType.NORMAL, # positional argument - MessageType.CONTROL_STATUS, # positional argument - len(ack_data), # positional argument - ) + header: Header + if msg_type == 0x2B: + # The 0x2B ACK requires a special header address (0xc0) to be accepted. + # This was discovered by comparing the working tester and failing client. + header = Header( + 0xC0, # Special address for 0x2B ACK + MessageType.CONTROL_STATUS, + len(ack_data), + ) + else: + # Other ACKs use the standard client address (0x80). + header = Header( + AddressMsgType.NORMAL, + MessageType.CONTROL_STATUS, + len(ack_data), + ) # Create and send the message buffer = Buffer(len(ack_data)) buffer.append_bytes(ack_data) message = Message(header, buffer) - await self._client.send(message.to_bytes()) + _LOGGER.debug(f"CLIENT ACK SENT -> RAW: {message.to_bytes().hex(':')}") + await self._client.send(message) _LOGGER.debug(f"Sent protocol-compliant ACK for subtype 0x{msg_type:02x}") From 02b39f46bf2acf226277383de8c55858e3d34673 Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Fri, 22 Aug 2025 12:48:46 +0800 Subject: [PATCH 09/15] feat: Implement ACK handling for status messages Cleans up ACK handling for several control status message subtypes. - Fixes rate limiting to remove special handling for 0x2B. - Comments out legacy ACK generation code that was found to be unnecessary. - Minor formatting fixes --- src/airtouch2/at2plus/At2PlusClient.py | 114 ++++++++++++------------- 1 file changed, 56 insertions(+), 58 deletions(-) diff --git a/src/airtouch2/at2plus/At2PlusClient.py b/src/airtouch2/at2plus/At2PlusClient.py index fb62e2e..31262c3 100644 --- a/src/airtouch2/at2plus/At2PlusClient.py +++ b/src/airtouch2/at2plus/At2PlusClient.py @@ -336,7 +336,7 @@ async def _handle_group_status_message(self, message: GroupStatusMessage): async def _send_ack_response( self, msg_type: int, message_type: bool = False, incoming_subheader=None ): - """Send a proper acknowledgment response based on protocol documentation.""" + """Send acknowledgment response.""" try: ack_data: bytes @@ -357,54 +357,56 @@ async def _send_ack_response( ] ) - elif msg_type == 0x10: # AC Status Extended - # Based on doc offsets 0x162-0x163 (AC status) - ack_data = bytes( - [ - 0x10, - 0x00, # Subtype + reserved - 0x00, - 0x02, # Normal data length (2 bytes) - 0x00, - 0x00, # Repeat data length (0) - 0x00, - 0x00, # Repeat count (0) - 0x00, - 0x00, # Basic AC status ACK - ] - ) - - elif msg_type == 0x31: # Favorite Status - # Acknowledge favorite status - ack_data = bytes( - [ - 0x31, - 0x00, # Subtype + reserved - 0x00, - 0x01, # Normal data length (1 byte) - 0x00, - 0x00, # Repeat data length (0) - 0x00, - 0x00, # Repeat count (0) - 0x00, # Simple favorite ACK - ] - ) - - elif msg_type == 0x40: # Zone Status - # Based on doc zone percentages (0x114-0x123) - ack_data = bytes( - [ - 0x40, - 0x00, # Subtype + reserved - 0x00, - 0x01, # Normal data length (1 byte) - 0x00, - 0x00, # Repeat data length (0) - 0x00, - 0x00, # Repeat count (0) - 0x00, # Simple zone ACK - ] - ) + # ACKs for these probably not needed but keeping in case. + # + # elif msg_type == 0x10: # AC Status Extended + # # Based on doc offsets 0x162-0x163 (AC status) + # ack_data = bytes( + # [ + # 0x10, + # 0x00, # Subtype + reserved + # 0x00, + # 0x02, # Normal data length (2 bytes) + # 0x00, + # 0x00, # Repeat data length (0) + # 0x00, + # 0x00, # Repeat count (0) + # 0x00, + # 0x00, # Basic AC status ACK + # ] + # ) + + # elif msg_type == 0x31: # Favorite Status + # # Acknowledge favorite status + # ack_data = bytes( + # [ + # 0x31, + # 0x00, # Subtype + reserved + # 0x00, + # 0x01, # Normal data length (1 byte) + # 0x00, + # 0x00, # Repeat data length (0) + # 0x00, + # 0x00, # Repeat count (0) + # 0x00, # Simple favorite ACK + # ] + # ) + + # elif msg_type == 0x40: # Zone Status + # # Based on doc zone percentages (0x114-0x123) + # ack_data = bytes( + # [ + # 0x40, + # 0x00, # Subtype + reserved + # 0x00, + # 0x01, # Normal data length (1 byte) + # 0x00, + # 0x00, # Repeat data length (0) + # 0x00, + # 0x00, # Repeat count (0) + # 0x00, # Simple zone ACK + # ] + # ) elif msg_type == 0x45: # Unknown status type # Basic acknowledgment for unknown type 0x45 @@ -437,7 +439,7 @@ async def _send_ack_response( ] ) - # Dedupe / rate-limit: suppress sending identical ACKs too frequently + # Rate-limit: suppress sending identical ACKs too frequently try: ack_hash = hashlib.sha256(ack_data).hexdigest() except Exception: @@ -446,13 +448,9 @@ async def _send_ack_response( now = time.monotonic() last_sent = self._last_ack_sent.get(ack_hash) - # Allow faster repeat ACKs for 0x2B as controller resends at ~270ms; either bypass or use short interval - effective_interval = 0.05 if msg_type == 0x2B else self._ack_min_interval if ( last_sent is not None - and (now - last_sent) < effective_interval - and msg_type - != 0x2B # never suppress for 0x2B within main interval, only after shorter interval + and (now - last_sent) < self._ack_min_interval ): _LOGGER.debug( f"Suppressing duplicate ACK for subtype 0x{msg_type:02x} (sent {now - last_sent:.03f}s ago)" @@ -469,9 +467,9 @@ async def _send_ack_response( from airtouch2.protocol.at2plus.message_common import AddressMsgType header: Header + # TODO: 0x45 ACK causes a burst of re-sends from the controller so this header is also likley to be wrong. if msg_type == 0x2B: # The 0x2B ACK requires a special header address (0xc0) to be accepted. - # This was discovered by comparing the working tester and failing client. header = Header( 0xC0, # Special address for 0x2B ACK MessageType.CONTROL_STATUS, @@ -490,10 +488,10 @@ async def _send_ack_response( buffer.append_bytes(ack_data) message = Message(header, buffer) - _LOGGER.debug(f"CLIENT ACK SENT -> RAW: {message.to_bytes().hex(':')}") await self._client.send(message) - _LOGGER.debug(f"Sent protocol-compliant ACK for subtype 0x{msg_type:02x}") + _LOGGER.debug(f"Sent ACK for subtype 0x{msg_type:02x}") + _LOGGER.debug(f"RAW: {message.to_bytes().hex(':')}") except Exception as e: _LOGGER.error( From bca814e706bac333b0128162ab1ae1af78d14e6a Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Fri, 22 Aug 2025 13:21:37 +0800 Subject: [PATCH 10/15] docs(protocol): Update notes for 0x2B and 0x45 messages testing: the ACK handling of 0x45 messages --- re_response/airtouch2plus_protocol_notes.md | 40 +++++++++++++------ src/airtouch2/at2plus/At2PlusClient.py | 9 +++-- .../protocol/at2plus/control_status_common.py | 2 +- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/re_response/airtouch2plus_protocol_notes.md b/re_response/airtouch2plus_protocol_notes.md index 6c0dd25..3773d04 100644 --- a/re_response/airtouch2plus_protocol_notes.md +++ b/re_response/airtouch2plus_protocol_notes.md @@ -64,9 +64,11 @@ Bytes 10+: Repeating 4-byte sections: **Correlation**: Relates to timer offsets 0x15A-0x161 in AirTouch2 documentation **Required Acknowledgment**: +A simple 1-byte payload is required. ``` -2B 00 00 04 00 00 00 00 00 00 00 00 +2B 00 00 01 00 00 00 00 00 ``` +**Note**: This acknowledgment requires a special header address of `0xC0` instead of the standard `0x80`. #### 0x31 - Favorite Status @@ -137,6 +139,16 @@ Bytes 8+: Repeating 8-byte zone blocks: ``` 40 00 00 01 00 00 00 00 00 ``` +#### 0x45 - System Identity Broadcast + +**Purpose**: Broadcasts system identity information. The payload contains the string "Polyaire Atch2PM", likely identifying the manufacturer and "AirTouch 2 Plus Module". This message appears to be sent periodically (e.g., every 13 hours). + +**Required Acknowledgment - still testing**: +A simple 1-byte payload is required. +``` +45 00 00 01 00 00 00 00 00 +``` +**Note**: This acknowledgment requires a special header address of `0xC0` instead of the standard `0x80`. ### Unknown Message Types @@ -168,8 +180,9 @@ Data (variable length): ### Message Header Requirements -- **Address Message Type**: `AddressMsgType.NORMAL` (0x80) -- **Message Type**: `MessageType.CONTROL_STATUS` (0xC0) +- **Address Message Type**: `AddressMsgType.NORMAL` (`0x80`) for most messages. + - **Exception**: Acknowledgments for message subtypes `0x2B` and `0x45` must use the address `0xC0`. +- **Message Type**: `MessageType.CONTROL_STATUS` (`0xC0`) - **CRC16**: Proper MODBUS CRC16 checksum required ### Critical Implementation Notes @@ -200,20 +213,23 @@ class MessageType(IntEnum): ```python async def _send_ack_response(self, msg_type: int): """Send protocol-compliant acknowledgment.""" + ack_data: bytes if msg_type == 0x2B: # Extended Status - ack_data = bytes([0x2B, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) - elif msg_type == 0x10: # AC Status Extended - ack_data = bytes([0x10, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) - elif msg_type == 0x31: # Favorite Status - ack_data = bytes([0x31, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]) - elif msg_type == 0x40: # Zone Status - ack_data = bytes([0x40, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]) + ack_data = bytes([0x2B, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]) + elif msg_type == 0x45: # System Identity + ack_data = bytes([0x45, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]) else: + # Generic ACK for other subtypes if needed ack_data = bytes([msg_type, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + + # Create header with correct address + if msg_type == 0x2B or msg_type == 0x45: + header = Header(0xC0, MessageType.CONTROL_STATUS, len(ack_data)) + else: + header = Header(0x80, MessageType.CONTROL_STATUS, len(ack_data)) # Create and send message with proper headers and checksum - # ... implementation details ... -``` + # ... implementation details ...``` ## Future Enhancement Opportunities diff --git a/src/airtouch2/at2plus/At2PlusClient.py b/src/airtouch2/at2plus/At2PlusClient.py index 31262c3..bf76df7 100644 --- a/src/airtouch2/at2plus/At2PlusClient.py +++ b/src/airtouch2/at2plus/At2PlusClient.py @@ -467,11 +467,12 @@ async def _send_ack_response( from airtouch2.protocol.at2plus.message_common import AddressMsgType header: Header - # TODO: 0x45 ACK causes a burst of re-sends from the controller so this header is also likley to be wrong. - if msg_type == 0x2B: - # The 0x2B ACK requires a special header address (0xc0) to be accepted. + # TESTING: 0x45 ACK causes a burst of re-sends from the controller so this header is also likley to be wrong. + # Guessing that it's the same as the 0x2B header and testing that + if msg_type == 0x2B or msg_type == 0x45: + # The 0x2B and 0x45 ACKs require a special header address (0xc0) to be accepted. header = Header( - 0xC0, # Special address for 0x2B ACK + 0xC0, # Special address for these ACKs MessageType.CONTROL_STATUS, len(ack_data), ) diff --git a/src/airtouch2/protocol/at2plus/control_status_common.py b/src/airtouch2/protocol/at2plus/control_status_common.py index ce18522..1711717 100644 --- a/src/airtouch2/protocol/at2plus/control_status_common.py +++ b/src/airtouch2/protocol/at2plus/control_status_common.py @@ -39,7 +39,7 @@ class ControlStatusSubType(IntEnum): FAVORITE_STATUS = 0x31 # Favorite names and active status EXTENDED_STATUS = 0x2B # Extended system status ZONE_STATUS = 0x40 # Zone control status - UNKNOWN_45 = 0x45 # Add this new one + UNKNOWN_45 = 0x45 # Seems to simply broadcast "Polyaire Atch2PM" which could stand for "AirTouch 2 Plus Module". @dataclass From c9beba97de5d8025c2cf32b0d35e6d9f4de040a6 Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Fri, 22 Aug 2025 13:52:23 +0800 Subject: [PATCH 11/15] Updates to 0x21, 0x31 understanding --- re_response/airtouch2plus_protocol_notes.md | 97 +++++++++++++++------ 1 file changed, 69 insertions(+), 28 deletions(-) diff --git a/re_response/airtouch2plus_protocol_notes.md b/re_response/airtouch2plus_protocol_notes.md index 3773d04..240ca93 100644 --- a/re_response/airtouch2plus_protocol_notes.md +++ b/re_response/airtouch2plus_protocol_notes.md @@ -70,54 +70,95 @@ A simple 1-byte payload is required. ``` **Note**: This acknowledgment requires a special header address of `0xC0` instead of the standard `0x80`. -#### 0x31 - Favorite Status +#### 0x21 - Group Status -**Purpose**: Provides names and active status of configured favorite scenes. +**Purpose**: Provides the current status of all zone groups, including power state and damper percentage. This message is sent frequently and immediately after a favorite scene is changed to reflect the new settings. **Structure**: ``` -Byte 0: 0x31 (Message subtype) +Byte 0: 0x21 (Message subtype) Byte 1: 0x00 (Reserved) -Bytes 2-7: Standard control status subheader (typically 00 02 00 0B 00 04) -Byte 8: Active selector (bitmap indicating which favorite is active) -Bytes 9+: Favorite entries +Bytes 2-7: Standard control status subheader (typically 00 00 00 08 00 08, indicating 0 bytes of normal data, and 8 repeats of 8-byte blocks) +Bytes 8+: Repeating 8-byte group status blocks ``` -**Active Selector Bitmap**: -- Bit 0 (0x01): Favorite 0 active -- Bit 1 (0x02): Favorite 1 active -- Bit 2 (0x04): Favorite 2 active -- Bit 3 (0x08): Favorite 3 active - -**Favorite Entry Format**: +**Group Status Block Format (8 bytes per group)**: +``` +Byte 0: Power and ID. Bit 6 (0x40) is power state (1=ON). Lower 4 bits are the group ID. +Byte 1: Target Damper Percentage (0-100). +Byte 2: Unknown flags. +Byte 3: Current Damper Percentage (0-100). +Bytes 4-7: Additional flags (spill, turbo, etc). +``` -*Favorite 0 (special case - no ID byte)*: +**Example Parsing from Live Log**: ``` -Status Byte | Separator | Name Bytes | Null Padding +Data: 21:00:00:00:00:08:00:08:40:64:96:64:02:f8:00:00:41:50:96:50:02:f8:00:00... + │ │ │ │ + │ │ └─ 8 repeats of 8-byte blocks + │ └─ Subtype (0x21) + │ + └─ Group 0 Block (40:64:96:64...): + - Power/ID: 0x40 -> Power ON, ID 0 + - Target Damper: 0x64 -> 100% + - Current Damper: 0x64 -> 100% + └─ Group 1 Block (41:50:96:50...): + - Power/ID: 0x41 -> Power ON, ID 1 + - Target Damper: 0x50 -> 80% + - Current Damper: 0x50 -> 80% ``` -*Favorites 1-3*: +**Required Acknowledgment**: +This is a status message and does not require an acknowledgment. + + +#### 0x31 - Favorite Status + +**Purpose**: Provides the names of all configured favorite scenes and indicates which one is currently active. This message is sent when the active favorite changes. + +**Structure**: ``` -Status Byte | Separator | Favorite ID | Name Bytes | Null Padding +Byte 0: 0x31 (Message subtype) +Byte 1: 0x00 (Reserved) +Bytes 2-7: Standard control status subheader (typically 00 02 00 0B 00 04, indicating 2 bytes of normal data, and 4 repeats of 11-byte blocks) +Bytes 8-9: Normal Data - Active favorite selector +Bytes 10+: Repeating 11-byte favorite entry blocks ``` -**Example Parsing**: +**Active Selector (Normal Data)**: +The first byte of the normal data is a bitmap indicating which favorite is active. The second byte is currently unknown (observed as `0x08`). +- `0x01`: Favorite 0 active +- `0x02`: Favorite 1 active +- `0x04`: Favorite 2 active +- `0x08`: Favorite 3 active + +**Favorite Entry Format (11 bytes per favorite)**: ``` -31:00:00:02:00:0b:00:04:01:08:00:6d:61:69:6e:00:00:00:00:1b:00:01:67:79:6d:00:00:00:00:00:00 -│ │ │ │ └─────────┘ │ │ │ └───────────┘ -│ │ │ │ "main" │ │ │ "gym" -│ │ │ │ │ │ │ -│ │ │ └─ separator │ │ └─ favorite ID (1) -│ │ └─ status byte │ └─ separator -│ └─ active selector (0x01 = fav 0) └─ status byte -└─ message header +Byte 0: Favorite ID (0-indexed) +Bytes 1-8: Name (8-byte ASCII string, null-padded) +Bytes 9-10: Unknown/Status bytes ``` -**Required Acknowledgment**: +**Example Parsing from Live Log**: +Message received when "gym" (Favorite ID 1) was activated. ``` -31 00 00 01 00 00 00 00 00 +Data: 31:00:00:02:00:0b:00:04:02:08:00:6d:61:69:6e:00:00:00:00:1b:00:01:67:79:6d:00:00:00:00:00:9b:00... + │ │ │ │ │ │ + │ │ │ │ │ └─ Repeat Count (4 favorites) + │ │ │ │ └─ Repeat Length (11 bytes) + │ │ │ └─ Normal Data Length (2 bytes) + │ │ └─ Active Selector (0x02 -> Favorite 1 is active) + │ └─ Subtype (0x31) + │ + └─ Favorite 1 Block: + - ID: 0x01 + - Name: 0x67796d... (in this case "gym") + - Unknown: 0x9b00 ``` +**Required Acknowledgment**: +This is a status message and does not require an acknowledgment. + #### 0x40 - Zone Status **Purpose**: Provides zone control percentages and status. From 7a420e0f53b8fa988657bf4b5052223e01b3ba55 Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Fri, 22 Aug 2025 14:18:03 +0800 Subject: [PATCH 12/15] Update logging for known messages --- src/airtouch2/at2plus/At2PlusClient.py | 54 +++++--------------------- 1 file changed, 10 insertions(+), 44 deletions(-) diff --git a/src/airtouch2/at2plus/At2PlusClient.py b/src/airtouch2/at2plus/At2PlusClient.py index bf76df7..5362028 100644 --- a/src/airtouch2/at2plus/At2PlusClient.py +++ b/src/airtouch2/at2plus/At2PlusClient.py @@ -134,19 +134,17 @@ async def handle_one_message(self) -> None: self._task_creator( self._handle_group_status_message(group_status_message) ) - elif ( - subheader.sub_type == ControlStatusSubType.UNKNOWN_45 - ): # Add this block - _LOGGER.debug( - f"Unknown status type 0x45: {message.data_buffer.to_bytes().hex(':')}" - ) - # Send ACK + + elif subheader.sub_type == ControlStatusSubType.FAVORITE_STATUS: + _LOGGER.debug(f"Handling 0x31: {message.data_buffer.to_bytes().hex(':')}") + # Status broadcast, no ACK needed. + + elif subheader.sub_type == ControlStatusSubType.UNKNOWN_45: + _LOGGER.debug(f"Handling 0x45: {message.data_buffer.to_bytes().hex(':')}") await self._send_ack_response(0x45) elif subheader.sub_type == ControlStatusSubType.EXTENDED_STATUS: - _LOGGER.debug( - "NEW CODE: Handling Extended Status message (0x2b)" - ) + _LOGGER.debug(f"Handling 0x2b: {message.data_buffer.to_bytes().hex(':')}") await self._send_ack_response(subheader.sub_type) else: @@ -376,39 +374,7 @@ async def _send_ack_response( # ] # ) - # elif msg_type == 0x31: # Favorite Status - # # Acknowledge favorite status - # ack_data = bytes( - # [ - # 0x31, - # 0x00, # Subtype + reserved - # 0x00, - # 0x01, # Normal data length (1 byte) - # 0x00, - # 0x00, # Repeat data length (0) - # 0x00, - # 0x00, # Repeat count (0) - # 0x00, # Simple favorite ACK - # ] - # ) - - # elif msg_type == 0x40: # Zone Status - # # Based on doc zone percentages (0x114-0x123) - # ack_data = bytes( - # [ - # 0x40, - # 0x00, # Subtype + reserved - # 0x00, - # 0x01, # Normal data length (1 byte) - # 0x00, - # 0x00, # Repeat data length (0) - # 0x00, - # 0x00, # Repeat count (0) - # 0x00, # Simple zone ACK - # ] - # ) - - elif msg_type == 0x45: # Unknown status type + elif msg_type == 0x45: # Basic acknowledgment for unknown type 0x45 ack_data = bytes( [ @@ -467,7 +433,7 @@ async def _send_ack_response( from airtouch2.protocol.at2plus.message_common import AddressMsgType header: Header - # TESTING: 0x45 ACK causes a burst of re-sends from the controller so this header is also likley to be wrong. + # TESTING: 0x45 ACK causes a burst of re-sends from the controller so this header is also likley to be wrong. # Guessing that it's the same as the 0x2B header and testing that if msg_type == 0x2B or msg_type == 0x45: # The 0x2B and 0x45 ACKs require a special header address (0xc0) to be accepted. From fd9a8a3164350529867cfd368ff2b5dc5bd203d1 Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Fri, 22 Aug 2025 14:23:10 +0800 Subject: [PATCH 13/15] Updated naming of ControlStatusSubType --- src/airtouch2/at2plus/At2PlusClient.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/airtouch2/at2plus/At2PlusClient.py b/src/airtouch2/at2plus/At2PlusClient.py index 5362028..8371a81 100644 --- a/src/airtouch2/at2plus/At2PlusClient.py +++ b/src/airtouch2/at2plus/At2PlusClient.py @@ -136,15 +136,21 @@ async def handle_one_message(self) -> None: ) elif subheader.sub_type == ControlStatusSubType.FAVORITE_STATUS: - _LOGGER.debug(f"Handling 0x31: {message.data_buffer.to_bytes().hex(':')}") + _LOGGER.debug( + f"Handling Favorite Status message (0x{subheader.sub_type.value:02x})" + ) # Status broadcast, no ACK needed. elif subheader.sub_type == ControlStatusSubType.UNKNOWN_45: - _LOGGER.debug(f"Handling 0x45: {message.data_buffer.to_bytes().hex(':')}") - await self._send_ack_response(0x45) + _LOGGER.debug( + f"Handling 'Polyaire' broadcast message (0x{subheader.sub_type.value:02x})" + ) + await self._send_ack_response(subheader.sub_type) elif subheader.sub_type == ControlStatusSubType.EXTENDED_STATUS: - _LOGGER.debug(f"Handling 0x2b: {message.data_buffer.to_bytes().hex(':')}") + _LOGGER.debug( + f"Handling Extended Status message (0x{subheader.sub_type.value:02x})" + ) await self._send_ack_response(subheader.sub_type) else: From c0bfd737679cfd79fdca6fbf5c08bb6880ffacb1 Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Sat, 23 Aug 2025 08:00:48 +0800 Subject: [PATCH 14/15] Updates to protocol notes --- re_response/airtouch2plus_protocol_notes.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/re_response/airtouch2plus_protocol_notes.md b/re_response/airtouch2plus_protocol_notes.md index 240ca93..53a3669 100644 --- a/re_response/airtouch2plus_protocol_notes.md +++ b/re_response/airtouch2plus_protocol_notes.md @@ -68,7 +68,7 @@ A simple 1-byte payload is required. ``` 2B 00 00 01 00 00 00 00 00 ``` -**Note**: This acknowledgment requires a special header address of `0xC0` instead of the standard `0x80`. +**Note**: The acknowledgment for this message must be sent as a `CONTROL_STATUS` message (type `0xC0`). #### 0x21 - Group Status @@ -189,7 +189,7 @@ A simple 1-byte payload is required. ``` 45 00 00 01 00 00 00 00 00 ``` -**Note**: This acknowledgment requires a special header address of `0xC0` instead of the standard `0x80`. +**Note**: The acknowledgment for this message must be sent as a `CONTROL_STATUS` message (type `0xC0`). ### Unknown Message Types @@ -221,9 +221,8 @@ Data (variable length): ### Message Header Requirements -- **Address Message Type**: `AddressMsgType.NORMAL` (`0x80`) for most messages. - - **Exception**: Acknowledgments for message subtypes `0x2B` and `0x45` must use the address `0xC0`. -- **Message Type**: `MessageType.CONTROL_STATUS` (`0xC0`) +- **Address Source**: Your client should always identify itself with address `0x80`. +- **Message Type**: Use `MessageType.CONTROL_STATUS` (`0xC0`) for sending control commands and for acknowledging messages like `0x2B` and `0x45`. Use other types like `MessageType.EXTENDED` (`0xCB`) for other specific requests. - **CRC16**: Proper MODBUS CRC16 checksum required ### Critical Implementation Notes From 3395538850cf3e6594a0aadc5c4f5e028810fae0 Mon Sep 17 00:00:00 2001 From: Adam Ramshaw Date: Mon, 25 Aug 2025 09:42:41 +0800 Subject: [PATCH 15/15] Cleaning up desctiptions of ACKs and adding some scaffolding for the handling of Favourite status in the future --- src/airtouch2/at2plus/At2PlusClient.py | 37 +++++++++++++++++++++----- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/airtouch2/at2plus/At2PlusClient.py b/src/airtouch2/at2plus/At2PlusClient.py index 8371a81..6ba8cc1 100644 --- a/src/airtouch2/at2plus/At2PlusClient.py +++ b/src/airtouch2/at2plus/At2PlusClient.py @@ -36,6 +36,10 @@ group_names_from_subdata, ) from airtouch2.protocol.at2plus.messages.GroupStatus import GroupStatusMessage +from airtouch2.protocol.at2plus.messages.FavouriteStatus import ( + Favourite, + FavouriteStatusMessage, +) _LOGGER = logging.getLogger(__name__) @@ -50,6 +54,8 @@ def __init__( # public self.aircons_by_id: dict[int, At2PlusAircon] = {} self.groups_by_id: dict[int, At2PlusGroup] = {} + self.favourites: list[Favourite] = [] + self.active_favourite_id: int | None = None # private self._client = NetClient( @@ -61,6 +67,7 @@ def __init__( self._ability_message_queue: asyncio.Queue[AcAbilityMessage] = asyncio.Queue() self._found_ac = asyncio.Event() self._new_group_callbacks: list[Callback] = [] + self._favourite_callbacks: list[Callback] = [] # ACK dedupe / rate-limit cache self._last_ack_sent = {} @@ -98,6 +105,15 @@ def remove_callback() -> None: return remove_callback + def add_favourite_callback(self, callback: Callback): + self._favourite_callbacks.append(callback) + + def remove_callback() -> None: + if callback in self._favourite_callbacks: + self._favourite_callbacks.remove(callback) + + return remove_callback + async def send(self, msg: Serializable): await self._client.send(msg) @@ -136,10 +152,21 @@ async def handle_one_message(self) -> None: ) elif subheader.sub_type == ControlStatusSubType.FAVORITE_STATUS: + # Status broadcast, no ACK needed. _LOGGER.debug( f"Handling Favorite Status message (0x{subheader.sub_type.value:02x})" ) - # Status broadcast, no ACK needed. + subdata = message.data_buffer.read_bytes( + subheader.subdata_length.total() + ) + fav_status = FavouriteStatusMessage.from_data( + subheader, subdata + ) + self.favourites = fav_status.favourites + self.active_favourite_id = fav_status.active_favourite_id + # Notify listeners that the favourites list has changed + for callback in self._favourite_callbacks: + callback() elif subheader.sub_type == ControlStatusSubType.UNKNOWN_45: _LOGGER.debug( @@ -381,7 +408,7 @@ async def _send_ack_response( # ) elif msg_type == 0x45: - # Basic acknowledgment for unknown type 0x45 + # Basic acknowledgment for 0x45 - System Identity Broadcast ack_data = bytes( [ 0x45, @@ -439,12 +466,10 @@ async def _send_ack_response( from airtouch2.protocol.at2plus.message_common import AddressMsgType header: Header - # TESTING: 0x45 ACK causes a burst of re-sends from the controller so this header is also likley to be wrong. - # Guessing that it's the same as the 0x2B header and testing that if msg_type == 0x2B or msg_type == 0x45: - # The 0x2B and 0x45 ACKs require a special header address (0xc0) to be accepted. + # The 0x2B and 0x45 ACKs require a `CONTROL_STATUS` message (type `0xC0`) header address to be accepted. header = Header( - 0xC0, # Special address for these ACKs + 0xC0, MessageType.CONTROL_STATUS, len(ack_data), )