Skip to content

Commit 80c9ffb

Browse files
committed
aioble/server: Support singleton re-registration across restart cycles.
Enable a pattern where Service and Characteristic objects are created once and re-registered via register_services() after each BLE radio restart, avoiding per-cycle heap allocations on constrained targets. Fix _register() consuming initial= values after first registration. Fix _server_shutdown() not waking tasks blocked in written(). Fix _init_capture() guard mismatch with _server_shutdown() preventing capture infrastructure rebuild after shutdown. Add register_services() auto-rebuild of capture infrastructure. Add ble_reregister multitest covering 3 stop/start cycles. Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
1 parent 6ae440a commit 80c9ffb

File tree

3 files changed

+173
-5
lines changed

3 files changed

+173
-5
lines changed

micropython/bluetooth/aioble/aioble/server.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
register_irq_handler,
1616
GattError,
1717
)
18-
from .device import DeviceConnection, DeviceTimeout
18+
from .device import DeviceConnection, DeviceDisconnectedError, DeviceTimeout
1919

2020
_registered_characteristics = {}
2121

@@ -56,6 +56,10 @@ def _server_irq(event, data):
5656

5757
def _server_shutdown():
5858
global _registered_characteristics
59+
for characteristic in _registered_characteristics.values():
60+
if hasattr(characteristic, "_write_event"):
61+
characteristic._write_event.set()
62+
characteristic._value_handle = None
5963
_registered_characteristics = {}
6064
if hasattr(BaseCharacteristic, "_capture_task"):
6165
BaseCharacteristic._capture_task.cancel()
@@ -84,7 +88,6 @@ def _register(self, value_handle):
8488
_registered_characteristics[value_handle] = self
8589
if self._initial is not None:
8690
self.write(self._initial)
87-
self._initial = None
8891

8992
# Read value from local db.
9093
def read(self):
@@ -100,13 +103,21 @@ def write(self, data, send_update=False):
100103
else:
101104
ble.gatts_write(self._value_handle, data, send_update)
102105

103-
# When the a capture-enabled characteristic is created, create the
106+
# When a capture-enabled characteristic is created, create the
104107
# necessary events (if not already created).
108+
# Guard on _capture_task (not _capture_queue) to match _server_shutdown()
109+
# which guards on _capture_task. This ensures partial teardown (task gone
110+
# but queue remains) self-heals instead of silently no-oping.
105111
@staticmethod
106112
def _init_capture():
107-
if hasattr(BaseCharacteristic, "_capture_queue"):
113+
if hasattr(BaseCharacteristic, "_capture_task"):
108114
return
109-
115+
# Clean up any partial state from incomplete shutdown
116+
for attr in ("_capture_queue", "_capture_write_event", "_capture_consumed_event"):
117+
try:
118+
delattr(BaseCharacteristic, attr)
119+
except AttributeError:
120+
pass
110121
BaseCharacteristic._capture_queue = deque((), _WRITE_CAPTURE_QUEUE_LIMIT)
111122
BaseCharacteristic._capture_write_event = asyncio.ThreadSafeFlag()
112123
BaseCharacteristic._capture_consumed_event = asyncio.ThreadSafeFlag()
@@ -152,6 +163,9 @@ async def written(self, timeout_ms=None):
152163
with DeviceTimeout(None, timeout_ms):
153164
await self._write_event.wait()
154165

