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,954 @@
1
+ """Real-time Hermes viewer: device selection, BLE scan, connect, plot, recording."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import csv
7
+ import os
8
+ import platform
9
+ import sys
10
+ import threading
11
+ from datetime import datetime
12
+
13
+ import numpy as np
14
+ from PySide6.QtCore import QObject, QSettings, QStandardPaths, Qt, QThread, Signal
15
+ from PySide6.QtWidgets import (
16
+ QCheckBox,
17
+ QDialog,
18
+ QFrame,
19
+ QHBoxLayout,
20
+ QLabel,
21
+ QLineEdit,
22
+ QListWidget,
23
+ QListWidgetItem,
24
+ QMessageBox,
25
+ QPushButton,
26
+ QRadioButton,
27
+ QSplitter,
28
+ QStackedWidget,
29
+ QVBoxLayout,
30
+ QWidget,
31
+ )
32
+
33
+ from nucleuskit_toolkit.hermes.realtime.proxy import HermesBleProxy
34
+ from nucleuskit_toolkit.shimmer import ShimmerSerialProxy
35
+ from nucleuskit_toolkit.ui.icons import Spinner
36
+ from nucleuskit_toolkit.ui.realtime_eeg_plot import RealtimeEegPlot
37
+ from nucleuskit_toolkit.ui.realtime_hermes_motion_plot import RealtimeHermesMotionPlot
38
+ from nucleuskit_toolkit.ui.realtime_nlms_config_dialog import NlmsPredictorConfigDialog
39
+ from nucleuskit_toolkit.ui.realtime_shimmer_plot import RealtimeShimmerPlot
40
+ from nucleuskit_toolkit.ui.theme import current_palette
41
+
42
+ PAGE_DEVICE = 0
43
+ PAGE_HERMES_SCAN = 1
44
+ PAGE_SHIMMER_COM = 2
45
+ PAGE_STREAM = 3
46
+
47
+
48
+ class StreamBridge(QObject):
49
+ """Marshals EEG rows and status strings from BLE worker threads to the GUI thread."""
50
+
51
+ eeg = Signal(object)
52
+ status = Signal(str) # proxy status messages (connection lost / reconnecting)
53
+
54
+
55
+ class ShimmerStreamBridge(QObject):
56
+ """Marshals Shimmer decoded rows to the GUI thread (IMU + EDA + PPG for plotting)."""
57
+
58
+ sample = Signal(float, float, float, float, float)
59
+
60
+
61
+ class MotionStreamBridge(QObject):
62
+ """Marshals Hermes motion tuples from the BLE worker thread to the GUI thread."""
63
+
64
+ motion = Signal(float, float, float, float, float, float, float, float, float)
65
+
66
+
67
+ def _bleak_scan_hermes_devices(
68
+ timeout_s: float,
69
+ on_found, # type: ignore[no-untyped-def]
70
+ stop: threading.Event,
71
+ ) -> int:
72
+ """
73
+ Run Bleak discovery in the current thread, reporting each Hermes device as soon as it is seen.
74
+
75
+ on_found(name, address) is called once per device; the scan ends after timeout_s seconds or when stop is
76
+ set. Returns the number of devices found.
77
+ On Windows, asyncio must use SelectorEventLoop in worker threads (not the default
78
+ Proactor), or Bleak discovery can hang indefinitely with no error output.
79
+ """
80
+ if sys.platform == "win32":
81
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
82
+
83
+ from bleak import BleakScanner
84
+
85
+ from nucleuskit_toolkit.hermes.realtime.proxy import HERMES_NAME_SUBSTRING
86
+
87
+ seen: set[str] = set()
88
+
89
+ def detected(device, advertisement) -> None: # type: ignore[no-untyped-def]
90
+ name = device.name or getattr(advertisement, "local_name", None) or ""
91
+ if HERMES_NAME_SUBSTRING in name and device.address not in seen:
92
+ seen.add(device.address)
93
+ on_found(name, device.address)
94
+
95
+ async def discover() -> None:
96
+ scanner = BleakScanner(detection_callback=detected)
97
+ await scanner.start()
98
+ try:
99
+ loop = asyncio.get_running_loop()
100
+ end = loop.time() + timeout_s
101
+ while loop.time() < end and not stop.is_set():
102
+ await asyncio.sleep(0.1)
103
+ finally:
104
+ await scanner.stop()
105
+
106
+ asyncio.run(discover())
107
+ return len(seen)
108
+
109
+
110
+ class HermesScanThread(QThread):
111
+ """Runs the BLE scan on a background thread; keeps a strong C++/Python ref (no QObject GC bug)."""
112
+
113
+ SCAN_SECONDS = 8.0
114
+
115
+ device_found = Signal(str, str)
116
+ scan_finished = Signal(int)
117
+ scan_failed = Signal(str)
118
+
119
+ def __init__(self) -> None:
120
+ super().__init__()
121
+ self._stop = threading.Event()
122
+
123
+ def stop(self) -> None:
124
+ self._stop.set()
125
+
126
+ def run(self) -> None:
127
+ try:
128
+ count = _bleak_scan_hermes_devices(self.SCAN_SECONDS, self.device_found.emit, self._stop)
129
+ self.scan_finished.emit(count)
130
+ except Exception as e:
131
+ self.scan_failed.emit(str(e))
132
+
133
+
134
+ class WaitConnectThread(QThread):
135
+ """Blocks on HermesBleProxy.wait_until_connected() without blocking the GUI thread."""
136
+
137
+ done = Signal()
138
+ failed = Signal(str)
139
+
140
+ def __init__(self, proxy: HermesBleProxy) -> None:
141
+ super().__init__()
142
+ self._proxy = proxy
143
+
144
+ def run(self) -> None:
145
+ try:
146
+ self._proxy.wait_until_connected()
147
+ self.done.emit()
148
+ except Exception as e:
149
+ self.failed.emit(str(e))
150
+
151
+
152
+ class WaitShimmerConnectThread(QThread):
153
+ """Blocks on ShimmerSerialProxy.wait_until_connected() without blocking the GUI thread."""
154
+
155
+ done = Signal()
156
+ failed = Signal(str)
157
+
158
+ def __init__(self, proxy: ShimmerSerialProxy) -> None:
159
+ super().__init__()
160
+ self._proxy = proxy
161
+
162
+ def run(self) -> None:
163
+ try:
164
+ self._proxy.wait_until_connected()
165
+ self.done.emit()
166
+ except Exception as e:
167
+ self.failed.emit(str(e))
168
+
169
+
170
+ EEG_COLUMNS = ["AF8", "AF7", "CHEEK_R", "CHEEK_L", "EAR_R", "AFz", "BROW_L", "NOSE"]
171
+ MOTION_COLUMNS = ["x(g)", "y(g)", "z(g)", "x(deg/s)", "y(deg/s)", "z(deg/s)", "x(G)", "y(G)", "z(G)"]
172
+
173
+
174
+ class RealtimeViewerPage(QWidget):
175
+ def __init__(self, parent: QWidget | None = None) -> None:
176
+ super().__init__(parent)
177
+ self._stack = QStackedWidget()
178
+ self._bridge = StreamBridge(self)
179
+ self._shimmer_bridge = ShimmerStreamBridge(self)
180
+ self._motion_bridge = MotionStreamBridge(self)
181
+ self._proxy: HermesBleProxy | None = None
182
+ self._shimmer_proxy: ShimmerSerialProxy | None = None
183
+ self._connecting = False
184
+ self._recording = False
185
+ self._record_lock = threading.Lock()
186
+ self._eeg_file = None
187
+ self._motion_file = None
188
+ self._eeg_writer = None
189
+ self._motion_writer = None
190
+ self._shimmer_file = None
191
+ self._shimmer_writer = None
192
+
193
+ self._scan_thread: HermesScanThread | None = None
194
+ self._wait_thread: WaitConnectThread | None = None
195
+ self._wait_shimmer_thread: WaitShimmerConnectThread | None = None
196
+
197
+ self._build_device_step()
198
+ self._build_scan_step()
199
+ self._build_shimmer_com_step()
200
+ self._build_streaming_step()
201
+
202
+ root = QVBoxLayout(self)
203
+ root.setContentsMargins(0, 0, 0, 0)
204
+
205
+ root.addWidget(self._stack, stretch=1)
206
+
207
+ self._stack.setCurrentIndex(PAGE_DEVICE)
208
+
209
+ def _is_streaming_connected(self) -> bool:
210
+ if self._proxy is not None and self._proxy.is_connected:
211
+ return True
212
+ if self._shimmer_proxy is not None and self._shimmer_proxy.is_connected:
213
+ return True
214
+ return False
215
+
216
+ def _address_column_title(self) -> str:
217
+ if platform.system() == "Darwin":
218
+ return "Address (UUID on macOS)"
219
+ return "MAC address"
220
+
221
+ def _build_device_step(self) -> None:
222
+ w = QWidget()
223
+ title = QLabel("Select device type")
224
+ title.setProperty("role", "title")
225
+
226
+ self._radio_hermes = QRadioButton("Hermes Headset")
227
+ self._radio_shimmer = QRadioButton("Shimmer wristband")
228
+ self._radio_hermes.setChecked(True)
229
+
230
+ hint = QLabel(
231
+ "Shimmer: pair the wristband in Windows Bluetooth settings (Classic), then connect by MAC address."
232
+ )
233
+ hint.setProperty("role", "hint")
234
+ hint.setWordWrap(True)
235
+
236
+ self._device_next = QPushButton("Next")
237
+ self._device_next.clicked.connect(self._on_device_next)
238
+
239
+ row = QHBoxLayout()
240
+ row.addStretch(1)
241
+ row.addWidget(self._device_next)
242
+
243
+ col = QVBoxLayout(w)
244
+ col.addWidget(title)
245
+ col.addSpacing(8)
246
+ col.addWidget(self._radio_hermes)
247
+ col.addWidget(self._radio_shimmer)
248
+ col.addWidget(hint)
249
+ col.addStretch(1)
250
+ col.addLayout(row)
251
+
252
+ self._stack.addWidget(w)
253
+
254
+ def _on_device_next(self) -> None:
255
+ if self._radio_hermes.isChecked():
256
+ self._stack.setCurrentIndex(PAGE_HERMES_SCAN)
257
+ else:
258
+ self._stack.setCurrentIndex(PAGE_SHIMMER_COM)
259
+
260
+ def _build_scan_step(self) -> None:
261
+ w = QWidget()
262
+ title = QLabel("Discover Hermes headset")
263
+ title.setProperty("role", "title")
264
+
265
+ self._addr_hint = QLabel(f"Discovered devices show name and {self._address_column_title()}.")
266
+ self._addr_hint.setWordWrap(True)
267
+
268
+ self._device_list = QListWidget()
269
+ self._device_list.itemDoubleClicked.connect(lambda _i: self._on_connect_clicked())
270
+
271
+ self._scan_btn = QPushButton("Scan")
272
+ self._scan_btn.setProperty("secondary", True)
273
+ self._scan_btn.clicked.connect(self._scan_or_stop)
274
+
275
+ self._connect_btn = QPushButton("Connect")
276
+ self._connect_btn.clicked.connect(self._on_connect_clicked)
277
+ self._connect_btn.setEnabled(False)
278
+ self._device_list.itemSelectionChanged.connect(self._update_connect_enabled)
279
+
280
+ self._scan_spinner = Spinner(18, current_palette().accent)
281
+ self._scan_spinner.hide()
282
+ self._scan_status = QLabel("Press Scan to look for Hermes headsets nearby.")
283
+ self._scan_status.setProperty("role", "hint")
284
+
285
+ row_btns = QHBoxLayout()
286
+ row_btns.addWidget(self._scan_btn)
287
+ row_btns.addWidget(self._connect_btn)
288
+ row_btns.addSpacing(12)
289
+ row_btns.addWidget(self._scan_spinner)
290
+ row_btns.addWidget(self._scan_status, 1)
291
+
292
+ back = QPushButton("Back")
293
+ back.setProperty("secondary", True)
294
+ back.clicked.connect(self._scan_back)
295
+
296
+ row_nav = QHBoxLayout()
297
+ row_nav.addWidget(back)
298
+ row_nav.addStretch(1)
299
+
300
+ col = QVBoxLayout(w)
301
+ col.addWidget(title)
302
+ col.addWidget(self._addr_hint)
303
+ col.addWidget(self._device_list, stretch=1)
304
+ col.addLayout(row_btns)
305
+ col.addLayout(row_nav)
306
+
307
+ self._stack.addWidget(w)
308
+
309
+ def _build_shimmer_com_step(self) -> None:
310
+ w = QWidget()
311
+ title = QLabel("Shimmer wristband — connect")
312
+ title.setProperty("role", "title")
313
+
314
+ # ── Paired device scan ───────────────────────────────────────────
315
+ scan_label = QLabel("Paired Bluetooth devices")
316
+ scan_label.setProperty("role", "section")
317
+
318
+ scan_hint = QLabel(
319
+ "Tap Scan to list devices already paired in Windows Bluetooth settings. "
320
+ "Click a device to fill in its MAC address automatically."
321
+ )
322
+ scan_hint.setWordWrap(True)
323
+
324
+ self._shimmer_scan_btn = QPushButton("Scan paired devices")
325
+ self._shimmer_scan_btn.clicked.connect(self._on_shimmer_scan)
326
+
327
+ self._shimmer_device_list = QListWidget()
328
+ self._shimmer_device_list.setMaximumHeight(140)
329
+ self._shimmer_device_list.itemClicked.connect(self._on_shimmer_device_selected)
330
+
331
+ # ── Separator ───────────────────────────────────────────────────
332
+ sep = QFrame()
333
+ sep.setFrameShape(QFrame.Shape.HLine)
334
+ sep.setFrameShadow(QFrame.Shadow.Sunken)
335
+
336
+ # ── Manual MAC entry ─────────────────────────────────────────────
337
+ manual_label = QLabel("MAC address")
338
+ manual_label.setProperty("role", "section")
339
+
340
+ row_mac = QHBoxLayout()
341
+ row_mac.addWidget(QLabel("MAC address:"))
342
+ self._shimmer_mac_edit = QLineEdit()
343
+ self._shimmer_mac_edit.setPlaceholderText("e.g. 00:06:66:B7:00:00")
344
+ self._shimmer_mac_edit.setMaxLength(17)
345
+ self._shimmer_mac_edit.setToolTip(
346
+ "Bluetooth MAC address in XX:XX:XX:XX:XX:XX format (colons or hyphens).\n"
347
+ "Find it in Windows Bluetooth settings or on the Shimmer device label."
348
+ )
349
+ self._shimmer_mac_edit.textChanged.connect(self._on_shimmer_mac_changed)
350
+ row_mac.addWidget(self._shimmer_mac_edit, stretch=1)
351
+
352
+ # ── Action buttons ───────────────────────────────────────────────
353
+ self._shimmer_connect_btn = QPushButton("Connect")
354
+ self._shimmer_connect_btn.setEnabled(False)
355
+ self._shimmer_connect_btn.clicked.connect(self._on_shimmer_connect_clicked)
356
+
357
+ back = QPushButton("Back")
358
+ back.setProperty("secondary", True)
359
+ back.clicked.connect(self._shimmer_com_back)
360
+
361
+ row_btns = QHBoxLayout()
362
+ row_btns.addWidget(back)
363
+ row_btns.addStretch(1)
364
+ row_btns.addWidget(self._shimmer_connect_btn)
365
+
366
+ col = QVBoxLayout(w)
367
+ col.addWidget(title)
368
+ col.addSpacing(8)
369
+ col.addWidget(scan_label)
370
+ col.addWidget(scan_hint)
371
+ col.addWidget(self._shimmer_scan_btn)
372
+ col.addWidget(self._shimmer_device_list)
373
+ col.addSpacing(8)
374
+ col.addWidget(sep)
375
+ col.addSpacing(8)
376
+ col.addWidget(manual_label)
377
+ col.addLayout(row_mac)
378
+ col.addStretch(1)
379
+ col.addLayout(row_btns)
380
+
381
+ self._stack.addWidget(w)
382
+ self._load_shimmer_mac()
383
+
384
+ @staticmethod
385
+ def _scan_paired_bt_devices() -> list[tuple[str, str]]:
386
+ """Return (name, MAC) pairs for all Bluetooth Classic devices paired in Windows.
387
+
388
+ Reads from the registry key where Windows stores paired BT device info.
389
+ Returns an empty list on non-Windows platforms or if the key cannot be opened.
390
+ """
391
+ if sys.platform != "win32":
392
+ return []
393
+ try:
394
+ import winreg
395
+
396
+ key_path = r"SYSTEM\CurrentControlSet\Services\BTHPORT\Parameters\Devices"
397
+ root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path)
398
+ devices: list[tuple[str, str]] = []
399
+ idx = 0
400
+ while True:
401
+ try:
402
+ sub_name = winreg.EnumKey(root, idx)
403
+ idx += 1
404
+ if len(sub_name) != 12:
405
+ continue
406
+ mac = ":".join(sub_name[j : j + 2] for j in range(0, 12, 2)).upper()
407
+ try:
408
+ sub = winreg.OpenKey(root, sub_name)
409
+ name_raw, _ = winreg.QueryValueEx(sub, "Name")
410
+ winreg.CloseKey(sub)
411
+ if isinstance(name_raw, bytes):
412
+ name = name_raw.rstrip(b"\x00").decode("utf-8", errors="replace")
413
+ else:
414
+ name = str(name_raw)
415
+ except OSError:
416
+ name = mac
417
+ devices.append((name, mac))
418
+ except OSError:
419
+ break
420
+ winreg.CloseKey(root)
421
+ return devices
422
+ except Exception:
423
+ return []
424
+
425
+ def _on_shimmer_scan(self) -> None:
426
+ self._shimmer_scan_btn.setEnabled(False)
427
+ self._shimmer_device_list.clear()
428
+ all_devices = self._scan_paired_bt_devices()
429
+ devices = [(n, m) for n, m in all_devices if "shimmer" in n.lower()]
430
+ if not devices:
431
+ if sys.platform != "win32":
432
+ msg = "Paired device scan is only available on Windows. Enter the MAC address manually."
433
+ elif not all_devices:
434
+ msg = "No paired Bluetooth devices found. Pair the Shimmer in Windows Bluetooth settings first."
435
+ else:
436
+ msg = (
437
+ f"No Shimmer devices found among {len(all_devices)} paired device(s). "
438
+ "Ensure the Shimmer is paired and its name contains 'Shimmer'."
439
+ )
440
+ item = QListWidgetItem(msg)
441
+ item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
442
+ self._shimmer_device_list.addItem(item)
443
+ else:
444
+ for name, mac in devices:
445
+ item = QListWidgetItem(f"{name} | {mac}")
446
+ item.setData(Qt.ItemDataRole.UserRole, mac)
447
+ self._shimmer_device_list.addItem(item)
448
+ self._shimmer_scan_btn.setEnabled(True)
449
+
450
+ def _on_shimmer_device_selected(self, item: QListWidgetItem) -> None:
451
+ mac = item.data(Qt.ItemDataRole.UserRole)
452
+ if mac:
453
+ self._shimmer_mac_edit.setText(mac)
454
+
455
+ def _shimmer_com_back(self) -> None:
456
+ if self._connecting:
457
+ QMessageBox.information(
458
+ self,
459
+ "Please wait",
460
+ "Connection in progress. Wait until it completes or fails before going back.",
461
+ )
462
+ return
463
+ if self._is_streaming_connected():
464
+ QMessageBox.information(
465
+ self,
466
+ "Disconnect required",
467
+ "Disconnect from the device before going back.",
468
+ )
469
+ return
470
+ self._stack.setCurrentIndex(PAGE_DEVICE)
471
+
472
+ def _shimmer_mac_is_valid(self) -> bool:
473
+ return ShimmerSerialProxy.is_mac_address(self._shimmer_mac_edit.text())
474
+
475
+ def _load_shimmer_mac(self) -> None:
476
+ s = QSettings("NucleusKit", "RealtimeViewer")
477
+ mac = s.value("shimmer/mac", "", type=str)
478
+ if mac:
479
+ self._shimmer_mac_edit.setText(mac)
480
+
481
+ def _save_shimmer_mac(self, mac: str) -> None:
482
+ s = QSettings("NucleusKit", "RealtimeViewer")
483
+ s.setValue("shimmer/mac", mac)
484
+
485
+ def _on_shimmer_mac_changed(self, text: str) -> None:
486
+ valid = ShimmerSerialProxy.is_mac_address(text) if text.strip() else True
487
+ style = "" if valid else "QLineEdit { border: 1px solid #c0392b; }"
488
+ self._shimmer_mac_edit.setStyleSheet(style)
489
+ self._shimmer_connect_btn.setEnabled(self._shimmer_mac_is_valid())
490
+
491
+ def _on_shimmer_connect_clicked(self) -> None:
492
+ mac_text = self._shimmer_mac_edit.text().strip()
493
+
494
+ if not mac_text:
495
+ QMessageBox.information(
496
+ self,
497
+ "MAC address required",
498
+ "Enter the Shimmer MAC address or scan for paired devices above.\n\n"
499
+ "Find it in Windows Bluetooth settings or on the Shimmer device label.",
500
+ )
501
+ return
502
+
503
+ if not ShimmerSerialProxy.is_mac_address(mac_text):
504
+ QMessageBox.warning(
505
+ self,
506
+ "Invalid MAC address",
507
+ f"'{mac_text}' is not a valid Bluetooth MAC address.\n\n"
508
+ "Expected format: XX:XX:XX:XX:XX:XX (e.g. 00:06:66:B7:00:00).",
509
+ )
510
+ return
511
+
512
+ target = mac_text.upper().replace("-", ":")
513
+ self._connecting = True
514
+ self._shimmer_connect_btn.setEnabled(False)
515
+ self._shimmer_scan_btn.setEnabled(False)
516
+
517
+ def sample_cb(row: list[float]) -> None:
518
+ self._shimmer_bridge.sample.emit(row[1], row[2], row[3], row[4], row[5])
519
+ with self._record_lock:
520
+ w = self._shimmer_writer
521
+ if w is not None:
522
+ w.writerow(row)
523
+
524
+ self._shimmer_proxy = ShimmerSerialProxy(target, sample_cb)
525
+
526
+ self._wait_shimmer_thread = WaitShimmerConnectThread(self._shimmer_proxy)
527
+ self._wait_shimmer_thread.done.connect(lambda: self._on_shimmer_connect_done(target))
528
+ self._wait_shimmer_thread.failed.connect(self._on_shimmer_connect_failed)
529
+ self._wait_shimmer_thread.finished.connect(self._cleanup_wait_shimmer_thread)
530
+ self._wait_shimmer_thread.start()
531
+
532
+ def _cleanup_wait_shimmer_thread(self) -> None:
533
+ self._wait_shimmer_thread = None
534
+ self._shimmer_scan_btn.setEnabled(True)
535
+ self._shimmer_connect_btn.setEnabled(self._shimmer_mac_is_valid())
536
+
537
+ def _on_shimmer_connect_done(self, connected_mac: str | None = None) -> None:
538
+ self._connecting = False
539
+ if connected_mac:
540
+ self._save_shimmer_mac(connected_mac)
541
+ self._plot_stack.setCurrentIndex(1)
542
+ self._stack.setCurrentIndex(PAGE_STREAM)
543
+ self._rec_btn.setEnabled(True)
544
+ self._disconnect_btn.setEnabled(True)
545
+ self._ear_r_ref_checkbox.setVisible(False)
546
+ self._eeg_plot.set_ear_r_re_reference(False)
547
+ self._nlms_checkbox.setVisible(False)
548
+ self._nlms_config_btn.setVisible(False)
549
+ self._shimmer_plot.clear_buffers()
550
+ self._stream_hint.setText("Streaming Shimmer wristband. Use the controls below.")
551
+
552
+ @staticmethod
553
+ def _format_shimmer_serial_error(msg: str) -> str:
554
+ lower = msg.lower()
555
+ if any(
556
+ s in lower
557
+ for s in (
558
+ "timed out",
559
+ "winerror",
560
+ "connection refused",
561
+ "no route to host",
562
+ "target machine actively refused",
563
+ "wsaehostunreach",
564
+ "a socket operation was attempted",
565
+ "ack",
566
+ "inquiry",
567
+ )
568
+ ):
569
+ return (
570
+ f"{msg}\n\n"
571
+ "Ensure the Shimmer is powered on, awake, and paired in Windows Bluetooth settings. "
572
+ "The device must be in range and not already connected to another host or app "
573
+ "(e.g. Shimmer Consensys). Try power-cycling the Shimmer and connecting again."
574
+ )
575
+ return msg
576
+
577
+ def _on_shimmer_connect_failed(self, msg: str) -> None:
578
+ self._connecting = False
579
+ if self._shimmer_proxy is not None:
580
+ self._shimmer_proxy.disconnect()
581
+ self._shimmer_proxy = None
582
+ QMessageBox.warning(self, "Connection failed", self._format_shimmer_serial_error(msg))
583
+
584
+ def _update_connect_enabled(self) -> None:
585
+ self._connect_btn.setEnabled(len(self._device_list.selectedItems()) > 0)
586
+
587
+ def _scan_back(self) -> None:
588
+ if self._connecting:
589
+ QMessageBox.information(
590
+ self,
591
+ "Please wait",
592
+ "Connection in progress. Wait until it completes or fails before going back.",
593
+ )
594
+ return
595
+ if self._is_streaming_connected():
596
+ QMessageBox.information(
597
+ self,
598
+ "Disconnect required",
599
+ "Disconnect from the device before going back.",
600
+ )
601
+ return
602
+ self._stack.setCurrentIndex(PAGE_DEVICE)
603
+
604
+ def _scan_running(self) -> bool:
605
+ return self._scan_thread is not None and self._scan_thread.isRunning()
606
+
607
+ def _scan_or_stop(self) -> None:
608
+ if self._scan_running():
609
+ self._stop_scan()
610
+ else:
611
+ self._start_scan()
612
+
613
+ def _start_scan(self) -> None:
614
+ if self._scan_running():
615
+ return
616
+
617
+ self._device_list.clear()
618
+ self._scan_btn.setText("Stop")
619
+ self._scan_spinner.set_color(current_palette().accent)
620
+ self._scan_spinner.start()
621
+ self._scan_status.setText("Scanning… 0 devices found")
622
+
623
+ self._scan_thread = HermesScanThread()
624
+ self._scan_thread.device_found.connect(self._on_device_found)
625
+ self._scan_thread.scan_finished.connect(self._on_scan_finished)
626
+ self._scan_thread.scan_failed.connect(self._on_scan_failed)
627
+ self._scan_thread.finished.connect(self._cleanup_scan_thread)
628
+ self._scan_thread.start()
629
+
630
+ def _stop_scan(self, wait: bool = False) -> None:
631
+ """Ask the scan to end; with wait=True, block until the radio is free (before connecting)."""
632
+ thread = self._scan_thread
633
+ if thread is None:
634
+ return
635
+ thread.stop()
636
+ if wait:
637
+ thread.wait(3000)
638
+
639
+ def _cleanup_scan_thread(self) -> None:
640
+ self._scan_thread = None
641
+ self._scan_spinner.stop()
642
+ self._scan_btn.setText("Scan")
643
+ self._scan_btn.setEnabled(not self._connecting)
644
+
645
+ def _on_device_found(self, name: str, address: str) -> None:
646
+ it = QListWidgetItem(f"{name} | {address}")
647
+ it.setData(Qt.ItemDataRole.UserRole, address)
648
+ self._device_list.addItem(it)
649
+ if self._device_list.count() == 1:
650
+ self._device_list.setCurrentItem(it)
651
+ n = self._device_list.count()
652
+ self._scan_status.setText(f"Scanning… {n} device{'s' if n != 1 else ''} found")
653
+
654
+ def _on_scan_finished(self, count: int) -> None:
655
+ if count:
656
+ self._scan_status.setText(f"{count} device{'s' if count != 1 else ''} found. Select one and press Connect.")
657
+ else:
658
+ self._scan_status.setText(
659
+ "No Hermes devices found. Make sure the headset is on and nearby, then scan again."
660
+ )
661
+
662
+ def _on_scan_failed(self, msg: str) -> None:
663
+ self._scan_status.setText("Scan failed.")
664
+ QMessageBox.warning(self, "Scan failed", msg)
665
+
666
+ def _on_connect_clicked(self) -> None:
667
+ items = self._device_list.selectedItems()
668
+ if not items:
669
+ QMessageBox.information(self, "No device", "Select a device from the list.")
670
+ return
671
+
672
+ address = items[0].data(Qt.ItemDataRole.UserRole)
673
+ if not address:
674
+ return
675
+
676
+ self._stop_scan(wait=True)
677
+ self._connecting = True
678
+ self._connect_btn.setEnabled(False)
679
+ self._scan_btn.setEnabled(False)
680
+
681
+ def eeg_cb(samples: list) -> None:
682
+ arr = np.asarray(samples, dtype=np.float64)
683
+ if arr.ndim == 2 and arr.shape[1] == 8:
684
+ self._eeg_plot.enqueue_samples(arr)
685
+ with self._record_lock:
686
+ w = self._eeg_writer
687
+ if w is not None:
688
+ w.writerows(samples)
689
+
690
+ def motion_cb(sample: tuple) -> None:
691
+ self._motion_bridge.motion.emit(*sample)
692
+ with self._record_lock:
693
+ w = self._motion_writer
694
+ if w is not None:
695
+ w.writerow([f"{x:.5f}" for x in sample])
696
+
697
+ # Emit status strings on the bridge so Qt routes them safely to the GUI thread.
698
+ bridge = self._bridge
699
+
700
+ def _status_cb(msg: str) -> None:
701
+ bridge.status.emit(msg)
702
+
703
+ self._proxy = HermesBleProxy(
704
+ address,
705
+ eeg_callback=eeg_cb,
706
+ motion_callback=motion_cb,
707
+ status_callback=_status_cb,
708
+ )
709
+
710
+ self._wait_thread = WaitConnectThread(self._proxy)
711
+ self._wait_thread.done.connect(self._on_connect_done)
712
+ self._wait_thread.failed.connect(self._on_connect_failed)
713
+ self._wait_thread.finished.connect(self._cleanup_wait_thread)
714
+ self._wait_thread.start()
715
+
716
+ def _cleanup_wait_thread(self) -> None:
717
+ self._wait_thread = None
718
+ self._connect_btn.setEnabled(len(self._device_list.selectedItems()) > 0)
719
+ self._scan_btn.setEnabled(True)
720
+
721
+ def _on_connect_done(self) -> None:
722
+ self._connecting = False
723
+ self._plot_stack.setCurrentIndex(0)
724
+ self._stack.setCurrentIndex(PAGE_STREAM)
725
+ self._rec_btn.setEnabled(True)
726
+ self._disconnect_btn.setEnabled(True)
727
+ self._ear_r_ref_checkbox.setVisible(True)
728
+ self._ear_r_ref_checkbox.setEnabled(True)
729
+ self._eeg_plot.set_ear_r_re_reference(self._ear_r_ref_checkbox.isChecked())
730
+ self._nlms_checkbox.setVisible(True)
731
+ self._nlms_checkbox.setEnabled(True)
732
+ self._nlms_config_btn.setVisible(True)
733
+ self._nlms_config_btn.setEnabled(True)
734
+ self._eeg_plot.clear_buffers()
735
+ self._hermes_motion_plot.clear_buffers()
736
+ self._stream_hint.setText("Streaming EEG and 9-axis motion. Use the controls below.")
737
+
738
+ def _on_proxy_status(self, msg: str) -> None:
739
+ """Show a BLE status/reconnect message in the stream hint (GUI thread)."""
740
+ # Don't overwrite recording-status text while a recording is active.
741
+ with self._record_lock:
742
+ recording = self._eeg_writer is not None
743
+ if not recording:
744
+ self._stream_hint.setText(msg)
745
+
746
+ def _on_connect_failed(self, msg: str) -> None:
747
+ self._connecting = False
748
+ if self._proxy is not None:
749
+ self._proxy.disconnect()
750
+ self._proxy = None
751
+ QMessageBox.warning(self, "Connection failed", msg)
752
+
753
+ def _build_streaming_step(self) -> None:
754
+ w = QWidget()
755
+ self._plot_stack = QStackedWidget()
756
+
757
+ hermes_stream = QWidget()
758
+ hermes_split = QSplitter(Qt.Orientation.Vertical)
759
+ self._eeg_plot = RealtimeEegPlot(hermes_stream)
760
+ self._hermes_motion_plot = RealtimeHermesMotionPlot(hermes_stream)
761
+ hermes_split.addWidget(self._eeg_plot)
762
+ hermes_split.addWidget(self._hermes_motion_plot)
763
+ hermes_split.setStretchFactor(0, 2)
764
+ hermes_split.setStretchFactor(1, 1)
765
+ hermes_outer = QVBoxLayout(hermes_stream)
766
+ hermes_outer.setContentsMargins(0, 0, 0, 0)
767
+ hermes_outer.addWidget(hermes_split)
768
+
769
+ self._shimmer_plot = RealtimeShimmerPlot(w)
770
+ self._plot_stack.addWidget(hermes_stream)
771
+ self._plot_stack.addWidget(self._shimmer_plot)
772
+
773
+ # EEG samples are enqueued directly from the BLE worker thread into the
774
+ # plot's thread-safe buffer; the plot's QTimer flushes at ~30 FPS.
775
+ # This removes the high-rate Qt signal that was flooding the event queue.
776
+ self._motion_bridge.motion.connect(self._hermes_motion_plot.add_motion_sample)
777
+ self._shimmer_bridge.sample.connect(self._shimmer_plot.add_sample)
778
+ # Show BLE reconnect/status messages in the stream hint label.
779
+ self._bridge.status.connect(self._on_proxy_status)
780
+
781
+ self._stream_hint = QLabel("Streaming. Use the controls below.")
782
+ self._stream_hint.setProperty("role", "hint")
783
+ self._stream_hint.setWordWrap(True)
784
+
785
+ self._disconnect_btn = QPushButton("Disconnect")
786
+ self._disconnect_btn.setEnabled(False)
787
+ self._disconnect_btn.clicked.connect(self._on_disconnect)
788
+
789
+ self._rec_btn = QPushButton("Start recording")
790
+ self._rec_btn.setEnabled(False)
791
+ self._rec_btn.clicked.connect(self._toggle_recording)
792
+
793
+ self._ear_r_ref_checkbox = QCheckBox("Re-reference all channels to EAR_R ÷ 2 (before filter)")
794
+ self._ear_r_ref_checkbox.setEnabled(False)
795
+ self._ear_r_ref_checkbox.setToolTip(
796
+ "Subtract EAR_R/2 from every channel, then apply the viewer bandpass. "
797
+ "Same scheme as offline loadEXG(re_reference=True)."
798
+ )
799
+ self._ear_r_ref_checkbox.toggled.connect(self._eeg_plot.set_ear_r_re_reference)
800
+
801
+ self._nlms_checkbox = QCheckBox("Adaptive NLMS decorrelation")
802
+ self._nlms_checkbox.setEnabled(False)
803
+ self._nlms_checkbox.setToolTip(
804
+ "Apply causal NLMS adaptive regression after the bandpass filter.\n"
805
+ "Predictor connectivity can be configured via the button to the right.\n"
806
+ "Unchecking resets all adaptive weights."
807
+ )
808
+ self._nlms_checkbox.toggled.connect(self._eeg_plot.set_nlms_filter)
809
+
810
+ self._nlms_config_btn = QPushButton("Configure NLMS predictors…")
811
+ self._nlms_config_btn.setEnabled(False)
812
+ self._nlms_config_btn.setToolTip(
813
+ "Edit which channels are used as predictors for each target channel.\n"
814
+ "Default: AF8, AF7, and BROW_L do not regress against each other."
815
+ )
816
+ self._nlms_config_btn.clicked.connect(self._configure_nlms_predictors)
817
+
818
+ row = QHBoxLayout()
819
+ row.addWidget(self._disconnect_btn)
820
+ row.addWidget(self._rec_btn)
821
+ row.addWidget(self._ear_r_ref_checkbox)
822
+ row.addWidget(self._nlms_checkbox)
823
+ row.addWidget(self._nlms_config_btn)
824
+ row.addStretch(1)
825
+
826
+ col = QVBoxLayout(w)
827
+ col.addWidget(self._stream_hint)
828
+ col.addWidget(self._plot_stack, stretch=1)
829
+ col.addLayout(row)
830
+
831
+ self._stack.addWidget(w)
832
+
833
+ def _configure_nlms_predictors(self) -> None:
834
+ dialog = NlmsPredictorConfigDialog(
835
+ self._eeg_plot.nlms_predictor_mask(),
836
+ parent=self,
837
+ )
838
+ if dialog.exec() != QDialog.DialogCode.Accepted:
839
+ return
840
+ self._eeg_plot.set_nlms_predictor_mask(dialog.predictor_mask())
841
+
842
+ def refresh_theme(self) -> None:
843
+ """Called by the main window after a theme change."""
844
+ for plot in (self._eeg_plot, self._hermes_motion_plot, self._shimmer_plot):
845
+ plot.refresh_theme()
846
+ self._scan_spinner.set_color(current_palette().accent)
847
+
848
+ def can_leave(self) -> bool:
849
+ """Called by the main window before another page is shown."""
850
+ if self._connecting:
851
+ QMessageBox.information(
852
+ self,
853
+ "Please wait",
854
+ "Connection in progress. Wait until it completes or fails before leaving this page.",
855
+ )
856
+ return False
857
+ if self._is_streaming_connected():
858
+ QMessageBox.information(
859
+ self,
860
+ "Disconnect required",
861
+ "Disconnect from the device before leaving this page.",
862
+ )
863
+ return False
864
+ return True
865
+
866
+ def _toggle_recording(self) -> None:
867
+ if not self._recording:
868
+ self._start_recording()
869
+ else:
870
+ self._stop_recording()
871
+
872
+ def _default_record_dir(self) -> str:
873
+ base = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DocumentsLocation)
874
+ folder = os.path.join(base, "NucleusKitRealtime")
875
+ os.makedirs(folder, exist_ok=True)
876
+ return folder
877
+
878
+ def _start_recording(self) -> None:
879
+ folder = self._default_record_dir()
880
+ ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
881
+
882
+ if self._plot_stack.currentIndex() == 0:
883
+ eeg_path = os.path.join(folder, f"hermes_eeg_{ts}.csv")
884
+ motion_path = os.path.join(folder, f"hermes_motion_{ts}.csv")
885
+ self._eeg_file = open(eeg_path, "w", newline="")
886
+ self._motion_file = open(motion_path, "w", newline="")
887
+ self._eeg_writer = csv.writer(self._eeg_file)
888
+ self._motion_writer = csv.writer(self._motion_file)
889
+ self._eeg_writer.writerow(EEG_COLUMNS)
890
+ self._motion_writer.writerow(MOTION_COLUMNS)
891
+ self._stream_hint.setText(f"Recording to:\n{eeg_path}\n{motion_path}")
892
+ else:
893
+ shimmer_path = os.path.join(folder, f"rawShimmer_{ts}.csv")
894
+ self._shimmer_file = open(shimmer_path, "w", newline="")
895
+ self._shimmer_writer = csv.writer(self._shimmer_file)
896
+ self._stream_hint.setText(
897
+ f"Recording to:\n{shimmer_path}\n"
898
+ "(columns: epoch_ms, IMU_x, IMU_y, IMU_z, EDA, PPG — no header, offline-compatible)"
899
+ )
900
+
901
+ self._recording = True
902
+ self._rec_btn.setText("Stop recording")
903
+
904
+ def _stop_recording(self) -> None:
905
+ with self._record_lock:
906
+ self._eeg_writer = None
907
+ self._motion_writer = None
908
+ self._shimmer_writer = None
909
+ if self._eeg_file is not None:
910
+ self._eeg_file.close()
911
+ self._eeg_file = None
912
+ if self._motion_file is not None:
913
+ self._motion_file.close()
914
+ self._motion_file = None
915
+ if self._shimmer_file is not None:
916
+ self._shimmer_file.close()
917
+ self._shimmer_file = None
918
+ self._recording = False
919
+ self._rec_btn.setText("Start recording")
920
+ if self._plot_stack.currentIndex() == 0:
921
+ self._stream_hint.setText("Streaming EEG and 9-axis motion. Use the controls below.")
922
+ else:
923
+ self._stream_hint.setText("Streaming Shimmer wristband. Use the controls below.")
924
+
925
+ def _on_disconnect(self) -> None:
926
+ if self._recording:
927
+ self._stop_recording()
928
+
929
+ if self._proxy is not None:
930
+ self._proxy.disconnect()
931
+ self._proxy = None
932
+
933
+ if self._shimmer_proxy is not None:
934
+ self._shimmer_proxy.disconnect()
935
+ self._shimmer_proxy = None
936
+
937
+ self._rec_btn.setEnabled(False)
938
+ self._disconnect_btn.setEnabled(False)
939
+ self._ear_r_ref_checkbox.setVisible(True)
940
+ self._ear_r_ref_checkbox.setEnabled(False)
941
+ self._ear_r_ref_checkbox.setChecked(False)
942
+ self._eeg_plot.set_ear_r_re_reference(False)
943
+ self._nlms_checkbox.setVisible(True)
944
+ self._nlms_checkbox.setEnabled(False)
945
+ self._nlms_checkbox.setChecked(False)
946
+ self._nlms_config_btn.setVisible(True)
947
+ self._nlms_config_btn.setEnabled(False)
948
+ self._rec_btn.setText("Start recording")
949
+ self._plot_stack.setCurrentIndex(0)
950
+ self._stream_hint.setText("Streaming. Use the controls below.")
951
+ self._eeg_plot.clear_buffers()
952
+ self._hermes_motion_plot.clear_buffers()
953
+ self._shimmer_plot.clear_buffers()
954
+ self._stack.setCurrentIndex(PAGE_DEVICE)