35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350 | class PartectorBleManager(threading.Thread):
"""Connects to the Partectors in reach and collects the data they send over the link.
Only connected devices deliver data. The scanner is used to find devices,
to gate connects by signal strength and to refresh stale device handles.
Args:
serial_numbers: Only connect to these devices. None connects to every
Partector in reach, first come first served.
max_links: Upper bound of simultaneous links (including ones that are
still retrying). BlueZ handles about seven reliably.
"""
# How often the manager drains its queues. The queues are bounded and the
# producers are ~1Hz per device, so polling faster only burns CPU on a
# Raspberry Pi Zero 2 W without delivering data any sooner.
LOOP_INTERVAL_SECONDS = 1.0
# The adapter is considered lost after discovery has been down this long.
# Discovery failing *is* the adapter check, so no subprocess is needed while
# running; `bluetoothctl` is only used to decide when it is safe to restart.
ADAPTER_LOST_AFTER_SECONDS = 30.0
ADAPTER_CHECK_INTERVAL_SECONDS = 3.0
# On a normal stop the links get this long to disconnect gracefully before
# their tasks are cancelled.
SHUTDOWN_GRACE_SECONDS = 8.0
DEFAULT_MAX_LINKS = 7
def __init__(
self, serial_numbers: Iterable[int] | None = None, max_links: int = DEFAULT_MAX_LINKS
) -> None:
super().__init__(daemon=True)
self._allowed_serials: frozenset[int] | None = (
frozenset(serial_numbers) if serial_numbers is not None else None
)
self._max_links = max(1, max_links)
self._stop_event = threading.Event()
# Ends the connection tasks of the current scanner session; only polled,
# never awaited, so setting it from another thread is fine.
self._task_stop_event = asyncio.Event()
self._queue_scanner = PartectorBleScanner.create_scanner_queue()
self._queue_connection = PartectorBleConnection.create_connection_queue()
self._links: dict[int, BleLink] = {} # key: serial number
self._rejected_for_cap: set[int] = set()
self._scanner: PartectorBleScanner | None = None
# Raw data points from the links, converted to DataFrames only in
# get_data(). Building them here would put pandas on the event loop that
# also services the BLE notifications, which on a Raspberry Pi Zero 2 W is
# enough to stall the links themselves.
self._points: dict[int, list[NaneosDeviceDataPoint]] = {}
# == Public API (any thread) ===================================================================
def get_data(self) -> dict[int, pd.DataFrame]:
"""Returns the collected data as DataFrames and clears the buffer."""
# Swap first: the BLE thread keeps appending while we convert.
points, self._points = self._points, {}
return {
serial: df
for serial, serial_points in points.items()
if not (df := to_pandas_df(serial_points)).empty
}
def stop(self) -> None:
self._task_stop_event.set()
self._stop_event.set()
def run(self) -> None:
try:
asyncio.run(self._async_run())
except RuntimeError as e:
logger.exception(f"BLEManager loop exited with: {e}")
def get_connected_device_strings(self) -> list[str]:
"""Devices with a live BLE link, P2 Pro first. A link whose family is not
known yet is listed as P2. Devices that are only being retried are not listed."""
live = [(sn, link.device_type) for sn, link in self._live_links()]
pro = [f"SN{sn} (P2 Pro)" for sn, kind in live if kind == DeviceType.P2PRO]
other = [f"SN{sn} (P2)" for sn, kind in live if kind != DeviceType.P2PRO]
return pro + other
def get_connected_serial_numbers(self) -> list[int]:
"""Serial numbers of the devices with a live BLE link."""
return [sn for sn, _ in self._live_links()]
def _live_links(self) -> list[tuple[int, BleLink]]:
# Copy first: the event loop thread changes the dict while we iterate.
return [(sn, link) for sn, link in list(self._links.items()) if link.is_connected]
# == Event loop ================================================================================
async def _async_run(self) -> None:
self._loop = asyncio.get_running_loop()
while not self._stop_event.is_set():
await self._wait_for_bluetooth_adapter()
if self._stop_event.is_set():
break
self._task_stop_event.clear()
self._scanner = PartectorBleScanner(loop=self._loop, queue=self._queue_scanner)
adapter_lost = False
try:
async with self._scanner:
logger.info("Scanner started.")
adapter_lost = await self._manager_loop()
except asyncio.CancelledError:
logger.info("BLEManager cancelled.")
break
finally:
# A lost adapter cannot disconnect anything gracefully anyway.
grace = 0.0 if adapter_lost else self.SHUTDOWN_GRACE_SECONDS
await self._shutdown_links(grace_seconds=grace)
logger.info("BLEManager cleanup complete.")
async def _manager_loop(self) -> bool:
"""Runs until stopped. Returns True if it ended because the adapter was lost."""
discovery_down_since: float | None = None
while not self._stop_event.is_set():
try:
# The scanner reports whether BlueZ discovery is actually running.
# Probing the adapter with a `bluetoothctl` subprocess instead cost
# a fork/exec plus a D-Bus round trip against the same bluetoothd
# that carries the BLE links.
if self._scanner is not None and not self._scanner.is_discovering:
now = time.monotonic()
if discovery_down_since is None:
discovery_down_since = now
elif now - discovery_down_since >= self.ADAPTER_LOST_AFTER_SECONDS:
logger.warning("Bluetooth adapter lost. Stopping all connections...")
return True
else:
discovery_down_since = None
await asyncio.sleep(self.LOOP_INTERVAL_SECONDS)
await self._scanner_queue_routine()
await self._connection_queue_routine()
self._forget_finished_links()
except Exception as e:
logger.exception(f"Error in manager loop: {e}")
return False
async def _shutdown_links(self, grace_seconds: float) -> None:
"""Ends every connection task: first by asking, then by cancelling."""
self._task_stop_event.set()
pending = [link.task for link in self._links.values() if not link.task.done()]
if pending and grace_seconds > 0:
_, still_pending = await asyncio.wait(pending, timeout=grace_seconds)
pending = list(still_pending)
if pending:
# Normal for a link that is inside a connect attempt to an
# unreachable device: connect() can take up to its own timeout.
logger.info(f"{len(pending)} connection task(s) still busy, cancelling.")
for task in pending:
task.cancel()
if pending:
await asyncio.wait(pending, timeout=2.0) # let the cancellation propagate
self._links.clear()
async def _task_connection(self, device: BLEDevice, serial: int) -> None:
connection = PartectorBleConnection(
device=device,
loop=self._loop,
serial_number=serial,
queue=self._queue_connection,
rssi_provider=lambda: self._get_rssi(device.address),
device_provider=lambda: self._get_device(device.address),
)
link = self._links.get(serial)
if link is not None:
link.connection = connection
try:
async with connection:
while not self._task_stop_event.is_set():
await asyncio.sleep(0.5)
except asyncio.CancelledError:
logger.info(f"{serial}: Connection task cancelled.")
except Exception as e:
logger.warning(f"{serial}: Connection task failed: {e}")
finally:
if link is not None:
link.connection = None
logger.info(f"{serial}: Connection task finished.")
def _forget_finished_links(self) -> None:
for serial, link in list(self._links.items()):
if link.task.done():
self._links.pop(serial, None)
logger.info(f"{serial}: Connection task finished and popped.")
# == Adapter ===================================================================================
async def _wait_for_bluetooth_adapter(self) -> None:
while not self._stop_event.is_set():
if await self._adapter_available():
logger.info("Bluetooth adapter is available and ready.")
return
logger.info(
"Bluetooth adapter not available. "
f"Retrying in {self.ADAPTER_CHECK_INTERVAL_SECONDS} seconds..."
)
await asyncio.sleep(self.ADAPTER_CHECK_INTERVAL_SECONDS)
@staticmethod
async def _adapter_available() -> bool:
"""Is there a powered Bluetooth adapter to start a scanner session on?"""
if sys.platform.startswith("linux"):
return await _bluez_adapter_powered()
try:
scanner = BleakScanner()
await scanner.start()
await scanner.stop()
return True
except Exception as e:
logger.debug(f"Bluetooth adapter not available: {e}")
return False
# == Queue draining ============================================================================
def _get_device(self, address: str) -> BLEDevice | None:
"""Most recently advertised BLEDevice for an address, or None."""
if self._scanner is None:
return None
return self._scanner.get_device(address)
def _get_rssi(self, address: str) -> int | None:
"""Most recent RSSI for an address, or None if it is stale / unknown."""
if self._scanner is None:
return None
return self._scanner.get_rssi(address)
def _buffer_points(self, points: list[NaneosDeviceDataPoint]) -> None:
"""Append data points to the per-device buffer, keeping the newest ones."""
for point in points:
if point.serial_number is None:
continue
buffered = self._points.setdefault(point.serial_number, [])
buffered.append(point)
if len(buffered) > MAX_ROWS_PER_DEVICE:
del buffered[:-MAX_ROWS_PER_DEVICE]
async def _scanner_queue_routine(self) -> None:
"""Drain the scanner queue and start a link for every new device that qualifies."""
seen: dict[int, BLEDevice] = {}
while not self._queue_scanner.empty():
try:
device, serial = self._queue_scanner.get_nowait()
except asyncio.QueueEmpty:
break
seen[serial] = device
for serial, device in seen.items():
if serial in self._links:
continue
if not self._wants_link(serial):
continue
rssi = self._get_rssi(device.address)
if rssi is not None and rssi < PartectorBleConnection.MIN_RSSI_CONNECT_DBM:
logger.info(
f"Ignoring serial={serial} ({device.address}): RSSI {rssi} dBm is below "
f"{PartectorBleConnection.MIN_RSSI_CONNECT_DBM} dBm."
)
continue
logger.info(
f"New device detected: serial={serial}, address={device.address}, rssi={rssi}"
)
task = self._loop.create_task(self._task_connection(device, serial))
self._links[serial] = BleLink(task=task)
def _wants_link(self, serial: int) -> bool:
"""Allow-list and link cap. Logged once per device per decision, not per second."""
if self._allowed_serials is not None and serial not in self._allowed_serials:
return False
if len(self._links) >= self._max_links:
if serial not in self._rejected_for_cap:
logger.info(f"Not connecting to serial={serial}: {self._max_links} links in use.")
self._rejected_for_cap.add(serial)
return False
self._rejected_for_cap.discard(serial)
return True
async def _connection_queue_routine(self) -> None:
"""Drain the connection queue and record the data points in one batch."""
batch_data: list[NaneosDeviceDataPoint] = []
while not self._queue_connection.empty():
try:
batch_data.append(self._queue_connection.get_nowait())
except asyncio.QueueEmpty:
break
# A connected device reveals whether it is a P2 or a P2 Pro through the
# points it sends.
for data in batch_data:
if data.serial_number is None or data.device_type is None:
continue
link = self._links.get(data.serial_number)
if link is not None:
link.device_type = data.device_type
self._buffer_points(batch_data)
|