diff --git a/re_response/airtouch2plus_protocol_notes.md b/re_response/airtouch2plus_protocol_notes.md new file mode 100644 index 0000000..53a3669 --- /dev/null +++ b/re_response/airtouch2plus_protocol_notes.md @@ -0,0 +1,304 @@ +# 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**: +A simple 1-byte payload is required. +``` +2B 00 00 01 00 00 00 00 00 +``` +**Note**: The acknowledgment for this message must be sent as a `CONTROL_STATUS` message (type `0xC0`). + +#### 0x21 - Group Status + +**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: 0x21 (Message subtype) +Byte 1: 0x00 (Reserved) +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 +``` + +**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). +``` + +**Example Parsing from Live Log**: +``` +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% +``` + +**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**: +``` +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 +``` + +**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)**: +``` +Byte 0: Favorite ID (0-indexed) +Bytes 1-8: Name (8-byte ASCII string, null-padded) +Bytes 9-10: Unknown/Status bytes +``` + +**Example Parsing from Live Log**: +Message received when "gym" (Favorite ID 1) was activated. +``` +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. + +**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 +``` +#### 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**: The acknowledgment for this message must be sent as a `CONTROL_STATUS` message (type `0xC0`). + +### 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 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 + +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.""" + ack_data: bytes + if msg_type == 0x2B: # Extended Status + 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 ...``` + +## 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 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 diff --git a/src/airtouch2/at2plus/At2PlusClient.py b/src/airtouch2/at2plus/At2PlusClient.py index e074d7b..6ba8cc1 100644 --- a/src/airtouch2/at2plus/At2PlusClient.py +++ b/src/airtouch2/at2plus/At2PlusClient.py @@ -1,38 +1,77 @@ import asyncio from datetime import datetime +import time +import hashlib import logging 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 +from airtouch2.protocol.at2plus.messages.FavouriteStatus import ( + Favourite, + FavouriteStatusMessage, +) _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] = {} + self.favourites: list[Favourite] = [] + self.active_favourite_id: int | None = None # 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] = [] 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 = {} + self._ack_min_interval = 0.3 self.add_new_ac_callback(lambda: self._found_ac.set()) @@ -66,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) @@ -76,49 +124,131 @@ 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)) + _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: + 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) + ) + + 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})" + ) + 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( + 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 Extended Status message (0x{subheader.sub_type.value:02x})" + ) + await self._send_ack_response(subheader.sub_type) + + 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}" + ) + 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) + 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""" 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]: @@ -126,7 +256,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") @@ -150,21 +280,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) @@ -199,10 +334,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] @@ -224,3 +363,135 @@ 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, incoming_subheader=None + ): + """Send acknowledgment response.""" + try: + ack_data: bytes + + if msg_type == 0x2B: # Extended Status - Timer/System related + # 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, + 0x01, # Normal data length (1 byte) + 0x00, + 0x00, # Repeat data length (0) + 0x00, + 0x00, # Repeat count (0) + 0x00, # 1-byte zero payload + ] + ) + + # 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 == 0x45: + # Basic acknowledgment for 0x45 - System Identity Broadcast + 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) + ] + ) + + # 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) + if ( + last_sent is not None + 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)" + ) + 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 + if msg_type == 0x2B or msg_type == 0x45: + # The 0x2B and 0x45 ACKs require a `CONTROL_STATUS` message (type `0xC0`) header address to be accepted. + header = Header( + 0xC0, + 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) + + _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( + 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/control_status_common.py b/src/airtouch2/protocol/at2plus/control_status_common.py index 09fd222..1711717 100644 --- a/src/airtouch2/protocol/at2plus/control_status_common.py +++ b/src/airtouch2/protocol/at2plus/control_status_common.py @@ -35,6 +35,11 @@ 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 + UNKNOWN_45 = 0x45 # Seems to simply broadcast "Polyaire Atch2PM" which could stand for "AirTouch 2 Plus Module". @dataclass diff --git a/src/airtouch2/protocol/at2plus/message_common.py b/src/airtouch2/protocol/at2plus/message_common.py index 9cf87a3..3bdb1a0 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): @@ -56,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 @@ -66,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: @@ -104,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] @@ -117,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()