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,394 @@
1
+ """MQTT Controller page — connect to a Vizia Mobile broker and send recording commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import QObject, QSettings, Qt, Signal
6
+ from PySide6.QtGui import QFont
7
+ from PySide6.QtWidgets import (
8
+ QComboBox,
9
+ QFormLayout,
10
+ QGroupBox,
11
+ QHBoxLayout,
12
+ QLabel,
13
+ QLineEdit,
14
+ QMessageBox,
15
+ QPlainTextEdit,
16
+ QPushButton,
17
+ QSizePolicy,
18
+ QSpinBox,
19
+ QSplitter,
20
+ QVBoxLayout,
21
+ QWidget,
22
+ )
23
+
24
+ from nucleuskit_toolkit.mqtt.mqtt_controller import (
25
+ DEFAULT_BROKER_PORT,
26
+ DEFAULT_COMMAND_TOPIC_SUFFIX,
27
+ DEFAULT_STATUS_TOPIC,
28
+ TARGET_ALL,
29
+ DeviceStatus,
30
+ MqttController,
31
+ )
32
+
33
+ _SETTINGS_ORG = "NucleusKit"
34
+ _SETTINGS_APP = "MqttController"
35
+ _KEY_HOST = "broker/host"
36
+ _KEY_PORT = "broker/port"
37
+ _KEY_STATUS_TOPIC = "broker/status_topic"
38
+ _KEY_CMD_SUFFIX = "broker/command_suffix"
39
+
40
+
41
+ class _MqttBridge(QObject):
42
+ """
43
+ Bridges paho callbacks (arbitrary thread) to Qt signals (GUI thread).
44
+ Must live on the GUI thread so signal delivery is automatic via the event loop.
45
+ """
46
+
47
+ log_line = Signal(str)
48
+ device_updated = Signal(object) # DeviceStatus
49
+ connected = Signal()
50
+ disconnected = Signal()
51
+
52
+
53
+ class MqttControllerPage(QWidget):
54
+ """Full-page MQTT controller: broker configuration, device discovery, commands."""
55
+
56
+
57
+ def __init__(self, parent: QWidget | None = None) -> None:
58
+ super().__init__(parent)
59
+
60
+ self._bridge = _MqttBridge(self)
61
+ self._bridge.log_line.connect(self._append_log)
62
+ self._bridge.device_updated.connect(self._on_device_updated)
63
+ self._bridge.connected.connect(self._on_connected)
64
+ self._bridge.disconnected.connect(self._on_disconnected)
65
+
66
+ self._controller: MqttController | None = None
67
+
68
+ self._build_ui()
69
+ self._load_settings()
70
+
71
+ # ── UI construction ──────────────────────────────────────────────────
72
+
73
+ def _build_ui(self) -> None:
74
+ root = QVBoxLayout(self)
75
+ root.setContentsMargins(0, 0, 0, 0)
76
+
77
+ # ── Main splitter: left panel / right log ────────────────────────
78
+ splitter = QSplitter(Qt.Orientation.Horizontal)
79
+ splitter.setChildrenCollapsible(False)
80
+
81
+ left = QWidget()
82
+ left_col = QVBoxLayout(left)
83
+ left_col.setContentsMargins(0, 0, 8, 0)
84
+
85
+ left_col.addWidget(self._build_broker_group())
86
+ left_col.addSpacing(8)
87
+ left_col.addWidget(self._build_devices_group())
88
+ left_col.addSpacing(8)
89
+ left_col.addWidget(self._build_commands_group())
90
+ left_col.addStretch(1)
91
+
92
+ right = QWidget()
93
+ right_col = QVBoxLayout(right)
94
+ right_col.setContentsMargins(8, 0, 0, 0)
95
+ right_col.addWidget(self._build_log_group())
96
+
97
+ splitter.addWidget(left)
98
+ splitter.addWidget(right)
99
+ splitter.setStretchFactor(0, 1)
100
+ splitter.setStretchFactor(1, 2)
101
+
102
+ root.addWidget(splitter, stretch=1)
103
+
104
+ def _build_broker_group(self) -> QGroupBox:
105
+ group = QGroupBox("Broker connection")
106
+ form = QFormLayout(group)
107
+
108
+ self._host_edit = QLineEdit()
109
+ self._host_edit.setPlaceholderText("e.g. 192.168.1.100")
110
+ self._host_edit.setToolTip("IP address or hostname of the MQTT broker.")
111
+ form.addRow("Host:", self._host_edit)
112
+
113
+ self._port_spin = QSpinBox()
114
+ self._port_spin.setRange(1, 65535)
115
+ self._port_spin.setValue(DEFAULT_BROKER_PORT)
116
+ form.addRow("Port:", self._port_spin)
117
+
118
+ self._status_topic_edit = QLineEdit()
119
+ self._status_topic_edit.setPlaceholderText(DEFAULT_STATUS_TOPIC)
120
+ self._status_topic_edit.setToolTip(
121
+ "Topic on which Vizia devices publish status packets.\n"
122
+ "Default: vizia/status"
123
+ )
124
+ form.addRow("Status topic:", self._status_topic_edit)
125
+
126
+ self._cmd_suffix_edit = QLineEdit()
127
+ self._cmd_suffix_edit.setPlaceholderText(DEFAULT_COMMAND_TOPIC_SUFFIX)
128
+ self._cmd_suffix_edit.setToolTip(
129
+ "Suffix appended after {recorderId}/ when publishing commands.\n"
130
+ "Default: control/"
131
+ )
132
+ form.addRow("Command suffix:", self._cmd_suffix_edit)
133
+
134
+ btn_row = QHBoxLayout()
135
+ self._connect_btn = QPushButton("Connect")
136
+ self._connect_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
137
+ self._connect_btn.clicked.connect(self._on_connect_clicked)
138
+ self._disconnect_btn = QPushButton("Disconnect")
139
+ self._disconnect_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
140
+ self._disconnect_btn.setEnabled(False)
141
+ self._disconnect_btn.clicked.connect(self._on_disconnect_clicked)
142
+ btn_row.addWidget(self._connect_btn)
143
+ btn_row.addWidget(self._disconnect_btn)
144
+
145
+ self._status_label = QLabel("Not connected")
146
+ self._status_label.setProperty("role", "hint")
147
+
148
+ form.addRow(btn_row)
149
+ form.addRow("Status:", self._status_label)
150
+
151
+ return group
152
+
153
+ def _build_devices_group(self) -> QGroupBox:
154
+ group = QGroupBox("Discovered devices")
155
+ col = QVBoxLayout(group)
156
+
157
+ hint = QLabel(
158
+ "Devices are discovered automatically from incoming status packets.\n"
159
+ "Select a target below before sending commands."
160
+ )
161
+ hint.setWordWrap(True)
162
+ hint.setProperty("role", "hint")
163
+ col.addWidget(hint)
164
+
165
+ self._device_combo = QComboBox()
166
+ self._device_combo.addItem("All devices", TARGET_ALL)
167
+ self._device_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
168
+ col.addWidget(self._device_combo)
169
+
170
+ self._device_status_label = QLabel("")
171
+ self._device_status_label.setProperty("role", "hint")
172
+ self._device_status_label.setWordWrap(True)
173
+ col.addWidget(self._device_status_label)
174
+
175
+ return group
176
+
177
+ def _build_commands_group(self) -> QGroupBox:
178
+ group = QGroupBox("Commands")
179
+ col = QVBoxLayout(group)
180
+
181
+ rec_row = QHBoxLayout()
182
+ self._start_btn = QPushButton("Start recording")
183
+ self._start_btn.setEnabled(False)
184
+ self._start_btn.clicked.connect(self._on_start_recording)
185
+ self._stop_btn = QPushButton("Stop recording")
186
+ self._stop_btn.setEnabled(False)
187
+ self._stop_btn.clicked.connect(self._on_stop_recording)
188
+ rec_row.addWidget(self._start_btn)
189
+ rec_row.addWidget(self._stop_btn)
190
+ col.addLayout(rec_row)
191
+
192
+ col.addSpacing(4)
193
+
194
+ tag_form = QFormLayout()
195
+ self._tag_code_edit = QLineEdit()
196
+ self._tag_code_edit.setPlaceholderText("e.g. STIMULUS_ON")
197
+ tag_form.addRow("Tag code:", self._tag_code_edit)
198
+
199
+ self._tag_values_edit = QLineEdit()
200
+ self._tag_values_edit.setPlaceholderText("optional detail string")
201
+ tag_form.addRow("Tag values:", self._tag_values_edit)
202
+ col.addLayout(tag_form)
203
+
204
+ self._tag_btn = QPushButton("Send tag")
205
+ self._tag_btn.setEnabled(False)
206
+ self._tag_btn.clicked.connect(self._on_send_tag)
207
+ col.addWidget(self._tag_btn)
208
+
209
+ return group
210
+
211
+ def _build_log_group(self) -> QGroupBox:
212
+ group = QGroupBox("Connection log")
213
+ col = QVBoxLayout(group)
214
+
215
+ self._log_view = QPlainTextEdit()
216
+ self._log_view.setReadOnly(True)
217
+ self._log_view.setMaximumBlockCount(500)
218
+ font = QFont("Monospace")
219
+ font.setStyleHint(QFont.StyleHint.TypeWriter)
220
+ font.setPointSize(9)
221
+ self._log_view.setFont(font)
222
+ col.addWidget(self._log_view)
223
+
224
+ clear_btn = QPushButton("Clear log")
225
+ clear_btn.setProperty("secondary", True)
226
+ clear_btn.clicked.connect(self._log_view.clear)
227
+ col.addWidget(clear_btn)
228
+
229
+ return group
230
+
231
+ # ── Settings persistence ─────────────────────────────────────────────
232
+
233
+ def _load_settings(self) -> None:
234
+ s = QSettings(_SETTINGS_ORG, _SETTINGS_APP)
235
+ self._host_edit.setText(s.value(_KEY_HOST, "", type=str))
236
+ self._port_spin.setValue(int(s.value(_KEY_PORT, DEFAULT_BROKER_PORT)))
237
+ self._status_topic_edit.setText(s.value(_KEY_STATUS_TOPIC, "", type=str))
238
+ self._cmd_suffix_edit.setText(s.value(_KEY_CMD_SUFFIX, "", type=str))
239
+
240
+ def _save_settings(self) -> None:
241
+ s = QSettings(_SETTINGS_ORG, _SETTINGS_APP)
242
+ s.setValue(_KEY_HOST, self._host_edit.text().strip())
243
+ s.setValue(_KEY_PORT, self._port_spin.value())
244
+ s.setValue(_KEY_STATUS_TOPIC, self._status_topic_edit.text().strip())
245
+ s.setValue(_KEY_CMD_SUFFIX, self._cmd_suffix_edit.text().strip())
246
+
247
+ # ── Connection actions ───────────────────────────────────────────────
248
+
249
+ def _on_connect_clicked(self) -> None:
250
+ host = self._host_edit.text().strip()
251
+ if not host:
252
+ QMessageBox.warning(self, "Host required", "Enter the broker IP address or hostname.")
253
+ return
254
+
255
+ port = self._port_spin.value()
256
+ status_topic = self._status_topic_edit.text().strip() or DEFAULT_STATUS_TOPIC
257
+ cmd_suffix = self._cmd_suffix_edit.text().strip() or DEFAULT_COMMAND_TOPIC_SUFFIX
258
+
259
+ self._save_settings()
260
+
261
+ if self._controller is not None:
262
+ self._controller.disconnect()
263
+ self._controller = None
264
+
265
+ self._device_combo.clear()
266
+ self._device_combo.addItem("All devices", TARGET_ALL)
267
+ self._device_status_label.setText("")
268
+
269
+ self._controller = MqttController(
270
+ broker_host=host,
271
+ broker_port=port,
272
+ status_topic=status_topic,
273
+ command_topic_suffix=cmd_suffix,
274
+ on_log=self._bridge.log_line.emit,
275
+ on_device_update=self._bridge.device_updated.emit,
276
+ on_connected=self._bridge.connected.emit,
277
+ on_disconnected=self._bridge.disconnected.emit,
278
+ )
279
+
280
+ self._connect_btn.setEnabled(False)
281
+ self._status_label.setText("Connecting …")
282
+ self._controller.connect()
283
+
284
+ def _on_disconnect_clicked(self) -> None:
285
+ if self._controller is not None:
286
+ self._controller.disconnect()
287
+ self._controller = None
288
+ self._set_disconnected_state()
289
+
290
+ # ── Bridge callbacks (GUI thread) ────────────────────────────────────
291
+
292
+ def _on_connected(self) -> None:
293
+ self._disconnect_btn.setEnabled(True)
294
+ self._connect_btn.setEnabled(False)
295
+ self._start_btn.setEnabled(True)
296
+ self._stop_btn.setEnabled(True)
297
+ self._tag_btn.setEnabled(True)
298
+ self._status_label.setText("Connected")
299
+
300
+ def _on_disconnected(self) -> None:
301
+ self._set_disconnected_state()
302
+
303
+ def _set_disconnected_state(self) -> None:
304
+ self._connect_btn.setEnabled(True)
305
+ self._disconnect_btn.setEnabled(False)
306
+ self._start_btn.setEnabled(False)
307
+ self._stop_btn.setEnabled(False)
308
+ self._tag_btn.setEnabled(False)
309
+ self._status_label.setText("Not connected")
310
+
311
+ def _on_device_updated(self, status: DeviceStatus) -> None:
312
+ device_id = status.device_id
313
+
314
+ found = False
315
+ for i in range(self._device_combo.count()):
316
+ if self._device_combo.itemData(i) == device_id:
317
+ self._device_combo.setItemText(
318
+ i,
319
+ f"{device_id} (recording={'yes' if status.recording else 'no'}, battery={status.battery}%)",
320
+ )
321
+ found = True
322
+ break
323
+
324
+ if not found:
325
+ self._device_combo.addItem(
326
+ f"{device_id} (recording={'yes' if status.recording else 'no'}, battery={status.battery}%)",
327
+ device_id,
328
+ )
329
+
330
+ self._refresh_device_status_label()
331
+
332
+ def _refresh_device_status_label(self) -> None:
333
+ if self._controller is None:
334
+ self._device_status_label.setText("")
335
+ return
336
+ devices = self._controller.discovered_devices
337
+ if not devices:
338
+ self._device_status_label.setText("No devices seen yet.")
339
+ else:
340
+ lines = [
341
+ f"• {d.device_id}: {'● recording' if d.recording else '○ idle'}, battery {d.battery}%"
342
+ for d in devices
343
+ ]
344
+ self._device_status_label.setText("\n".join(lines))
345
+
346
+ # ── Command actions ──────────────────────────────────────────────────
347
+
348
+ def _selected_device_id(self) -> str:
349
+ data = self._device_combo.currentData()
350
+ return data if data is not None else TARGET_ALL
351
+
352
+ def _on_start_recording(self) -> None:
353
+ if self._controller is None:
354
+ return
355
+ self._controller.send_start_recording(self._selected_device_id())
356
+
357
+ def _on_stop_recording(self) -> None:
358
+ if self._controller is None:
359
+ return
360
+ self._controller.send_stop_recording(self._selected_device_id())
361
+
362
+ def _on_send_tag(self) -> None:
363
+ if self._controller is None:
364
+ return
365
+ code = self._tag_code_edit.text().strip()
366
+ if not code:
367
+ QMessageBox.warning(self, "Tag code required", "Enter a tag code before sending.")
368
+ return
369
+ values = self._tag_values_edit.text().strip()
370
+ self._controller.send_tag(code, values, self._selected_device_id())
371
+
372
+ # ── Log ──────────────────────────────────────────────────────────────
373
+
374
+ def _append_log(self, msg: str) -> None:
375
+ self._log_view.appendPlainText(msg)
376
+ self._log_view.verticalScrollBar().setValue(
377
+ self._log_view.verticalScrollBar().maximum()
378
+ )
379
+
380
+ # ── Navigation ───────────────────────────────────────────────────────
381
+
382
+ def can_leave(self) -> bool:
383
+ """Called by the main window before another page is shown."""
384
+ if self._controller is not None and self._controller.is_connected:
385
+ answer = QMessageBox.question(
386
+ self,
387
+ "Disconnect?",
388
+ "You are connected to a broker. Disconnect and leave this page?",
389
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
390
+ )
391
+ if answer != QMessageBox.StandardButton.Yes:
392
+ return False
393
+ self._on_disconnect_clicked()
394
+ return True