diff --git a/micropython/bluetooth/aioble/aioble/server.py b/micropython/bluetooth/aioble/aioble/server.py index 5d5d7399b..1e8ae0d6d 100644 --- a/micropython/bluetooth/aioble/aioble/server.py +++ b/micropython/bluetooth/aioble/aioble/server.py @@ -15,7 +15,7 @@ register_irq_handler, GattError, ) -from .device import DeviceConnection, DeviceTimeout +from .device import DeviceConnection, DeviceDisconnectedError, DeviceTimeout _registered_characteristics = {} @@ -56,6 +56,10 @@ def _server_irq(event, data): def _server_shutdown(): global _registered_characteristics + for characteristic in _registered_characteristics.values(): + if hasattr(characteristic, "_write_event"): + characteristic._write_event.set() + characteristic._value_handle = None _registered_characteristics = {} if hasattr(BaseCharacteristic, "_capture_task"): BaseCharacteristic._capture_task.cancel() @@ -84,7 +88,6 @@ def _register(self, value_handle): _registered_characteristics[value_handle] = self if self._initial is not None: self.write(self._initial) - self._initial = None # Read value from local db. def read(self): @@ -100,13 +103,21 @@ def write(self, data, send_update=False): else: ble.gatts_write(self._value_handle, data, send_update) - # When the a capture-enabled characteristic is created, create the + # When a capture-enabled characteristic is created, create the # necessary events (if not already created). + # Guard on _capture_task (not _capture_queue) to match _server_shutdown() + # which guards on _capture_task. This ensures partial teardown (task gone + # but queue remains) self-heals instead of silently no-oping. @staticmethod def _init_capture(): - if hasattr(BaseCharacteristic, "_capture_queue"): + if hasattr(BaseCharacteristic, "_capture_task"): return - + # Clean up any partial state from incomplete shutdown + for attr in ("_capture_queue", "_capture_write_event", "_capture_consumed_event"): + try: + delattr(BaseCharacteristic, attr) + except AttributeError: + pass BaseCharacteristic._capture_queue = deque((), _WRITE_CAPTURE_QUEUE_LIMIT) BaseCharacteristic._capture_write_event = asyncio.ThreadSafeFlag() BaseCharacteristic._capture_consumed_event = asyncio.ThreadSafeFlag() @@ -152,6 +163,9 @@ async def written(self, timeout_ms=None): with DeviceTimeout(None, timeout_ms): await self._write_event.wait() + if self._value_handle is None: + raise DeviceDisconnectedError + # Return the write data and clear the stored copy. # In default usage this will be just the connection handle. # In capture mode this will be a tuple of (connection_handle, received_data) @@ -338,3 +352,8 @@ def register_services(*services): for descriptor in characteristic.descriptors: descriptor._register(service_handles[n]) n += 1 + + for characteristic in _registered_characteristics.values(): + if characteristic.flags & _FLAG_WRITE_CAPTURE: + BaseCharacteristic._init_capture() + break diff --git a/micropython/bluetooth/aioble/multitests/ble_reregister.py b/micropython/bluetooth/aioble/multitests/ble_reregister.py new file mode 100644 index 000000000..75c55eea1 --- /dev/null +++ b/micropython/bluetooth/aioble/multitests/ble_reregister.py @@ -0,0 +1,114 @@ +# Test that singleton service/characteristic instances can be re-registered +# across multiple stop/start cycles without data loss. + +import sys + +# ruff: noqa: E402 +sys.path.append("") + +from micropython import const +import machine +import time + +import asyncio +import aioble +import bluetooth + +TIMEOUT_MS = 5000 + +SERVICE_UUID = bluetooth.UUID("A5A5A5A5-FFFF-9999-1111-5A5A5A5A5A5A") +CHAR_INITIAL_UUID = bluetooth.UUID("00000000-1111-2222-3333-444444444444") +CHAR_WRITE_UUID = bluetooth.UUID("00000000-1111-2222-3333-555555555555") + + +# Acting in peripheral role. +async def instance0_task(): + # Create service and characteristics ONCE (singleton pattern). + service = aioble.Service(SERVICE_UUID) + aioble.Characteristic(service, CHAR_INITIAL_UUID, read=True, initial=b"hello") + char_write = aioble.Characteristic(service, CHAR_WRITE_UUID, read=True, write=True) + + multitest.globals(BDADDR=aioble.config("mac")) + multitest.next() + + for i in range(3): + # Re-register the same service instances. + aioble.register_services(service) + + # Write a cycle-specific value to the writable characteristic. + char_write.write("periph{}".format(i)) + + multitest.broadcast("connect-{}".format(i)) + + # Wait for central to connect. + print("advertise", i) + connection = await aioble.advertise( + 20_000, adv_data=b"\x02\x01\x06\x04\xffMPY", timeout_ms=TIMEOUT_MS + ) + print("connected", i) + + # Wait for the central to write. + await char_write.written(timeout_ms=TIMEOUT_MS) + print("written", i) + + # Wait for the central to disconnect. + await connection.disconnected(timeout_ms=TIMEOUT_MS) + print("disconnected", i) + + # Shutdown aioble. + print("shutdown", i) + aioble.stop() + + await asyncio.sleep_ms(100) + + +def instance0(): + try: + asyncio.run(instance0_task()) + finally: + aioble.stop() + + +# Acting in central role. +async def instance1_task(): + multitest.next() + + for i in range(3): + multitest.wait("connect-{}".format(i)) + + # Connect to peripheral. + print("connect", i) + device = aioble.Device(*BDADDR) + connection = await device.connect(timeout_ms=TIMEOUT_MS) + + # Discover characteristics. + service = await connection.service(SERVICE_UUID) + char_initial = await service.characteristic(CHAR_INITIAL_UUID) + char_write = await service.characteristic(CHAR_WRITE_UUID) + + # Read the initial= characteristic — must be the same every cycle. + print("read initial", await char_initial.read(timeout_ms=TIMEOUT_MS)) + + # Read the writable characteristic — should have cycle-specific value. + print("read written", await char_write.read(timeout_ms=TIMEOUT_MS)) + + # Write to the writable characteristic. + print("write", i) + await char_write.write("central{}".format(i), response=True, timeout_ms=TIMEOUT_MS) + + # Disconnect from peripheral. + print("disconnect", i) + await connection.disconnect(timeout_ms=TIMEOUT_MS) + print("disconnected", i) + + # Shutdown aioble. + aioble.stop() + + await asyncio.sleep_ms(100) + + +def instance1(): + try: + asyncio.run(instance1_task()) + finally: + aioble.stop() diff --git a/micropython/bluetooth/aioble/multitests/ble_reregister.py.exp b/micropython/bluetooth/aioble/multitests/ble_reregister.py.exp new file mode 100644 index 000000000..898b02b01 --- /dev/null +++ b/micropython/bluetooth/aioble/multitests/ble_reregister.py.exp @@ -0,0 +1,35 @@ +--- instance0 --- +advertise 0 +connected 0 +written 0 +disconnected 0 +shutdown 0 +advertise 1 +connected 1 +written 1 +disconnected 1 +shutdown 1 +advertise 2 +connected 2 +written 2 +disconnected 2 +shutdown 2 +--- instance1 --- +connect 0 +read initial b'hello' +read written b'periph0' +write 0 +disconnect 0 +disconnected 0 +connect 1 +read initial b'hello' +read written b'periph1' +write 1 +disconnect 1 +disconnected 1 +connect 2 +read initial b'hello' +read written b'periph2' +write 2 +disconnect 2 +disconnected 2