166+
if self._value_handle is None:
167+
raise DeviceDisconnectedError
168+
155169
# Return the write data and clear the stored copy.
156170
# In default usage this will be just the connection handle.
157171
# In capture mode this will be a tuple of (connection_handle, received_data)
@@ -338,3 +352,8 @@ def register_services(*services):
338352
for descriptor in characteristic.descriptors:
339353
descriptor._register(service_handles[n])
340354
n += 1
355+
356+
for characteristic in _registered_characteristics.values():
357+
if characteristic.flags & _FLAG_WRITE_CAPTURE:
358+
BaseCharacteristic._init_capture()
359+
break
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Test that singleton service/characteristic instances can be re-registered
2+
# across multiple stop/start cycles without data loss.
3+
4+
import sys
5+
6+
# ruff: noqa: E402
7+
sys.path.append("")
8+
9+
from micropython import const
10+
import machine
11+
import time
12+
13+
import asyncio
14+
import aioble
15+
import bluetooth
16+
17+
TIMEOUT_MS = 5000
18+
19+
SERVICE_UUID = bluetooth.UUID("A5A5A5A5-FFFF-9999-1111-5A5A5A5A5A5A")
20+
CHAR_INITIAL_UUID = bluetooth.UUID("00000000-1111-2222-3333-444444444444")
21+
CHAR_WRITE_UUID = bluetooth.UUID("00000000-1111-2222-3333-555555555555")
22+
23+
24+
# Acting in peripheral role.
25+
async def instance0_task():
26+
# Create service and characteristics ONCE (singleton pattern).
27+
service = aioble.Service(SERVICE_UUID)
28+
aioble.Characteristic(service, CHAR_INITIAL_UUID, read=True, initial=b"hello")
29+
char_write = aioble.Characteristic(service, CHAR_WRITE_UUID, read=True, write=True)
30+
31+
multitest.globals(BDADDR=aioble.config("mac"))
32+
multitest.next()
33+
34+
for i in range(3):
35+
# Re-register the same service instances.
36+
aioble.register_services(service)
37+
38+
# Write a cycle-specific value to the writable characteristic.
39+
char_write.write("periph{}".format(i))
40+
41+
multitest.broadcast("connect-{}".format(i))
42+
43+
# Wait for central to connect.
44+
print("advertise", i)
45+
connection = await aioble.advertise(
46+
20_000, adv_data=b"\x02\x01\x06\x04\xffMPY", timeout_ms=TIMEOUT_MS
47+
)
48+
print("connected", i)
49+
50+
# Wait for the central to write.
51+
await char_write.written(timeout_ms=TIMEOUT_MS)
52+
print("written", i)
53+
54+
# Wait for the central to disconnect.
55+
await connection.disconnected(timeout_ms=TIMEOUT_MS)
56+
print("disconnected", i)
57+
58+
# Shutdown aioble.
59+
print("shutdown", i)
60+
aioble.stop()
61+
62+
await asyncio.sleep_ms(100)
63+
64+
65+
def instance0():
66+
try:
67+
asyncio.run(instance0_task())
68+
finally:
69+
aioble.stop()
70+
71+
72+
# Acting in central role.
73+
async def instance1_task():
74+
multitest.next()
75+
76+
for i in range(3):
77+
multitest.wait("connect-{}".format(i))
78+
79+
# Connect to peripheral.
80+
print("connect", i)
81+
device = aioble.Device(*BDADDR)
82+
connection = await device.connect(timeout_ms=TIMEOUT_MS)
83+
84+
# Discover characteristics.
85+
service = await connection.service(SERVICE_UUID)
86+
char_initial = await service.characteristic(CHAR_INITIAL_UUID)
87+
char_write = await service.characteristic(CHAR_WRITE_UUID)
88+
89+
# Read the initial= characteristic — must be the same every cycle.
90+
print("read initial", await char_initial.read(timeout_ms=TIMEOUT_MS))
91+
92+
# Read the writable characteristic — should have cycle-specific value.
93+
print("read written", await char_write.read(timeout_ms=TIMEOUT_MS))
94+
95+
# Write to the writable characteristic.
96+
print("write", i)
97+
await char_write.write("central{}".format(i), response=True, timeout_ms=TIMEOUT_MS)
98+
99+
# Disconnect from peripheral.
100+
print("disconnect", i)
101+
await connection.disconnect(timeout_ms=TIMEOUT_MS)
102+
print("disconnected", i)
103+
104+
# Shutdown aioble.
105+
aioble.stop()
106+
107+
await asyncio.sleep_ms(100)
108+
109+
110+
def instance1():
111+
try:
112+
asyncio.run(instance1_task())
113+
finally:
114+
aioble.stop()
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
--- instance0 ---
2+
advertise 0
3+
connected 0
4+
written 0
5+
disconnected 0
6+
shutdown 0
7+
advertise 1
8+
connected 1
9+
written 1
10+
disconnected 1
11+
shutdown 1
12+
advertise 2
13+
connected 2
14+
written 2
15+
disconnected 2
16+
shutdown 2
17+
--- instance1 ---
18+
connect 0
19+
read initial b'hello'
20+
read written b'periph0'
21+
write 0
22+
disconnect 0
23+
disconnected 0
24+
connect 1
25+
read initial b'hello'
26+
read written b'periph1'
27+
write 1
28+
disconnect 1
29+
disconnected 1
30+
connect 2
31+
read initial b'hello'
32+
read written b'periph2'
33+
write 2
34+
disconnect 2
35+
disconnected 2

0 commit comments

Comments
 (0)