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.
- nucleuskit_toolkit/__init__.py +3 -0
- nucleuskit_toolkit/__main__.py +60 -0
- nucleuskit_toolkit/app.py +34 -0
- nucleuskit_toolkit/camera/__init__.py +9 -0
- nucleuskit_toolkit/camera/processing.py +374 -0
- nucleuskit_toolkit/config.py +86 -0
- nucleuskit_toolkit/hermes/__init__.py +0 -0
- nucleuskit_toolkit/hermes/constants.py +77 -0
- nucleuskit_toolkit/hermes/realtime/__init__.py +8 -0
- nucleuskit_toolkit/hermes/realtime/proxy.py +652 -0
- nucleuskit_toolkit/logging_utils.py +68 -0
- nucleuskit_toolkit/mqtt/__init__.py +5 -0
- nucleuskit_toolkit/mqtt/mqtt_controller.py +256 -0
- nucleuskit_toolkit/pipeline.py +194 -0
- nucleuskit_toolkit/position/__init__.py +5 -0
- nucleuskit_toolkit/position/gps_processor.py +91 -0
- nucleuskit_toolkit/session_job.py +28 -0
- nucleuskit_toolkit/shimmer/__init__.py +5 -0
- nucleuskit_toolkit/shimmer/constants.py +32 -0
- nucleuskit_toolkit/shimmer/realtime/__init__.py +5 -0
- nucleuskit_toolkit/shimmer/realtime/proxy.py +279 -0
- nucleuskit_toolkit/tools/__init__.py +1 -0
- nucleuskit_toolkit/tools/eeg_regression.py +91 -0
- nucleuskit_toolkit/tools/recording_merger.py +437 -0
- nucleuskit_toolkit/ui/__init__.py +1 -0
- nucleuskit_toolkit/ui/icons.py +165 -0
- nucleuskit_toolkit/ui/main_window.py +465 -0
- nucleuskit_toolkit/ui/offline_job.py +144 -0
- nucleuskit_toolkit/ui/pages/__init__.py +1 -0
- nucleuskit_toolkit/ui/pages/about_page.py +88 -0
- nucleuskit_toolkit/ui/pages/channel_fixer_page.py +205 -0
- nucleuskit_toolkit/ui/pages/channel_gain_page.py +362 -0
- nucleuskit_toolkit/ui/pages/eeg_regression_page.py +385 -0
- nucleuskit_toolkit/ui/pages/mqtt_controller_page.py +394 -0
- nucleuskit_toolkit/ui/pages/offline_page.py +754 -0
- nucleuskit_toolkit/ui/pages/playback_page.py +1524 -0
- nucleuskit_toolkit/ui/pages/ppg_fixer_page.py +199 -0
- nucleuskit_toolkit/ui/pages/realtime_viewer_page.py +954 -0
- nucleuskit_toolkit/ui/pages/recording_merge_page.py +395 -0
- nucleuskit_toolkit/ui/pages/revert_original_page.py +128 -0
- nucleuskit_toolkit/ui/pages/settings_page.py +69 -0
- nucleuskit_toolkit/ui/pages/tools_menu_page.py +42 -0
- nucleuskit_toolkit/ui/plot_style.py +155 -0
- nucleuskit_toolkit/ui/realtime_eeg_plot.py +244 -0
- nucleuskit_toolkit/ui/realtime_hermes_motion_plot.py +162 -0
- nucleuskit_toolkit/ui/realtime_nlms_config_dialog.py +101 -0
- nucleuskit_toolkit/ui/realtime_shimmer_plot.py +135 -0
- nucleuskit_toolkit/ui/resources/__init__.py +1 -0
- nucleuskit_toolkit/ui/resources/branding/__init__.py +1 -0
- nucleuskit_toolkit/ui/resources/branding/logo.png +0 -0
- nucleuskit_toolkit/ui/session_inspect.py +226 -0
- nucleuskit_toolkit/ui/theme.py +330 -0
- nucleuskit_toolkit/ui/widgets.py +190 -0
- nucleuskit_toolkit-2.2.0.dist-info/METADATA +351 -0
- nucleuskit_toolkit-2.2.0.dist-info/RECORD +59 -0
- nucleuskit_toolkit-2.2.0.dist-info/WHEEL +5 -0
- nucleuskit_toolkit-2.2.0.dist-info/entry_points.txt +2 -0
- nucleuskit_toolkit-2.2.0.dist-info/licenses/LICENSE +675 -0
- 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]
|