Skip to content

naneos.ble.partector

Partectors on BLE.

BlePartector

Bases: PartectorDevice

A Partector on a BLE link, usable from any thread but the BLE manager's.

The link lives on the event loop of PartectorBleManager; every call is handed over to that loop. The handle stays valid while the manager keeps the link, also across reconnects: is_connected tells if it is up right now.

Source code in src/naneos/ble/partector/device.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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
class BlePartector(PartectorDevice):
    """A Partector on a BLE link, usable from any thread but the BLE manager's.

    The link lives on the event loop of PartectorBleManager; every call is
    handed over to that loop. The handle stays valid while the manager keeps
    the link, also across reconnects: is_connected tells if it is up right now.
    """

    # On top of the command's own timeout: waiting for the command in flight
    # and for the loop to pick the call up.
    HANDOVER_TIMEOUT_SECONDS = 5.0

    def __init__(self, connection: PartectorBleConnection, loop: asyncio.AbstractEventLoop) -> None:
        self._connection = connection
        self._loop = loop
        self._loop_thread = threading.get_ident()  # created on the loop's thread

    @property
    def serial_number(self) -> int:
        return self._connection.SERIAL_NUMBER

    @property
    def device_type(self) -> DeviceType | None:
        return self._connection.device_type

    @property
    def firmware_version(self) -> int | None:
        return self._connection.firmware_version

    @property
    def connection_type(self) -> ConnectionType:
        return ConnectionType.CONNECTED

    @property
    def is_connected(self) -> bool:
        return self._connection.is_connected

    @property
    def sample_rate_hz(self) -> float:
        return 1

    def write(self, command: str) -> None:
        self._run(self._connection.write(command), BleCommandChannel.QUERY_TIMEOUT_SECONDS)

    def query(self, command: str, timeout: float | None = None) -> list[str]:
        timeout = timeout or BleCommandChannel.QUERY_TIMEOUT_SECONDS
        return self._run(self._connection.query(command, timeout), timeout)

    def set_sample_rate(self, hz: int | None) -> None:
        if hz is None:
            return  # 1 Hz is the default over BLE
        raise NotSupportedError(
            "The data rate is fixed at 1 Hz over BLE; it can only be changed over USB."
        )

    def read_ui_curve(self, timeout: float | None = None) -> UiCurve:
        """See PartectorDevice. Over BLE the readout takes about 40 s, default timeout 90 s."""
        timeout = timeout or BLE_READOUT_TIMEOUT_SECONDS
        return self._run(self._connection.read_ui_curve(timeout), UI_COMPUTE_SECONDS + timeout)

    def read_pulse_form(self, timeout: float | None = None) -> PulseForm:
        """See PartectorDevice. Over BLE the readout takes about 50 s, default timeout 90 s."""
        timeout = timeout or BLE_READOUT_TIMEOUT_SECONDS
        return self._run(self._connection.read_pulse_form(timeout), timeout)

    def _run(self, coroutine: Coroutine[Any, Any, T], timeout: float) -> T:
        if threading.get_ident() == self._loop_thread:
            coroutine.close()
            raise RuntimeError("A BlePartector cannot be used from the BLE manager's own thread.")
        if self._loop.is_closed():
            coroutine.close()
            raise ConnectionError(f"SN{self.serial_number}: the BLE manager has stopped.")

        future = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
        try:
            return future.result(timeout + self.HANDOVER_TIMEOUT_SECONDS)
        except TimeoutError:
            future.cancel()
            raise

read_pulse_form(timeout=None)

See PartectorDevice. Over BLE the readout takes about 50 s, default timeout 90 s.

Source code in src/naneos/ble/partector/device.py
77
78
79
80
def read_pulse_form(self, timeout: float | None = None) -> PulseForm:
    """See PartectorDevice. Over BLE the readout takes about 50 s, default timeout 90 s."""
    timeout = timeout or BLE_READOUT_TIMEOUT_SECONDS
    return self._run(self._connection.read_pulse_form(timeout), timeout)

read_ui_curve(timeout=None)

See PartectorDevice. Over BLE the readout takes about 40 s, default timeout 90 s.

Source code in src/naneos/ble/partector/device.py
72
73
74
75
def read_ui_curve(self, timeout: float | None = None) -> UiCurve:
    """See PartectorDevice. Over BLE the readout takes about 40 s, default timeout 90 s."""
    timeout = timeout or BLE_READOUT_TIMEOUT_SECONDS
    return self._run(self._connection.read_ui_curve(timeout), UI_COMPUTE_SECONDS + timeout)

PartectorBleManager

Bases: 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.

Parameters:

Name Type Description Default
serial_numbers Iterable[int] | None

Only connect to these devices. None connects to every Partector in reach, first come first served.

None
max_links int

Upper bound of simultaneous links (including ones that are still retrying). BlueZ handles about seven reliably.

DEFAULT_MAX_LINKS
point_listener PointListener | None

