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,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)
|