totalsync-webinterface 0.2.0__tar.gz → 0.3.0__tar.gz

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 (35) hide show
  1. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/PKG-INFO +4 -3
  2. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/README.md +2 -2
  3. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/pyproject.toml +4 -4
  4. totalsync_webinterface-0.3.0/totalsync_webinterface/control_window.py +303 -0
  5. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/serial_dump.py +1 -1
  6. totalsync_webinterface-0.3.0/totalsync_webinterface/teensy_commander.py +355 -0
  7. totalsync_webinterface-0.2.0/totalsync_webinterface/teensy_commander.py +0 -585
  8. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/.gitignore +0 -0
  9. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/.hgignore +0 -0
  10. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/LICENSE +0 -0
  11. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/__init__.py +0 -0
  12. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/camera.py +0 -0
  13. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/curses_interface.py +0 -0
  14. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/fake_picamera.py +0 -0
  15. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/packet.py +0 -0
  16. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/pin_sheet.py +0 -0
  17. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/serial_dummy.py +0 -0
  18. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web/CanvasCamera.js +0 -0
  19. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web/CanvasPlot.js +0 -0
  20. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web/favicon.ico +0 -0
  21. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web/index.html +0 -0
  22. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web/interface.js +0 -0
  23. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web/reconnecting-websocket/LICENSE.txt +0 -0
  24. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web/reconnecting-websocket/README.md +0 -0
  25. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web/reconnecting-websocket/package.json +0 -0
  26. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web/reconnecting-websocket/reconnecting-websocket.js +0 -0
  27. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web/style.css +0 -0
  28. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web/webgl-plot/LICENSE +0 -0
  29. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web/webgl-plot/README.md +0 -0
  30. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web/webgl-plot/dist/webglplot.esm.js +0 -0
  31. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web/webgl-plot/package.json +0 -0
  32. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/web_interface.py +0 -0
  33. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/websocket_server/LICENSE +0 -0
  34. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/websocket_server/__init__.py +0 -0
  35. {totalsync_webinterface-0.2.0 → totalsync_webinterface-0.3.0}/totalsync_webinterface/websocket_server/websocket_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: totalsync-webinterface
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: TotalSync acquisition: serial reader, live browser interface and recorder
5
5
  Project-URL: Homepage, https://github.com/NeuroNetMem/totalsync
6
6
  Project-URL: Repository, https://github.com/NeuroNetMem/totalsync
@@ -18,6 +18,7 @@ Requires-Python: >=3.11
18
18
  Requires-Dist: cobs
19
19
  Requires-Dist: numpy
20
20
  Requires-Dist: pyserial
21
+ Requires-Dist: pyside6-essentials>=6.8
21
22
  Requires-Dist: pyzmq
22
23
  Requires-Dist: windows-curses; platform_system == 'Windows'
23
24
  Provides-Extra: extras
@@ -41,5 +42,5 @@ totalsync --pinsheet docs/pinSheet_2026.json
41
42
  totalsync -D # simulated Teensy, no hardware needed
42
43
  ```
43
44
 
44
- Needs `tkinter` for its startup dialogs, which is stdlib but not pip-installable — see
45
- the installation guide if your Python build lacks it.
45
+ The control window is Qt, through `PySide6-Essentials`, which is installed from PyPI as
46
+ a regular dependency. The live data view stays in the browser.
@@ -14,5 +14,5 @@ totalsync --pinsheet docs/pinSheet_2026.json
14
14
  totalsync -D # simulated Teensy, no hardware needed
15
15
  ```
16
16
 
