nucleuskit-toolkit 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. nucleuskit_toolkit/__init__.py +3 -0
  2. nucleuskit_toolkit/__main__.py +60 -0
  3. nucleuskit_toolkit/app.py +34 -0
  4. nucleuskit_toolkit/camera/__init__.py +9 -0
  5. nucleuskit_toolkit/camera/processing.py +374 -0
  6. nucleuskit_toolkit/config.py +86 -0
  7. nucleuskit_toolkit/hermes/__init__.py +0 -0
  8. nucleuskit_toolkit/hermes/constants.py +77 -0
  9. nucleuskit_toolkit/hermes/realtime/__init__.py +8 -0
  10. nucleuskit_toolkit/hermes/realtime/proxy.py +652 -0
  11. nucleuskit_toolkit/logging_utils.py +68 -0
  12. nucleuskit_toolkit/mqtt/__init__.py +5 -0
  13. nucleuskit_toolkit/mqtt/mqtt_controller.py +256 -0
  14. nucleuskit_toolkit/pipeline.py +194 -0
  15. nucleuskit_toolkit/position/__init__.py +5 -0
  16. nucleuskit_toolkit/position/gps_processor.py +91 -0
  17. nucleuskit_toolkit/session_job.py +28 -0
  18. nucleuskit_toolkit/shimmer/__init__.py +5 -0
  19. nucleuskit_toolkit/shimmer/constants.py +32 -0
  20. nucleuskit_toolkit/shimmer/realtime/__init__.py +5 -0
  21. nucleuskit_toolkit/shimmer/realtime/proxy.py +279 -0
  22. nucleuskit_toolkit/tools/__init__.py +1 -0
  23. nucleuskit_toolkit/tools/eeg_regression.py +91 -0
  24. nucleuskit_toolkit/tools/recording_merger.py +437 -0
  25. nucleuskit_toolkit/ui/__init__.py +1 -0
  26. nucleuskit_toolkit/ui/icons.py +165 -0
  27. nucleuskit_toolkit/ui/main_window.py +465 -0
  28. nucleuskit_toolkit/ui/offline_job.py +144 -0
  29. nucleuskit_toolkit/ui/pages/__init__.py +1 -0
  30. nucleuskit_toolkit/ui/pages/about_page.py +88 -0
  31. nucleuskit_toolkit/ui/pages/channel_fixer_page.py +205 -0
  32. nucleuskit_toolkit/ui/pages/channel_gain_page.py +362 -0
  33. nucleuskit_toolkit/ui/pages/eeg_regression_page.py +385 -0
  34. nucleuskit_toolkit/ui/pages/mqtt_controller_page.py +394 -0
  35. nucleuskit_toolkit/ui/pages/offline_page.py +754 -0
  36. nucleuskit_toolkit/ui/pages/playback_page.py +1524 -0
  37. nucleuskit_toolkit/ui/pages/ppg_fixer_page.py +199 -0
  38. nucleuskit_toolkit/ui/pages/realtime_viewer_page.py +954 -0
  39. nucleuskit_toolkit/ui/pages/recording_merge_page.py +395 -0
  40. nucleuskit_toolkit/ui/pages/revert_original_page.py +128 -0
  41. nucleuskit_toolkit/ui/pages/settings_page.py +69 -0
  42. nucleuskit_toolkit/ui/pages/tools_menu_page.py +42 -0
  43. nucleuskit_toolkit/ui/plot_style.py +155 -0
  44. nucleuskit_toolkit/ui/realtime_eeg_plot.py +244 -0
  45. nucleuskit_toolkit/ui/realtime_hermes_motion_plot.py +162 -0
  46. nucleuskit_toolkit/ui/realtime_nlms_config_dialog.py +101 -0
  47. nucleuskit_toolkit/ui/realtime_shimmer_plot.py +135 -0
  48. nucleuskit_toolkit/ui/resources/__init__.py +1 -0
  49. nucleuskit_toolkit/ui/resources/branding/__init__.py +1 -0
  50. nucleuskit_toolkit/ui/resources/branding/logo.png +0 -0
  51. nucleuskit_toolkit/ui/session_inspect.py +226 -0
  52. nucleuskit_toolkit/ui/theme.py +330 -0
  53. nucleuskit_toolkit/ui/widgets.py +190 -0
  54. nucleuskit_toolkit-2.2.0.dist-info/METADATA +351 -0
  55. nucleuskit_toolkit-2.2.0.dist-info/RECORD +59 -0
  56. nucleuskit_toolkit-2.2.0.dist-info/WHEEL +5 -0
  57. nucleuskit_toolkit-2.2.0.dist-info/entry_points.txt +2 -0
  58. nucleuskit_toolkit-2.2.0.dist-info/licenses/LICENSE +675 -0
  59. nucleuskit_toolkit-2.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,652 @@
