commit 358ca1f20db5de7d29e3da06fc78c09c34bc755d parent 049e0af18a54a77826e59c2066e5738eb9e123d7 Author: Stéphan Kochen <git@stephank.nl> Date: Thu, 03 Sep 2026 20:28:22 +0200 Handle fragmented BLE mesh packets
diff --git a/custom_components/inlite/lib/inlite_ble/hub.py b/custom_components/inlite/lib/inlite_ble/hub.py index 9a6ac5d0676ab6c9827f3b34e614b56d3795c177..dc667745b7b34e05014aa4d2415c19dd49d99dfe 100644 --- a/custom_components/inlite/lib/inlite_ble/hub.py +++ b/custom_components/inlite/lib/inlite_ble/hub.py @@ -45,6 +45,7 @@ WRITE_DELAY = 0.06 # 60ms between BLE writes (matches app timing) ACK_TIMEOUT = 2.0 # seconds to wait for hub ACK STREAM_TIMEOUT = 3.0 # seconds to wait for STREAM response +BLE_PACKET_PART_SIZE = 78 # Matches the official app's GATT packet splitter. class ZoneState: @@ -120,6 +121,7 @@ self._stream_event = asyncio.Event() self._last_stream_data = b"" self._stream_buffer = bytearray() self._stream_invalid = False + self._incoming_ble_buffer = bytearray() self._zone_states: dict[int, ZoneState] = {} self._firmware_version: int | None = None self._discovered_device_ids: set[int] = set() @@ -198,8 +200,18 @@ # Subscribe to notifications on both bidirectional characteristics if subscribe: handler = notification_handler or self._on_notification - await self._client.start_notify(CHAR_WRITE_UUID, handler) - await self._client.start_notify(CHAR_CONTINUATION_UUID, handler) + # A CSRmesh packet can be split over the continuation and complete + # characteristics. Pass the characteristic identity on explicitly: + # Bleak otherwise supplies a backend-specific sender (usually an + # integer handle), which cannot be compared portably. + await self._client.start_notify( + CHAR_WRITE_UUID, + lambda _sender, data: handler(CHAR_WRITE_UUID, data), + ) + await self._client.start_notify( + CHAR_CONTINUATION_UUID, + lambda _sender, data: handler(CHAR_CONTINUATION_UUID, data), + ) _LOGGER.info("Notifications enabled") return True @@ -232,7 +244,14 @@ self.handle_notification(sender, data) def handle_notification(self, sender: Any, data: bytearray) -> None: """Process one notification. Called by the shared BLE dispatcher.""" - raw = bytes(data) + if sender == CHAR_CONTINUATION_UUID: + self._incoming_ble_buffer.extend(data) + return + + # The complete characteristic terminates one encrypted packet. A + # one-piece packet simply has an empty continuation buffer. + raw = bytes(self._incoming_ble_buffer) + bytes(data) + self._incoming_ble_buffer.clear() decrypted = self._crypto.decrypt_packet(raw) if decrypted is None: return @@ -346,10 +365,17 @@ self._on_teach_in_progress, source_id, value[0] ) async def _write(self, packet: bytes) -> None: - """Write an encrypted packet to the hub.""" + """Write an encrypted packet, splitting BLE continuation fragments.""" if not self._client or not self._client.is_connected: raise ConnectionError("Not connected to hub") - await self._client.write_gatt_char(CHAR_WRITE_UUID, packet, response=True) + for offset in range(0, len(packet), BLE_PACKET_PART_SIZE): + part = packet[offset : offset + BLE_PACKET_PART_SIZE] + is_final = offset + BLE_PACKET_PART_SIZE >= len(packet) + await self._client.write_gatt_char( + CHAR_WRITE_UUID if is_final else CHAR_CONTINUATION_UUID, + part, + response=True, + ) async def _write_mesh(self, dest_id: int, pkt_type: int, data: bytes) -> None: """Encrypt and write a mesh packet.""" diff --git a/tests/test_hub.py b/tests/test_hub.py index c44839b2c1a1a1f729d305f7e6dc80c82733cb48..5e6c3841ca68d9eaaed6c83d3f8d7e4266b26055 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -2,7 +2,8 @@ """Tests for inlite_ble hub module — ZoneState and notification safety.""" import asyncio -from inlite_ble.hub import InliteHub, ZoneState +from inlite_ble.hub import BLE_PACKET_PART_SIZE, InliteHub, ZoneState +from inlite_ble.protocol import CHAR_CONTINUATION_UUID, CHAR_WRITE_UUID class TestZoneState: @@ -91,6 +92,46 @@ return writes writes = asyncio.run(run()) assert writes == [(0x72, b"\x00\x00"), (0x72, b"\x03\x00"), (0x72, b"\x03\x00\xef")] + + def test_complete_notification_reassembles_continuation(self) -> None: + received: list[dict] = [] + hub = InliteHub(device_id=1, passphrase="test") + loop = asyncio.new_event_loop() + try: + hub._loop = loop + hub._notification_callback = received.append + packet = hub._crypto.encrypt_packet(0, 0x73, b"x" * 100) + hub.handle_notification(CHAR_CONTINUATION_UUID, bytearray(packet[:78])) + assert received == [] + hub.handle_notification(CHAR_WRITE_UUID, bytearray(packet[78:])) + loop.call_soon(loop.stop) + loop.run_forever() + finally: + loop.close() + assert received[0]["data"] == b"x" * 100 + + def test_write_splits_large_ble_packet(self) -> None: + class Client: + is_connected = True + + def __init__(self) -> None: + self.writes: list[tuple[str, bytes, bool]] = [] + + async def write_gatt_char(self, char: str, data: bytes, response: bool) -> None: + self.writes.append((char, data, response)) + + async def run() -> Client: + hub = InliteHub(device_id=1, passphrase="test") + client = Client() + hub._client = client # type: ignore[assignment] + await hub._write(b"x" * (BLE_PACKET_PART_SIZE + 1)) + return client + + client = asyncio.run(run()) + assert client.writes == [ + (CHAR_CONTINUATION_UUID, b"x" * BLE_PACKET_PART_SIZE, True), + (CHAR_WRITE_UUID, b"x", True), + ] def test_teach_in_tlv_is_forwarded(self) -> None: received: list[tuple[int, int]] = []