Called with every data point as it arrives, in addition to get_data(). Runs on the BLE event loop: must be quick and must not block.

None
Source code in src/naneos/ble/partector/manager.py
 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
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
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.
        point_listener: Called with every data point as it arrives, in addition to
            get_data(). Runs on the BLE event loop: must be quick and must not block.
    """

    # 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

    # A link that is down makes its device advertise again, so a scanner that
    # hears no Partector at all for this long while a link waits to reconnect
    # has probably gone deaf, and is restarted. The device may also just be
    # switched off, so the interval doubles per fruitless restart up to the cap.
    SCANNER_SILENCE_SECONDS = 60.0
    SCANNER_RESTART_MAX_INTERVAL_SECONDS = 600.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,
        point_listener: PointListener | None = None,
    ) -> None:
        super().__init__(daemon=True)
        self._point_listener = point_listener
        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
        self._link_down_since: float | None = None
        self._scanner_restart_interval = self.SCANNER_SILENCE_SECONDS
        self._silent_scanner_restarts = 0

        # 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]] = {}
        self._points_lock = threading.Lock()  # get_data() swaps the buffer under the loop's feet

    # == 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. Only the swap
        # is locked, so the conversion never holds up the event loop.
        with self._points_lock:
            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_serial_numbers(self) -> list[int]:
        """Serial numbers of the devices with a live BLE link."""
        return [sn for sn, _ in self._live_links()]

    def get_devices(self) -> list[PartectorDevice]:
        """Handles to write to and query the devices with a live BLE link."""
        return [link.device for _, link in self._live_links() if link.device is not None]

    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()
                await self._revive_silent_scanner()

            except Exception as e:
                logger.exception(f"Error in manager loop: {e}")

        return False

    async def _revive_silent_scanner(self) -> None:
        """Restarts a scanner that hears nothing although a link waits to reconnect.

        Without advertisements no link can come back: the RSSI gate stays shut and
        BlueZ forgets the devices, so the occasional forced attempt fails with
        "device not found". is_discovering does not catch this, because a passive
        scan that stopped reporting looks exactly like one with nothing in reach.
        """
        now = time.monotonic()
        waiting = [serial for serial, link in self._links.items() if not link.is_connected]
        if self._scanner is None or not waiting:
            self._link_down_since = None
            self._reset_scanner_revival()
            return

        if self._link_down_since is None:
            self._link_down_since = now

        heard = self._scanner.seconds_since_advertisement
        if heard is not None and heard < self.SCANNER_SILENCE_SECONDS:
            self._reset_scanner_revival()  # the scanner hears Partectors, it is fine
            return

        # silent_for starts over with every restart, which paces the restarts.
        silent_for = self._scanner.silent_for
        if min(silent_for, now - self._link_down_since) < self._scanner_restart_interval:
            return

        # Every second fruitless restart tries the other scan mode.
        self._silent_scanner_restarts += 1
        switch_mode = self._silent_scanner_restarts % 2 == 0
        logger.warning(
            f"No Partector advertisement for {heard or silent_for:.0f}s while {waiting} wait to "
            f"reconnect. Restarting the scanner{' in the other scan mode' if switch_mode else ''}."
        )
        await self._scanner.restart(switch_mode=switch_mode)
        self._scanner_restart_interval = min(
            self._scanner_restart_interval * 2, self.SCANNER_RESTART_MAX_INTERVAL_SECONDS
        )

    def _reset_scanner_revival(self) -> None:
        self._scanner_restart_interval = self.SCANNER_SILENCE_SECONDS
        self._silent_scanner_restarts = 0

    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),
            point_listener=self._point_listener,
        )
        link = self._links.get(serial)
        if link is not None:
            link.connection = connection
            link.device = BlePartector(connection, self._loop)

        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
                link.device = 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."""
        with self._points_lock:
            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 < MIN_RSSI_CONNECT_DBM:
                logger.info(
                    f"Ignoring serial={serial} ({device.address}): RSSI {rssi} dBm is below "
                    f"{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

        self._buffer_points(batch_data)

get_connected_serial_numbers()

Serial numbers of the devices with a live BLE link.

Source code in src/naneos/ble/partector/manager.py
134
135
136
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()]

get_data()

Returns the collected data as DataFrames and clears the buffer.

Source code in src/naneos/ble/partector/manager.py
111
112
113
114
115
116
117
118
119
120
121
122
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. Only the swap
    # is locked, so the conversion never holds up the event loop.
    with self._points_lock:
        points, self._points = self._points, {}

    return {
        serial: df
        for serial, serial_points in points.items()
        if not (df := to_pandas_df(serial_points)).empty
    }

get_devices()

Handles to write to and query the devices with a live BLE link.

Source code in src/naneos/ble/partector/manager.py
138
139
140
def get_devices(self) -> list[PartectorDevice]:
    """Handles to write to and query the devices with a live BLE link."""
    return [link.device for _, link in self._live_links() if link.device is not None]