commit 73d3ff4d595a1b23d627bc0a55b4f88a7bdc4738 parent b6ac0e3dd42073246614a537f3cadd505e5e5b7c Author: Stéphan Kochen <git@stephank.nl> Date: Tue, 29 Sep 2026 21:52:31 +0200 Handle BLE notifications on main thread
diff --git a/custom_components/inlite/coordinator.py b/custom_components/inlite/coordinator.py index 3d087b63731e320cdb88789b9f00556f2aadcece..eceba53a0b41c0ff9206f03a01dca9d7aa8959bd 100644 --- a/custom_components/inlite/coordinator.py +++ b/custom_components/inlite/coordinator.py @@ -184,7 +184,7 @@ def _dispatch_notification(self, sender: object, data: bytearray) -> None: """Route notifications from the one shared BLE subscription.""" if self._active_hub is not None: - self._active_hub.handle_notification(sender, data) + self._active_hub.queue_notification(sender, data) async def _disconnect_shared(self) -> None: """Close the one gateway connection and detach every logical hub.""" diff --git a/custom_components/inlite/lib/inlite_ble/hub.py b/custom_components/inlite/lib/inlite_ble/hub.py index b8ad50442b7d914418d568126e6e713e592c5d19..295bf05dbf46f7314e737da61f8864d1c38ef861 100644 --- a/custom_components/inlite/lib/inlite_ble/hub.py +++ b/custom_components/inlite/lib/inlite_ble/hub.py @@ -251,15 +251,23 @@ self._client = None self._loop = None def _on_notification(self, sender: Any, data: bytearray) -> None: - """Handle incoming BLE notifications. + """Receive a BLE notification from Bleak on an arbitrary thread.""" + self.queue_notification(sender, data) + + def queue_notification(self, sender: Any, data: bytearray) -> None: + """Schedule notification processing on the hub's event loop. - Bleak calls this from a background thread, so we use - call_soon_threadsafe to schedule event-loop work safely. + This must be the entry point for BLE callbacks. In particular, the + continuation buffer is mutable and must never be assembled from the + callback thread. """ - self.handle_notification(sender, data) + loop = self._loop + if loop is None or loop.is_closed(): + return + loop.call_soon_threadsafe(self.handle_notification, sender, bytearray(data)) def handle_notification(self, sender: Any, data: bytearray) -> None: - """Process one notification. Called by the shared BLE dispatcher.""" + """Process one notification on the hub's event loop.""" if sender == CHAR_CONTINUATION_UUID: self._incoming_ble_buffer.extend(data) return diff --git a/tests/test_hub.py b/tests/test_hub.py index af09b8b73b662d6211485bba6929a8831525e86a..b482d2da017746a97eee537c376717bcdf8b3eb7 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -84,7 +84,7 @@ def test_notification_uses_call_soon_threadsafe(self) -> None: """Verify the notification handler references call_soon_threadsafe.""" import inspect - source = inspect.getsource(InliteHub._on_notification) + source = inspect.getsource(InliteHub.queue_notification) assert "call_soon_threadsafe" in source def test_stream_segments_are_reassembled_by_offset(self) -> None: @@ -110,6 +110,24 @@ "dest_id": hub._crypto.controller_address, "data": b"\x00\x00", } hub.handle_notification(CHAR_WRITE_UUID, bytearray(b"packet")) + await asyncio.sleep(0) + _, payload = await hub._wait_packet((PKT_BLOCK_FLUSH,), 0.1) + return payload + + assert asyncio.run(run()) == b"\x00\x00" + + def test_notification_is_marshaled_to_the_event_loop(self) -> None: + async def run() -> bytes: + hub = InliteHub(device_id=0x1234, passphrase="test") + hub._loop = asyncio.get_running_loop() + hub._crypto.decrypt_packet = lambda _packet: { # type: ignore[method-assign] + "pkt_type": PKT_BLOCK_FLUSH, + "src_id": hub.device_id, + "dest_id": hub._crypto.controller_address, + "data": b"\x00\x00", + } + hub.queue_notification(CHAR_WRITE_UUID, bytearray(b"packet")) + assert hub._packet_queue.empty() await asyncio.sleep(0) _, payload = await hub._wait_packet((PKT_BLOCK_FLUSH,), 0.1) return payload