1
+ """
2
+ Hermes V1 BLE client: EEG + motion notifications.
3
+
4
+ Runs asyncio on a dedicated thread; decodes EEG on worker threads via queues.
5
+
6
+ Stability fixes applied in 2026-10-NewEmotionsModel:
7
+ - Bug 1: asyncio.Event objects are created inside _run_async_main so they are
8
+ bound to the correct event loop (not the main-thread loop which may not exist
9
+ on Windows). trigger_shutdown uses loop.call_soon_threadsafe to cross the
10
+ thread boundary safely.
11
+ - Bug 2: The EEG notification callback now dispatches via
12
+ loop.call_soon_threadsafe instead of asyncio.create_task, which is not
13
+ thread-safe when called from a non-loop OS thread (common on Windows BLE).
14
+ - Bug 3: BleakClient receives a disconnected_callback so unexpected drops are
15
+ detected and recorded immediately.
16
+ - Bug 4: main_task contains an automatic reconnection loop with exponential
17
+ back-off (1 s → 2 s → … capped at 30 s, up to MAX_RECONNECTS attempts).
18
+
19
+ Stream fixes applied in 2026-10-fix-eeg-stream:
20
+ - The EEG worker logs an error and keeps going: one failing packet or callback
21
+ (for example a recording file closed while a packet is written) no longer
22
+ stops the EEG stream for the rest of the connection.
23
+ - Packet counters are validated (7-bit, 0-127): a counter out of range or
24
+ repeated is ignored, and a jump of more than half the counter range
25
+ resynchronises instead of inventing up to 127 missing packets. An invalid
26
+ counter used to loop forever on the event-loop thread, freezing the
27
+ connection.
28
+ - All packet bookkeeping is reset on (re)connection.
29
+ - BLE messages are also written to a rotating log file (ble_log_path()), so
30
+ a drop leaves a trace when the app runs without a console.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import collections
37
+ import datetime
38
+ import logging
39
+ import os
40
+ import queue
41
+ import struct
42
+ import sys
43
+ import threading
44
+ from logging.handlers import RotatingFileHandler
45
+ from time import sleep, time
46
+ from typing import Callable
47
+
48
+ from bleak import BleakClient
49
+
50
+ ACC_SENS = 0.061 / 1000 # 0.061 mg/LSB → g
51
+ GYRO_SENS = 8.75 / 1000 # 8.75 mdps/LSB → dps
52
+ MAG_SENS = 0.14 / 1000 # 0.14 mgauss/LSB → gauss
53
+
54
+ EEG_DATA_UUID = "9fa480e1-4967-11e5-a151-0002a5d5c51b"
55
+ EEG_CONFIG_UUID = "9fa480e2-4967-11e5-a151-0002a5d5c51b"
56
+ EVENT_UUID = "9fa48301-4967-11e5-a151-0002a5d5c51b"
57
+ MOTION_UUID = "9fa48201-4967-11e5-a151-0002a5d5c51b"
58
+
59
+ HERMES_NAME_SUBSTRING = "Hermes V1"
60
+
61
+ EegCallback = Callable[[list], None] | None
62
+ MotionCallback = Callable[[tuple], None] | None
63
+ StatusCallback = Callable[[str], None] | None
64
+
65
+ BLE_LOG_NAME = "hermes_ble.log"
66
+ _BLE_LOG_MAX_BYTES = 2_000_000
67
+ _BLE_LOG_BACKUPS = 3
68
+
69
+ _log = logging.getLogger("nucleuskit_toolkit.ble")
70
+ _log.propagate = False # printed to the console by _report; the file handler only writes the file
71
+ _log_lock = threading.Lock()
72
+
73
+
74
+ def ble_log_path() -> str:
75
+ """Rotating BLE log: ``%LOCALAPPDATA%/REAK/NucleusKitToolkit/logs`` on Windows, the user log folder elsewhere."""
76
+ if sys.platform == "win32":
77
+ base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
78
+ elif sys.platform == "darwin":
79
+ base = os.path.expanduser("~/Library/Logs")
80
+ else:
81
+ base = os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state")
82
+ return os.path.join(base, "REAK", "NucleusKitToolkit", "logs", BLE_LOG_NAME)
83
+
84
+
85
+ def _ensure_file_log() -> None:
86
+ with _log_lock:
87
+ if any(isinstance(h, RotatingFileHandler) for h in _log.handlers):
88
+ return
89
+ try:
90
+ path = ble_log_path()
91
+ os.makedirs(os.path.dirname(path), exist_ok=True)
92
+ handler = RotatingFileHandler(
93
+ path, maxBytes=_BLE_LOG_MAX_BYTES, backupCount=_BLE_LOG_BACKUPS, encoding="utf-8"
94
+ )
95
+ except OSError:
96
+ return # logging must never break streaming
97
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s [%(threadName)s] %(message)s"))
98
+ _log.addHandler(handler)
99
+ _log.setLevel(logging.INFO)
100
+
101
+
102
+ def _report(message: str, level: int = logging.INFO, exc_info: bool = False) -> None:
103
+ """Print a BLE message to the console (and the GUI log) and write it to the BLE log file."""
104
+ print(message)
105
+ _ensure_file_log()
106
+ _log.log(level, message, exc_info=exc_info)
107
+
108
+
109
+ class EegPacketAssembler:
110
+ """Orders EEG notifications by their 7-bit packet counter and fills the gaps.
111
+
112
+ Each notification starts with a counter (0-127, wrapping). A missing packet is
113
+ emitted as ``(False, None, counter)`` once ``MAX_WAIT`` newer packets arrived,
114
+ so the decoder can insert NaN samples; received packets are emitted as
115
+ ``(True, payload, counter)``, in order.
116
+ """
117
+
118
+ MODULO = 128
119
+ MAX_WAIT = 10
120
+ MAX_GAP = MODULO // 2 # a larger jump means the counter cannot be trusted: resynchronise
121
+
122
+ def __init__(self, emit: Callable[[tuple], None]) -> None:
123
+ self._emit = emit
124
+ self.reset()
125
+
126
+ def reset(self) -> None:
127
+ self.last_packet: int | None = None
128
+ self.packets: collections.deque[int] = collections.deque()
129
+ self.packet_received: collections.deque[bool] = collections.deque()
130
+ self.samples_per_packets: collections.deque[bytes | None] = collections.deque()
131
+ self.ignored = 0
132
+ self.dropped = 0
133
+ self.resyncs = 0
134
+
135
+ @classmethod
136
+ def missing_between(cls, last_packet: int | None, current_packet: int) -> list[int]:
137
+ """Counters strictly between ``last_packet`` and ``current_packet`` (wrapping at 128)."""
138
+ if last_packet is None:
139
+ return []
140
+ gap = (current_packet - last_packet) % cls.MODULO
141
+ return [(last_packet + k) % cls.MODULO for k in range(1, gap)]
142
+
143
+ def push(self, data: bytes | bytearray) -> bool:
144
+ """Add one notification; returns False when it is ignored."""
145
+ if len(data) < 1:
146
+ self.ignored += 1
147
+ _report("[HermesBleProxy] Ignoring an empty EEG notification.", logging.WARNING)
148
+ return False
149
+ current_packet = data[0]
150
+ payload = bytes(data[1:])
151
+ if not 0 <= current_packet < self.MODULO:
152
+ self.ignored += 1
153
+ _report(f"[HermesBleProxy] Ignoring EEG packet with invalid counter {current_packet}.", logging.WARNING)
154
+ return False
155
+ if current_packet == self.last_packet:
156
+ self.ignored += 1
157
+ _report(f"[HermesBleProxy] Ignoring repeated EEG packet {current_packet}.", logging.WARNING)
158
+ return False
159
+
160
+ missing = self.missing_between(self.last_packet, current_packet)
161
+ if len(missing) >= self.MAX_GAP:
162
+ self.resyncs += 1
163
+ _report(
164
+ f"[HermesBleProxy] EEG packet counter jumped from {self.last_packet} to {current_packet}: "
165
+ "resynchronising.",
166
+ logging.WARNING,
167
+ )
168
+ self._flush_all()
169
+ missing = []
170
+ elif missing:
171
+ self.dropped += len(missing)
172
+ span = str(missing[0]) if len(missing) == 1 else f"{missing[0]}-{missing[-1]}"
173
+ _report(f"[HermesBleProxy] Dropped {len(missing)} EEG packet(s): {span}.", logging.WARNING)
174
+
175
+ for packet in missing:
176
+ self.packets.append(packet)
177
+ self.packet_received.append(False)
178
+ self.samples_per_packets.append(None)
179
+ self.packets.append(current_packet)
180
+ self.packet_received.append(True)
181
+ self.samples_per_packets.append(payload)
182
+ self.last_packet = current_packet
183
+ self._transfer()
184
+ return True
185
+
186
+ def _transfer(self) -> None:
187
+ while self.packet_received:
188
+ delay = (self.last_packet - self.packets[0]) % self.MODULO
189
+ if not self.packet_received[0] and delay < self.MAX_WAIT:
190
+ break
191
+ self._emit_head()
192
+
193
+ def _flush_all(self) -> None:
194
+ while self.packet_received:
195
+ self._emit_head()
196
+
197
+ def _emit_head(self) -> None:
198
+ item = (self.packet_received.popleft(), self.samples_per_packets.popleft(), self.packets.popleft())
199
+ self._emit(item)
200
+
201
+
202
+ class HermesBleProxy:
203
+ MAX_RECONNECTS = 10
204
+ _RECONNECT_BASE_DELAY = 1.0 # seconds
205
+ _RECONNECT_MAX_DELAY = 30.0 # seconds
206
+
207
+ def __init__(
208
+ self,
209
+ mac_address: str,
210
+ eeg_callback: EegCallback = None,
211
+ motion_callback: MotionCallback = None,
212
+ status_callback: StatusCallback = None,
213
+ ) -> None:
214
+ self.is_connected = False
215
+ self.client: BleakClient | None = None
216
+ self.mac_address = mac_address
217
+
218
+ # Bounded queues — drop oldest item rather than growing without bound.
219
+ self.eeg_queue: queue.Queue = queue.Queue(maxsize=256)
220
+ self.motion_queue: queue.Queue = queue.Queue(maxsize=128)
221
+ self._assembler = EegPacketAssembler(self._enqueue_eeg)
222
+
223
+ # Bug 1 fix: asyncio.Event MUST be created inside the worker event loop.
224
+ # Initialise to None here; _run_async_main populates them before any
225
+ # coroutine runs.
226
+ self.shutdown_event: asyncio.Event | None = None
227
+ self._disconnected_event: asyncio.Event | None = None
228
+ self.loop: asyncio.AbstractEventLoop | None = None
229
+
230
+ self._connection_error: str | None = None
231
+ self._status_callback = status_callback
232
+ _report(f"[HermesBleProxy] Connecting to {mac_address} (BLE log: {ble_log_path()})")
233
+
234
+ self._async_thread = threading.Thread(target=self._run_async_main, daemon=True, name="HermesBle")
235
+
236
+ self.eeg_worker = threading.Thread(
237
+ target=HermesBleProxy.worker_process,
238
+ args=(self.eeg_queue, eeg_callback),
239
+ daemon=True,
240
+ name="HermesEeg",
241
+ )
242
+ self.eeg_worker.start()
243
+
244
+ self.motion_worker = threading.Thread(
245
+ target=HermesBleProxy.motion_process,
246
+ args=(self.motion_queue, motion_callback),
247
+ daemon=True,
248
+ name="HermesMotion",
249
+ )
250
+ self.motion_worker.start()
251
+
252
+ self._async_thread.start()
253
+
254
+ # ------------------------------------------------------------------ #
255
+ # Public API #
256
+ # ------------------------------------------------------------------ #
257
+
258
+ def wait_until_connected(self, timeout: float = 90.0) -> None:
259
+ """Block until connected, or raise on failure/timeout."""
260
+ deadline = time() + timeout
261
+ while time() < deadline:
262
+ if self.is_connected:
263
+ return
264
+ if self._connection_error is not None:
265
+ raise RuntimeError(self._connection_error)
266
+ if not self._async_thread.is_alive():
267
+ err = self._connection_error or "BLE thread ended before connecting."
268
+ raise RuntimeError(err)
269
+ sleep(0.05)
270
+ raise TimeoutError("Connection timed out.")
271
+
272
+ def disconnect(self) -> None:
273
+ try:
274
+ self.trigger_shutdown()
275
+ self._async_thread.join(timeout=30.0)
276
+ finally:
277
+ self.eeg_queue.put(None)
278
+ self.motion_queue.put(None)
279
+ self.eeg_worker.join(timeout=15.0)
280
+ self.motion_worker.join(timeout=15.0)
281
+
282
+ def trigger_shutdown(self) -> None:
283
+ """Signal the async thread to shut down (thread-safe, Bug 1 fix)."""
284
+ if self.loop is not None and self.shutdown_event is not None:
285
+ self.loop.call_soon_threadsafe(self.shutdown_event.set)
286
+
287
+ # ------------------------------------------------------------------ #
288
+ # Async thread bootstrap #
289
+ # ------------------------------------------------------------------ #
290
+
291
+ def _run_async_main(self) -> None:
292
+ # Windows: Proactor loop in a non-main thread breaks many asyncio BLE
293
+ # stacks; use SelectorEventLoop.
294
+ if sys.platform == "win32":
295
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
296
+ self.loop = asyncio.new_event_loop()
297
+ asyncio.set_event_loop(self.loop)
298
+
299
+ # Bug 1 fix: create asyncio.Events inside the running loop.
300
+ self.shutdown_event = asyncio.Event()
301
+ self._disconnected_event = asyncio.Event()
302
+
303
+ try:
304
+ self.loop.run_until_complete(self.main_task(self.mac_address))
305
+ except Exception as e:
306
+ self._connection_error = self._connection_error or str(e)
307
+ _report(f"[HermesBleProxy] BLE thread stopped: {e}", logging.ERROR, exc_info=True)
308
+
309
+ # ------------------------------------------------------------------ #
310
+ # BLE callbacks #
311
+ # ------------------------------------------------------------------ #
312
+
313
+ def _on_ble_disconnected(self, client: BleakClient) -> None:
314
+ """Bug 3 fix: called by bleak when the connection drops unexpectedly.
315
+
316
+ bleak schedules this callback inside the event loop, so setting an
317
+ asyncio.Event directly is safe here.
318
+ """
319
+ self.is_connected = False
320
+ a = self._assembler
321
+ _report(
322
+ "[HermesBleProxy] Connection dropped by device/OS "
323
+ f"(EEG packets dropped: {a.dropped}, ignored: {a.ignored}, resyncs: {a.resyncs}).",
324
+ logging.WARNING,
325
+ )
326
+ if self._disconnected_event is not None:
327
+ self._disconnected_event.set()
328
+
329
+ def _set_status(self, msg: str) -> None:
330
+ """Forward a status string to the UI callback (thread-safe)."""
331
+ if self._status_callback is not None:
332
+ try:
333
+ self._status_callback(msg)
334
+ except Exception:
335
+ pass
336
+
337
+ def _enqueue_eeg(self, item: tuple) -> None:
338
+ try:
339
+ self.eeg_queue.put_nowait(item)
340
+ except queue.Full:
341
+ try:
342
+ self.eeg_queue.get_nowait()
343
+ except queue.Empty:
344
+ pass
345
+ self.eeg_queue.put_nowait(item)
346
+
347
+ def _handle_eeg_data(self, _sender: object, data: bytearray) -> None:
348
+ """Bug 2 fix: synchronous EEG notification handler.
349
+
350
+ Called on the event-loop thread via loop.call_soon_threadsafe (see
351
+ _subscribe_characteristics), so asyncio.create_task is not needed and
352
+ no cross-thread hazard exists.
353
+ """
354
+ try:
355
+ self._assembler.push(data)
356
+ except Exception as e:
357
+ _report(f"[HermesBleProxy] _handle_eeg_data: {e}", logging.ERROR, exc_info=True)
358
+
359
+ async def motion_handler(self, _sender: object, data: bytearray) -> None:
360
+ try:
361
+ now = datetime.datetime.now()
362
+ ax_raw, ay_raw, az_raw, gx_raw, gy_raw, gz_raw, cx_raw, cy_raw, cz_raw = (
363
+ struct.unpack_from("<hhhhhhhhh", data)
364
+ )
365
+ timestamp_epoch = now.timestamp()
366
+ item = (
367
+ timestamp_epoch,
368
+ ax_raw, ay_raw, az_raw,
369
+ gx_raw, gy_raw, gz_raw,
370
+ cx_raw, cy_raw, cz_raw,
371
+ )
372
+ try:
373
+ self.motion_queue.put_nowait(item)
374
+ except queue.Full:
375
+ try:
376
+ self.motion_queue.get_nowait()
377
+ except queue.Empty:
378
+ pass
379
+ self.motion_queue.put_nowait(item)
380
+ except Exception as e:
381
+ _report(f"[HermesBleProxy] motion_handler: {e}", logging.ERROR)
382
+
383
+ async def config_handler(self, _sender: object, data: bytearray) -> None:
384
+ try:
385
+ _report(f"[HermesBleProxy] EEG config: {bytes(data).hex()}")
386
+ except Exception as e:
387
+ _report(f"[HermesBleProxy] config_handler: {e}", logging.ERROR)
388
+
389
+ async def notification_handler(self, sender: object, data: bytearray) -> None:
390
+ _report(f"Notification from {sender}: {data}")
391
+
392
+ # ------------------------------------------------------------------ #
393
+ # GATT subscribe / unsubscribe helpers #
394
+ # ------------------------------------------------------------------ #
395
+
396
+ async def _subscribe_characteristics(self) -> None:
397
+ """Subscribe to all four GATT characteristics."""
398
+ assert self.loop is not None
399
+
400
+ await self.client.start_notify(EVENT_UUID, self.notification_handler)
401
+ _report("[HermesBleProxy] Subscribed to button notifications.")
402
+
403
+ await self.client.start_notify(MOTION_UUID, self.motion_handler)
404
+ _report("[HermesBleProxy] Subscribed to motion notifications.")
405
+
406
+ await self.client.start_notify(EEG_CONFIG_UUID, self.config_handler)
407
+ _report("[HermesBleProxy] Subscribed to EEG config notifications.")
408
+
409
+ # Bug 2 fix: deliver the notification to _handle_eeg_data on the
410
+ # event-loop thread via call_soon_threadsafe, avoiding the thread-unsafe
411
+ # asyncio.create_task call that was here before.
412
+ loop = self.loop
413
+ await self.client.start_notify(
414
+ EEG_DATA_UUID,
415
+ lambda sender, data: loop.call_soon_threadsafe(
416
+ self._handle_eeg_data, sender, data
417
+ ),
418
+ )
419
+ _report("[HermesBleProxy] Subscribed to EEG data notifications.")
420
+
421
+ async def _unsubscribe_characteristics(self) -> None:
422
+ """Unsubscribe from all characteristics (best-effort; ignores errors)."""
423
+ for uuid in (EEG_DATA_UUID, EVENT_UUID, MOTION_UUID, EEG_CONFIG_UUID):
424
+ try:
425
+ await self.client.stop_notify(uuid)
426
+ except Exception:
427
+ pass
428
+
429
+ async def _stream_once(self) -> None:
430
+ """Subscribe, then wait until shutdown is requested or connection drops."""
431
+ self._disconnected_event.clear()
432
+ await self._subscribe_characteristics()
433
+
434
+ shutdown_fut = asyncio.ensure_future(self.shutdown_event.wait())
435
+ disconnect_fut = asyncio.ensure_future(self._disconnected_event.wait())
436
+
437
+ try:
438
+ _done, pending = await asyncio.wait(
439
+ [shutdown_fut, disconnect_fut],
440
+ return_when=asyncio.FIRST_COMPLETED,
441
+ )
442
+ for task in pending:
443
+ task.cancel()
444
+ try:
445
+ await task
446
+ except asyncio.CancelledError:
447
+ pass
448
+ finally:
449
+ # Unsubscribe best-effort (client may already be disconnected).
450
+ if self.client is not None and self.client.is_connected:
451
+ await self._unsubscribe_characteristics()
452
+
453
+ # ------------------------------------------------------------------ #
454
+ # Main async task #
455
+ # ------------------------------------------------------------------ #
456
+
457
+ async def main_task(
458
+ self, device_address: str, device_name: str = HERMES_NAME_SUBSTRING
459
+ ) -> None:
460
+ """Connect, stream data, and auto-reconnect on unexpected drops (Bug 4 fix)."""
461
+
462
+ # ── Initial connection (fails fast; no retry on first attempt) ──────
463
+ self._assembler.reset()
464
+ self.client = BleakClient(
465
+ device_address,
466
+ disconnected_callback=self._on_ble_disconnected, # Bug 3 fix
467
+ )
468
+ try:
469
+ await self.client.connect()
470
+ except Exception as e:
471
+ self._connection_error = str(e)
472
+ self.is_connected = False
473
+ _report(f"[HermesBleProxy] Initial connection failed: {e}", logging.ERROR)
474
+ return
475
+
476
+ _report(f"[HermesBleProxy] Connected to {device_name} [{device_address}]")
477
+ self._connection_error = None
478
+ self.is_connected = True
479
+ session_start = time()
480
+
481
+ # ── Stream + auto-reconnect loop (Bug 4 fix) ────────────────────────
482
+ reconnect_num = 0
483
+
484
+ while True:
485
+ # Stream until shutdown or unexpected disconnect.
486
+ if self.is_connected:
487
+ try:
488
+ await self._stream_once()
489
+ except Exception as e:
490
+ _report(f"[HermesBleProxy] Streaming error: {e}", logging.ERROR, exc_info=True)
491
+ finally:
492
+ self.is_connected = False
493
+
494
+ # Shutdown takes priority over reconnect.
495
+ if self.shutdown_event.is_set():
496
+ break
497
+
498
+ # ── Unexpected disconnect: plan a reconnect attempt ─────────────
499
+ reconnect_num += 1
500
+ if reconnect_num > self.MAX_RECONNECTS:
501
+ _report(
502
+ f"[HermesBleProxy] Max reconnects ({self.MAX_RECONNECTS}) reached. Stopping.",
503
+ logging.ERROR,
504
+ )
505
+ self._set_status(
506
+ "Could not reconnect. Please press Disconnect and try again."
507
+ )
508
+ break
509
+
510
+ delay = min(
511
+ self._RECONNECT_BASE_DELAY * (2 ** (reconnect_num - 1)),
512
+ self._RECONNECT_MAX_DELAY,
513
+ )
514
+ status_msg = (
515
+ f"Connection lost. Reconnecting in {delay:.0f} s… "
516
+ f"(attempt {reconnect_num}/{self.MAX_RECONNECTS})"
517
+ )
518
+ _report(f"[HermesBleProxy] {status_msg}", logging.WARNING)
519
+ self._set_status(status_msg)
520
+
521
+ # Sleep for the back-off period, but wake immediately on shutdown.
522
+ try:
523
+ await asyncio.wait_for(self.shutdown_event.wait(), timeout=delay)
524
+ break # Shutdown requested during sleep.
525
+ except asyncio.TimeoutError:
526
+ pass # Normal: delay elapsed.
527
+
528
+ if self.shutdown_event.is_set():
529
+ break
530
+
531
+ # ── Reconnect attempt ───────────────────────────────────────────
532
+ # Packets in flight before the drop cannot be ordered against the new stream: emit them, then start over.
533
+ self._assembler._flush_all()
534
+ self._assembler.reset()
535
+ self.client = BleakClient(
536
+ device_address,
537
+ disconnected_callback=self._on_ble_disconnected,
538
+ )
539
+ try:
540
+ await self.client.connect()
541
+ self.is_connected = True
542
+ self._connection_error = None
543
+ reconnected_msg = f"Reconnected to {device_name}. Streaming resumed."
544
+ _report(f"[HermesBleProxy] {reconnected_msg}")
545
+ self._set_status(reconnected_msg)
546
+ except Exception as e:
547
+ _report(f"[HermesBleProxy] Reconnect attempt {reconnect_num} failed: {e}", logging.WARNING)
548
+ self.is_connected = False
549
+ # Loop continues: will sleep-then-retry with incremented counter.
550
+
551
+ # ── Cleanup ──────────────────────────────────────────────────────────
552
+ elapsed = time() - session_start
553
+ _report(
554
+ f"[HermesBleProxy] Session ended after {elapsed:.1f} s. "
555
+ f"Reconnections: {reconnect_num}."
556
+ )
557
+ if self.client is not None and self.client.is_connected:
558
+ try:
559
+ await self.client.disconnect()
560
+ _report("[HermesBleProxy] Disconnected cleanly.")
561
+ except Exception:
562
+ pass
563
+ self.is_connected = False
564
+
565
+ # ------------------------------------------------------------------ #
566
+ # Worker-thread processors #
567
+ # ------------------------------------------------------------------ #
568
+
569
+ @staticmethod
570
+ def decode_eeg_payload(data: bytes, packet_number: int) -> list[list[float]]:
571
+ """Samples of one packet: 24 bytes per sample, 8 channels of 3-byte big-endian signed values."""
572
+ samples: list[list[float]] = []
573
+ n_complete = len(data) // 24
574
+ if len(data) % 24 != 0:
575
+ _report(
576
+ f"Warning: packet {packet_number} has {len(data)} bytes "
577
+ f"(not a multiple of 24). Dropping {len(data) % 24} trailing byte(s).",
578
+ logging.WARNING,
579
+ )
580
+ for i in range(0, n_complete * 24, 24):
581
+ sample = []
582
+ for j in range(0, 24, 3):
583
+ channel_data = data[i + j : i + j + 3]
584
+ sample.append(int.from_bytes(channel_data, byteorder="big", signed=True))
585
+ samples.append(HermesBleProxy.convert_ads1299_to_microvolts(sample))
586
+ return samples
587
+
588
+ @staticmethod
589
+ def worker_process(eeg_queue: queue.Queue, callback: EegCallback) -> None:
590
+ errors = 0
591
+ while True:
592
+ task = eeg_queue.get()
593
+ if task is None:
594
+ break
595
+ try:
596
+ packet_received, data, packet_number = task
597
+
598
+ if packet_received and data is not None:
599
+ samples = HermesBleProxy.decode_eeg_payload(data, packet_number)
600
+ if not samples:
601
+ _report(f"Warning: packet {packet_number} has no complete samples, skipping.", logging.WARNING)
602
+ continue
603
+ else:
604
+ samples = [[float("nan")] * 8 for _ in range(10)]
605
+
606
+ if callback is not None:
607
+ callback(samples)
608
+
609
+ except Exception as e:
610
+ # Keep the stream alive: one bad packet or failing callback must not stop the EEG for good.
611
+ errors += 1
612
+ if errors <= 20 or errors % 100 == 0:
613
+ _report(f"Error in EEG worker (#{errors}): {e}", logging.ERROR, exc_info=errors <= 3)
614
+
615
+ @staticmethod
616
+ def motion_process(motion_queue: queue.Queue, callback: MotionCallback) -> None:
617
+ while True:
618
+ sample = motion_queue.get()
619
+ if sample is None:
620
+ break
621
+ try:
622
+ (
623
+ timestamp,
624
+ ax_raw, ay_raw, az_raw,
625
+ gx_raw, gy_raw, gz_raw,
626
+ cx_raw, cy_raw, cz_raw,
627
+ ) = sample
628
+
629
+ ax_raw *= ACC_SENS
630
+ ay_raw *= ACC_SENS
631
+ az_raw *= ACC_SENS
632
+
633
+ gx_raw *= GYRO_SENS
634
+ gy_raw *= GYRO_SENS
635
+ gz_raw *= GYRO_SENS
636
+
637
+ cx_raw *= MAG_SENS
638
+ cy_raw *= MAG_SENS
639
+ cz_raw *= MAG_SENS
640
+
641
+ out = (ax_raw, ay_raw, az_raw, gx_raw, gy_raw, gz_raw, cx_raw, cy_raw, cz_raw)
642
+ if callback is not None:
643
+ callback(out)
644
+ except Exception as e:
645
+ _report(f"[HermesBleProxy] motion_process: {e}", logging.ERROR)
646
+
647
+ @staticmethod
648
+ def convert_ads1299_to_microvolts(
649
+ raw_values: list[int], gain: int = 12, vref: float = 4.5
650
+ ) -> list[float]:
651
+ lsb_uV = (2 * vref * 1e6) / (gain * (2**24))
652
+ return [val * lsb_uV for val in raw_values]