17
- Needs `tkinter` for its startup dialogs, which is stdlib but not pip-installable — see
18
- the installation guide if your Python build lacks it.
17
+ The control window is Qt, through `PySide6-Essentials`, which is installed from PyPI as
18
+ a regular dependency. The live data view stays in the browser.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "totalsync-webinterface"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "TotalSync acquisition: serial reader, live browser interface and recorder"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -23,6 +23,9 @@ dependencies = [
23
23
  "pyzmq",
24
24
  # On Linux/macOS `curses` is in the stdlib; only Windows needs the backport.
25
25
  'windows-curses ; platform_system == "Windows"',
26
+ # The control window. -Essentials is the QtWidgets/QtGui/QtCore subset of PySide6,
27
+ # without the add-on modules that make the full wheel several times larger.
28
+ "PySide6-Essentials>=6.8",
26
29
  ]
27
30
 
28
31
  [project.optional-dependencies]
@@ -30,9 +33,6 @@ extras = [
30
33
  "pyglet",
31
34
  "sounddevice",
32
35
  ]
33
- # NOTE: the GUI also needs `tkinter`, which is part of the stdlib but is *not*
34
- # pip-installable. It ships with the python.org and conda builds; with Homebrew
35
- # python you need `brew install python-tk`, on Debian/Ubuntu `apt install python3-tk`.
36
36
  # `picamera` is Raspberry Pi only and is imported behind a try/except in camera.py.
37
37
 
38
38
  [project.scripts]
