From 04dbdd273d2fce086a3866171fa23ff20c7eb2e3 Mon Sep 17 00:00:00 2001 From: nathanvdh <55475809+nathanvdh@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:42:02 +0930 Subject: [PATCH 1/3] airtouch2/protocol: Ack messages that require it Add 4 ControlStatusMessage subtypes identified by aramshaw, which all require an acknowledgement, otherwise the server will start ignoring the client. --- src/airtouch2/at2plus/At2PlusClient.py | 14 ++++++- .../protocol/at2plus/control_status_common.py | 13 +++++++ .../protocol/at2plus/messages/Ack.py | 37 +++++++++++++++++++ tests/protocol/at2plus/messages/test_ack.py | 29 +++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/airtouch2/protocol/at2plus/messages/Ack.py create mode 100644 tests/protocol/at2plus/messages/test_ack.py diff --git a/src/airtouch2/at2plus/At2PlusClient.py b/src/airtouch2/at2plus/At2PlusClient.py index e074d7b..a288c82 100644 --- a/src/airtouch2/at2plus/At2PlusClient.py +++ b/src/airtouch2/at2plus/At2PlusClient.py @@ -5,7 +5,7 @@ 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.control_status_common import ControlStatusSubHeader, ControlStatusSubType, need_ack 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 @@ -13,6 +13,7 @@ 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.Ack import Ack from airtouch2.protocol.at2plus.messages.GroupNames import RequestGroupNamesMessage, group_names_from_subdata from airtouch2.protocol.at2plus.messages.GroupStatus import GroupStatusMessage @@ -86,9 +87,20 @@ 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)) + # Currently unhandled + elif subheader.sub_type == ControlStatusSubType.AC_STATUS2: + pass + elif subheader.sub_type == ControlStatusSubType.SYSTEM_STATUS: + pass + elif subheader.sub_type == ControlStatusSubType.ZONE_STATUS: + pass + elif subheader.sub_type == ControlStatusSubType.SYSTEM_ID: + pass else: _LOGGER.warning( f"Unknown status message type: subtype={subheader.sub_type}, data={message.data_buffer.to_bytes().hex(':')}") + if subheader.sub_type in need_ack: + self._task_creator(self.send(Ack(subheader.sub_type))) elif message.header.type == MessageType.EXTENDED: subheader = ExtendedSubHeader.from_buffer(message.data_buffer) if subheader.sub_type == ExtendedMessageSubType.ABILITY: diff --git a/src/airtouch2/protocol/at2plus/control_status_common.py b/src/airtouch2/protocol/at2plus/control_status_common.py index 09fd222..24f3d3f 100644 --- a/src/airtouch2/protocol/at2plus/control_status_common.py +++ b/src/airtouch2/protocol/at2plus/control_status_common.py @@ -36,6 +36,19 @@ class ControlStatusSubType(IntEnum): AC_CONTROL = 0x22 AC_STATUS = 0x23 + AC_STATUS2 = 0x10 + SYSTEM_STATUS = 0x2B + ZONE_STATUS = 0x40 + SYSTEM_ID = 0x45 + + +need_ack: list[ControlStatusSubType] = [ + ControlStatusSubType.AC_STATUS2, + ControlStatusSubType.SYSTEM_STATUS, + ControlStatusSubType.ZONE_STATUS, + ControlStatusSubType.SYSTEM_ID, +] + @dataclass class SubDataLength(Serializable): diff --git a/src/airtouch2/protocol/at2plus/messages/Ack.py b/src/airtouch2/protocol/at2plus/messages/Ack.py new file mode 100644 index 0000000..2c03e46 --- /dev/null +++ b/src/airtouch2/protocol/at2plus/messages/Ack.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from airtouch2.common.interfaces import Serializable +from airtouch2.protocol.at2plus.control_status_common import ( + CONTROL_STATUS_SUBHEADER_LENGTH, + ControlStatusSubType, + ControlStatusSubHeader, + SubDataLength, +) +from airtouch2.protocol.at2plus.message_common import ( + AddressMsgType, + Header, + MessageType, + add_checksum_message_buffer, + prime_message_buffer, +) + + +class Ack(Serializable): + message_type: ControlStatusSubType + + def __init__(self, message_type: ControlStatusSubType): + self.message_type = message_type + + def to_bytes(self) -> bytes: + subheader = ControlStatusSubHeader(self.message_type, SubDataLength(1, 0, 0)) + buffer = prime_message_buffer( + Header( + AddressMsgType.NORMAL, + MessageType.CONTROL_STATUS, + CONTROL_STATUS_SUBHEADER_LENGTH + subheader.subdata_length.total(), + ) + ) + buffer.append(subheader) + buffer.append_bytes(bytes([0])) # zero payload byte + add_checksum_message_buffer(buffer) + return buffer.to_bytes() diff --git a/tests/protocol/at2plus/messages/test_ack.py b/tests/protocol/at2plus/messages/test_ack.py new file mode 100644 index 0000000..c672cc3 --- /dev/null +++ b/tests/protocol/at2plus/messages/test_ack.py @@ -0,0 +1,29 @@ +import unittest +from airtouch2.protocol.at2plus.control_status_common import ControlStatusSubType +from airtouch2.protocol.at2plus.messages.Ack import Ack + + +class TestAck(unittest.TestCase): + def _test_common(self, message_type: ControlStatusSubType): + ack = Ack(message_type) + serialised = ack.to_bytes() + expected = bytes([ + 0x55, 0x55, 0x80, 0xb0, 0x01, 0xc0, 0x00, 0x09, + # ControlStatusSubheader with matching sub message type + # Static, 1-byte, zero payload. + int(message_type), 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00 + ]) + self.assertEqual(serialised[:-2].hex(':'), expected.hex(':')) + + def test_ac_status2(self): + # aramshaw says this requires an extra payload byte + self._test_common(ControlStatusSubType.AC_STATUS2) + + def test_system_status(self): + self._test_common(ControlStatusSubType.SYSTEM_STATUS) + + def test_system_id(self): + self._test_common(ControlStatusSubType.SYSTEM_ID) + + def test_zone_status(self): + self._test_common(ControlStatusSubType.ZONE_STATUS) From aa969293f546ca47be6eaf5c8f78168a40a7b4d3 Mon Sep 17 00:00:00 2001 From: aramshaw Date: Sun, 21 Jun 2026 10:58:36 +0800 Subject: [PATCH 2/3] fix: acknowledge with 0xC0 in the address byte, not 0x80 The ACK's top-level address byte must carry the control/status identifier (0xC0), not the usual client address (0x80). Verified on a live AirTouch 2+: acking the status broadcasts with 0x80 triggers a continuous spam storm (~1.7 broadcasts/sec, ~100x normal) until the session is unusable; 0xC0 is accepted and the controller stays calm. It looks redundant with the message-type byte but the controller genuinely validates it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/airtouch2/protocol/at2plus/messages/Ack.py | 11 +++++++++-- tests/protocol/at2plus/messages/test_ack.py | 4 +++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/airtouch2/protocol/at2plus/messages/Ack.py b/src/airtouch2/protocol/at2plus/messages/Ack.py index 2c03e46..f354540 100644 --- a/src/airtouch2/protocol/at2plus/messages/Ack.py +++ b/src/airtouch2/protocol/at2plus/messages/Ack.py @@ -8,13 +8,20 @@ SubDataLength, ) from airtouch2.protocol.at2plus.message_common import ( - AddressMsgType, Header, MessageType, add_checksum_message_buffer, prime_message_buffer, ) +# The controller requires the ACK's top-level address byte to carry the +# control/status identifier (0xC0), NOT the usual client value (0x80). Verified +# on a live AirTouch 2+: acking the status broadcasts with 0x80 triggers a +# continuous broadcast spam storm (~1.7/sec, ~100x normal) until the session is +# unusable; 0xC0 is accepted cleanly. It looks redundant with the message-type +# byte, but the controller genuinely validates it. +CONTROL_STATUS_REPLY_ADDRESS = 0xC0 + class Ack(Serializable): message_type: ControlStatusSubType @@ -26,7 +33,7 @@ def to_bytes(self) -> bytes: subheader = ControlStatusSubHeader(self.message_type, SubDataLength(1, 0, 0)) buffer = prime_message_buffer( Header( - AddressMsgType.NORMAL, + CONTROL_STATUS_REPLY_ADDRESS, MessageType.CONTROL_STATUS, CONTROL_STATUS_SUBHEADER_LENGTH + subheader.subdata_length.total(), ) diff --git a/tests/protocol/at2plus/messages/test_ack.py b/tests/protocol/at2plus/messages/test_ack.py index c672cc3..2bbed11 100644 --- a/tests/protocol/at2plus/messages/test_ack.py +++ b/tests/protocol/at2plus/messages/test_ack.py @@ -8,7 +8,9 @@ def _test_common(self, message_type: ControlStatusSubType): ack = Ack(message_type) serialised = ack.to_bytes() expected = bytes([ - 0x55, 0x55, 0x80, 0xb0, 0x01, 0xc0, 0x00, 0x09, + # Address byte must be 0xC0 (control/status), not 0x80 — verified on a + # live controller: 0x80 triggers a continuous broadcast spam storm. + 0x55, 0x55, 0xc0, 0xb0, 0x01, 0xc0, 0x00, 0x09, # ControlStatusSubheader with matching sub message type # Static, 1-byte, zero payload. int(message_type), 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00 From 0eeffe7355498367c6fa1e144fd86bf841699f6c Mon Sep 17 00:00:00 2001 From: aramshaw Date: Sun, 21 Jun 2026 10:58:36 +0800 Subject: [PATCH 3/3] feat: periodic keep-alive poll to prevent the idle-timeout dropout Acking the broadcasts keeps the controller happy while it is talking, but it still drops the TCP session after ~16 min of silence (it stops broadcasting when nothing changes, e.g. overnight) - the recurring "connection lost" / unavailable problem. A lightweight AC-status request every 4 min keeps the session alive. Verified: 3 days with zero dropouts (vs several per day before). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/airtouch2/at2plus/At2PlusClient.py | 29 +++++++++++++++++++ tests/protocol/at2plus/test_keepalive_poll.py | 23 +++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/protocol/at2plus/test_keepalive_poll.py diff --git a/src/airtouch2/at2plus/At2PlusClient.py b/src/airtouch2/at2plus/At2PlusClient.py index a288c82..c5126c3 100644 --- a/src/airtouch2/at2plus/At2PlusClient.py +++ b/src/airtouch2/at2plus/At2PlusClient.py @@ -19,6 +19,11 @@ _LOGGER = logging.getLogger(__name__) +# The controller drops the TCP session after ~16 min of silence (it stops +# broadcasting when nothing changes, e.g. overnight). A lightweight status +# request every 4 min keeps the session alive and prevents that idle reset. +POLL_INTERVAL_SECONDS = 240 + class At2PlusClient: def __init__(self, host: str, dump_responses: bool = False, task_creator: TaskCreator = asyncio.create_task): @@ -30,6 +35,7 @@ def __init__(self, host: str, dump_responses: bool = False, task_creator: TaskCr 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._poll_task: asyncio.Task[None] | None = None self._new_ac_callbacks: list[Callback] = [] self._ability_message_queue: asyncio.Queue[AcAbilityMessage] = asyncio.Queue() self._found_ac = asyncio.Event() @@ -42,11 +48,15 @@ async def connect(self) -> bool: def run(self) -> None: self._client.run() + self._poll_task = self._task_creator(self._poll_loop()) async def wait_for_ac(self, timeout: int = 5) -> None: await asyncio.wait_for(self._found_ac.wait(), timeout) async def stop(self) -> None: + if self._poll_task is not None: + self._poll_task.cancel() + self._poll_task = None await self._client.stop() def add_new_ac_callback(self, callback: Callback): @@ -70,6 +80,25 @@ def remove_callback() -> None: async def send(self, msg: Serializable): await self._client.send(msg) + async def _send_keepalive_poll(self) -> None: + """Send a lightweight status request to keep the TCP session active. + + The controller resets the socket after ~16 min of silence (it stops + broadcasting when nothing changes, e.g. overnight), so a periodic + request keeps traffic flowing. Best-effort: transient send errors + during reconnection are ignored. + """ + try: + await self._client.send(AcStatusMessage([])) + _LOGGER.debug("Sent keep-alive status poll") + except Exception as e: + _LOGGER.debug(f"Keep-alive poll failed (likely reconnecting): {e}") + + async def _poll_loop(self) -> None: + while True: + await asyncio.sleep(POLL_INTERVAL_SECONDS) + await self._send_keepalive_poll() + async def handle_one_message(self) -> None: message = await self._read_message() if not message: diff --git a/tests/protocol/at2plus/test_keepalive_poll.py b/tests/protocol/at2plus/test_keepalive_poll.py new file mode 100644 index 0000000..08f48d0 --- /dev/null +++ b/tests/protocol/at2plus/test_keepalive_poll.py @@ -0,0 +1,23 @@ +import unittest + +from airtouch2.at2plus.At2PlusClient import At2PlusClient +from airtouch2.protocol.at2plus.messages.AcStatus import AcStatusMessage + + +class TestKeepAlivePoll(unittest.IsolatedAsyncioTestCase): + """The controller drops the session after ~16 min of silence; a periodic + lightweight status request keeps it alive.""" + + async def test_poll_sends_status_request(self): + client = At2PlusClient("localhost") + sent = [] + + async def fake_send(msg): + sent.append(msg) + + client._client.send = fake_send + + await client._send_keepalive_poll() + + self.assertEqual(len(sent), 1) + self.assertIsInstance(sent[0], AcStatusMessage)