@@ -0,0 +1,303 @@
1
+ """The control window of the `totalsync` command.
2
+
3
+ One window for the whole session: pick the serial port and the output directory, press
4
+ Play, and from then on Reset, Reload and Quit, with the packet counters in the status bar
5
+ and the address of the browser interface ready to be copied. The data itself is only
6
+ ever shown in the browser.
7
+ """
8
+
9
+ import logging
10
+ import os
11
+ import time
12
+ from pathlib import Path
13
+
14
+ import serial.tools.list_ports
15
+ import zmq
16
+ from PySide6.QtCore import Qt, QTimer
17
+ from PySide6.QtGui import QGuiApplication
18
+ from PySide6.QtWidgets import (QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel,
19
+ QLineEdit, QComboBox, QMainWindow, QMessageBox, QPushButton,
20
+ QSizePolicy, QVBoxLayout, QWidget)
21
+
22
+ from .teensy_commander import (DEFAULT_SERIAL_PORT, WEB_URL, SerialPortError, TeensyCommander,
23
+ open_web_interface, prepare_output_directory)
24
+
25
+ # How often the status bar is refreshed, and the window through which "nothing received"
26
+ # is judged: a port that stays silent for one whole tick is reported.
27
+ STATUS_INTERVAL_MS = 1000
28
+
29
+
30
+ def default_output_directory():
31
+ """Where a session is written unless the user says otherwise.
32
+
33
+ The working directory is where the user cd'd to before running the command, so it is
34
+ the best guess available; a Finder or desktop launch, where the working directory is
35
+ '/', is not - nobody wants to record into '/' and it is not writable anyway - hence the
36
+ fall back to $HOME.
37
+ """
38
+ start = Path.cwd()
39
+ if start == Path(start.anchor) or not os.access(start, os.W_OK):
40
+ start = Path.home()
41
+ return start
42
+
43
+
44
+ class ControlWindow(QMainWindow):
45
+ """The session window. Idle until Play, then running until it is closed.
46
+
47
+ ``commander`` is None until Play succeeds; whoever runs the event loop has to shut
48
+ it down once the window is gone (see teensy_commander.run_gui).
49
+ """
50
+
51
+ def __init__(self, cli_args, channel_labels=None, curses_screen=None):
52
+ super().__init__()
53
+ self.cli_args = cli_args
54
+ self.channel_labels = channel_labels
55
+ self.curses_screen = curses_screen
56
+ self.commander = None
57
+ # Raw and decoded packet counts, and the time, at the previous status tick. The
58
+ # rate shown is worked out from these rather than taken from the commander, whose
59
+ # packets_per_second is only recalculated when a packet arrives and so stays at
60
+ # its last value, however high, for as long as the port is silent.
61
+ self.last_count = 0
62
+ self.last_decoded = 0
63
+ self.last_tick = 0.0
64
+ # When the port went quiet, or None while packets are arriving. Silence is a
65
+ # state: it is logged once when it starts and once when it ends, not every tick.
66
+ self.silent_since = None
67
+ # Set once the user has confirmed (or a confirmation is not wanted), so that
68
+ # closeEvent does not ask a second time.
69
+ self.quit_confirmed = False
70
+
71
+ self.setWindowTitle('TotalSync')
72
+ self.setMinimumWidth(560)
73
+
74
+ # --- Session: where the data comes from and where it goes.
75
+ self.port_combo = QComboBox()
76
+ # Editable, so that a port the enumeration misses can still be typed in.
77
+ self.port_combo.setEditable(True)
78
+ self.port_combo.setMinimumContentsLength(24)
79
+ refresh_button = QPushButton('Refresh')
80
+ refresh_button.setToolTip('Look for serial ports again')
81
+ refresh_button.clicked.connect(self.refresh_ports)
82
+ port_row = QHBoxLayout()
83
+ port_row.addWidget(self.port_combo, 1)
84
+ port_row.addWidget(refresh_button)
85
+
86
+ self.dir_edit = QLineEdit()
87
+ self.dir_edit.textChanged.connect(self.update_buttons)
88
+ browse_button = QPushButton('Browse…')
89
+ browse_button.clicked.connect(self.browse_output_directory)
90
+ dir_row = QHBoxLayout()
91
+ dir_row.addWidget(self.dir_edit, 1)
92
+ dir_row.addWidget(browse_button)
93
+
94
+ self.session_box = QGroupBox('Session')
95
+ form = QFormLayout(self.session_box)
96
+ form.addRow('Serial port:', port_row)
97
+ form.addRow('Output directory:', dir_row)
98
+
99
+ # --- Data logger: the address of the browser interface.
100
+ url_edit = QLineEdit(WEB_URL)
101
+ url_edit.setReadOnly(True)
102
+ copy_button = QPushButton('Copy')
103
+ copy_button.setToolTip('Copy the address to the clipboard')
104
+ copy_button.clicked.connect(self.copy_url)
105
+ url_box = QGroupBox('Data logger (open in a browser)')
106
+ url_row = QHBoxLayout(url_box)
107
+ url_row.addWidget(url_edit, 1)
108
+ url_row.addWidget(copy_button)
109
+
110
+ # --- Actions. Same behaviour as the buttons of the old dialogs.
111
+ self.play_button = QPushButton('Play')
112
+ self.play_button.setDefault(True)
113
+ self.play_button.clicked.connect(self.play)
114
+ self.reset_button = QPushButton('Reset')
115
+ self.reset_button.setToolTip('Reset the Teensy and reopen the browser interface')
116
+ self.reset_button.clicked.connect(self.reset)
117
+ self.reload_button = QPushButton('Reload')
118
+ self.reload_button.setToolTip('Reopen the browser interface')
119
+ self.reload_button.clicked.connect(open_web_interface)
120
+ quit_button = QPushButton('Quit')
121
+ quit_button.clicked.connect(self.close)
122
+ buttons = QHBoxLayout()
123
+ for button in (self.play_button, self.reset_button, self.reload_button):
124
+ buttons.addWidget(button)
125
+ buttons.addStretch(1)
126
+ buttons.addWidget(quit_button)
127
+
128
+ central = QWidget()
129
+ layout = QVBoxLayout(central)
130
+ layout.addWidget(self.session_box)
131
+ layout.addWidget(url_box)
132
+ layout.addLayout(buttons)
133
+ self.setCentralWidget(central)
134
+
135
+ # A normal (not permanent) widget, so that a temporary showMessage() such as
136
+ # "URL copied" covers it for a moment and then gives it back.
137
+ self.status_label = QLabel('Not running')
138
+ # Two lines of whatever width the window has: Ignored, so that a long directory
139
+ # cannot set the minimum width of the whole window, which it otherwise would.
140
+ self.status_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
141
+ self.statusBar().addWidget(self.status_label, 1)
142
+
143
+ self.status_timer = QTimer(self)
144
+ self.status_timer.setInterval(STATUS_INTERVAL_MS)
145
+ self.status_timer.timeout.connect(self.update_status)
146
+
147
+ self.refresh_ports()
148
+ if cli_args.serial_port:
149
+ self.port_combo.setEditText(cli_args.serial_port)
150
+ self.dir_edit.setText(str(cli_args.output_dir or default_output_directory()))
151
+ self.update_buttons()
152
+
153
+ # --- setup state
154
+
155
+ def refresh_ports(self):
156
+ """Fill the port list from the ports present now, keeping what was typed."""
157
+ current = self.port_combo.currentText()
158
+ ports = sorted(comport.device for comport in serial.tools.list_ports.comports())
159
+ logging.info('Known serial ports: ' + repr(ports))
160
+ self.port_combo.clear()
161
+ self.port_combo.addItems(ports)
162
+ self.port_combo.lineEdit().setPlaceholderText(
163
+ 'Serial port' if ports else 'No serial ports found')
164
+ if current or not ports:
165
+ self.port_combo.setEditText(current)
166
+
167
+ def browse_output_directory(self):
168
+ start = self.dir_edit.text() or str(default_output_directory())
169
+ directory = QFileDialog.getExistingDirectory(
170
+ self, 'Where should TotalSync write this session?', start)
171
+ if directory:
172
+ self.dir_edit.setText(directory)
173
+
174
+ def update_buttons(self):
175
+ running = self.commander is not None
176
+ self.session_box.setEnabled(not running)
177
+ self.play_button.setEnabled(not running and bool(self.dir_edit.text().strip()))
178
+ self.reset_button.setEnabled(running)
179
+ self.reload_button.setEnabled(running)
180
+
181
+ def play(self):
182
+ """Start the session. On any failure say why and stay in the setup state."""
183
+ if self.commander is not None:
184
+ return
185
+ try:
186
+ output_dir = prepare_output_directory(self.dir_edit.text().strip())
187
+ except ValueError as exc:
188
+ QMessageBox.warning(self, 'Output directory', f'Cannot record into that directory: {exc}')
189
+ return
190
+
191
+ port = self.port_combo.currentText().strip() or DEFAULT_SERIAL_PORT
192
+ args = self.cli_args
193
+ logging.info(f'Launching Teensy Commander on serial port {port} and the web interface '
194
+ f'on ports HTTP:{args.http_port} and WS:{args.ws_port}')
195
+ try:
196
+ commander = TeensyCommander(serial_port=port,
197
+ http_port=args.http_port,
198
+ ws_port=args.ws_port,
199
+ curses_screen=self.curses_screen,
200
+ output_dir=output_dir,
201
+ write_bin=args.binfile,
202
+ use_dummy=args.dummy,
203
+ channel_labels=self.channel_labels)
204
+ except SerialPortError as exc:
205
+ QMessageBox.warning(self, 'Serial port', f'{exc}\n\nPick another port and press Play again.')
206
+ return
207
+ except (OSError, zmq.ZMQError) as exc:
208
+ # Typically another totalsync still holding the HTTP, WebSocket or ZMQ ports.
209
+ QMessageBox.critical(self, 'Cannot start', f'Could not start the servers: {exc}')
210
+ return
211
+
212
+ commander.start()
213
+ self.commander = commander
214
+ logging.info(f'Recording into {output_dir}')
215
+ # Only now: reaching this line means the servers are listening, so the page will
216
+ # load rather than greet the user with "unable to connect".
217
+ if not args.no_browser:
218
+ open_web_interface()
219
+
220
+ self.last_count = 0
221
+ self.last_decoded = 0
222
+ self.last_tick = time.monotonic()
223
+ self.silent_since = None
224
+ self.update_buttons()
225
+ self.update_status()
226
+ self.status_timer.start()
227
+
228
+ # --- running state
229
+
230
+ def reset(self):
231
+ self.commander.reset_packet()
232
+ open_web_interface()
233
+
234
+ def copy_url(self):
235
+ QGuiApplication.clipboard().setText(WEB_URL)
236
+ self.statusBar().showMessage(f'Copied {WEB_URL}', 2000)
237
+
238
+ def update_status(self):
239
+ """Show the packet counters; also the session's periodic health check."""
240
+ commander = self.commander
241
+ if not commander.alive:
242
+ self.force_quit()
243
+ return
244
+
245
+ now = time.monotonic()
246
+ received = commander.serial_dump.n_raw_packets
247
+ decoded = commander.n_packet
248
+ new = received - self.last_count
249
+ elapsed = now - self.last_tick
250
+ rate = (decoded - self.last_decoded) / elapsed if elapsed > 0 else 0.0
251
+ self.last_count, self.last_decoded, self.last_tick = received, decoded, now
252
+
253
+ counters = f'{received} received (+{new}) · {decoded} decoded · {rate:.1f}/s'
254
+ if new:
255
+ if self.silent_since is not None:
256
+ logging.info(f'Serial packets arriving again after '
257
+ f'{now - self.silent_since:.0f} s')
258
+ self.silent_since = None
259
+ self.status_label.setStyleSheet('')
260
+ logging.debug(f'Serial packets: {counters}')
261
+ else:
262
+ if self.silent_since is None:
263
+ # A silent port is the failure this counter exists to make visible, so it
264
+ # goes to the log at a level that survives the default verbosity - once.
265
+ # Silent since the previous tick, the last time anything was seen.
266
+ self.silent_since = now - elapsed
267
+ self.status_label.setStyleSheet('color: #c62828; font-weight: bold;')
268
+ logging.warning(f'Serial packets: {received} received, {decoded} decoded'
269
+ ' - nothing received since the last check!')
270
+ counters = (f'nothing received for {now - self.silent_since:.0f} s · '
271
+ f'{received} received · {decoded} decoded · {rate:.1f}/s')
272
+
273
+ # Shortened in the middle to what fits, so both ends of the path stay readable;
274
+ # the tooltip has it in full.
275
+ directory = str(commander.output_dir)
276
+ self.status_label.setToolTip(directory)
277
+ prefix = 'Recording to '
278
+ metrics = self.status_label.fontMetrics()
279
+ room = max(self.status_label.width() - metrics.horizontalAdvance(prefix), 100)
280
+ directory = metrics.elidedText(directory, Qt.TextElideMode.ElideMiddle, room)
281
+ self.status_label.setText(f'{commander.serial_port} · {counters}\n{prefix}{directory}')
282
+
283
+ shell_gui = commander.shell_gui
284
+ if shell_gui and shell_gui.alive and not shell_gui.is_alive():
285
+ logging.critical("Shell GUI died!")
286
+ # TODO: attempt to restart the shell GUI
287
+
288
+ # --- quitting
289
+
290
+ def force_quit(self):
291
+ """Close without asking: Ctrl-C in the terminal, or the session ended by itself."""
292
+ self.quit_confirmed = True
293
+ self.close()
294
+
295
+ def closeEvent(self, event):
296
+ if not self.quit_confirmed:
297
+ answer = QMessageBox.question(self, 'Quit', 'Really quit TotalSync?')
298
+ if answer != QMessageBox.StandardButton.Yes:
299
+ event.ignore()
300
+ return
301
+ self.quit_confirmed = True
302
+ self.status_timer.stop()
303
+ event.accept()
@@ -12,7 +12,7 @@ class SerialDump:
12
12
  without a GUI and -- worse -- that the dialog appeared at whatever point in startup
13
13
  this object happened to be constructed, which was after the browser had already been
14
14
  pointed at a server that was not listening yet. Choosing the directory is a job for
15
- whoever is talking to the user; see ``choose_output_directory`` in teensy_commander.
15
+ whoever is talking to the user; see ``ControlWindow`` in control_window.
16
16
  """
17
17
 
18
18
  def __init__(self, output_dir):