totalsync-webinterface 0.1.1__tar.gz → 0.1.2__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 (34) hide show
  1. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/.gitignore +43 -20
  2. totalsync_webinterface-0.1.2/.hgignore +7 -0
  3. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/PKG-INFO +6 -2
  4. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/README.md +1 -1
  5. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/pyproject.toml +7 -1
  6. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/serial_dump.py +13 -12
  7. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/teensy_commander.py +105 -10
  8. totalsync_webinterface-0.1.1/.hgignore +0 -17
  9. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/LICENSE +0 -0
  10. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/__init__.py +0 -0
  11. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/camera.py +0 -0
  12. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/curses_interface.py +0 -0
  13. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/fake_picamera.py +0 -0
  14. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/packet.py +0 -0
  15. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/pin_sheet.py +0 -0
  16. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/serial_dummy.py +0 -0
  17. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web/CanvasCamera.js +0 -0
  18. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web/CanvasPlot.js +0 -0
  19. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web/favicon.ico +0 -0
  20. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web/index.html +0 -0
  21. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web/interface.js +0 -0
  22. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web/reconnecting-websocket/LICENSE.txt +0 -0
  23. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web/reconnecting-websocket/README.md +0 -0
  24. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web/reconnecting-websocket/package.json +0 -0
  25. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web/reconnecting-websocket/reconnecting-websocket.js +0 -0
  26. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web/style.css +0 -0
  27. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web/webgl-plot/LICENSE +0 -0
  28. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web/webgl-plot/README.md +0 -0
  29. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web/webgl-plot/dist/webglplot.esm.js +0 -0
  30. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web/webgl-plot/package.json +0 -0
  31. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/web_interface.py +0 -0
  32. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/websocket_server/LICENSE +0 -0
  33. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/websocket_server/__init__.py +0 -0
  34. {totalsync_webinterface-0.1.1 → totalsync_webinterface-0.1.2}/totalsync_webinterface/websocket_server/websocket_server.py +0 -0
@@ -68,7 +68,6 @@ LogFile/
68
68
  *.elf
69
69
  *.bin
70
70
  .build/
71
- Teensy41_Totalsync/build/
72
71
 
73
72
  # ---------------------------------------------------------------------------
74
73
  # Node
@@ -84,28 +83,49 @@ yarn-error.log*
84
83
  # ---------------------------------------------------------------------------
85
84
  # JetBrains IDEs (PyCharm, CLion)
86
85
  #
87
- # Listed file by file rather than as a blanket `.idea/`, because the shared
88
- # settings under .idea/inspectionProfiles/ and .idea/vcs.xml are checked in.
89
- # ---------------------------------------------------------------------------
90
- .idea/workspace.xml
91
- .idea/tasks.xml
92
- .idea/usage.statistics.xml
93
- .idea/dictionaries/
94
- .idea/shelf/
95
- .idea/httpRequests/
96
- .idea/modules.xml
97
- .idea/misc.xml
98
- .idea/*.iml
99
- .idea/modules/
100
- .idea/**/dataSources/
101
- .idea/**/dataSources.ids
102
- .idea/**/dataSources.local.xml
103
- .idea/**/sqlDataSources.xml
104
- .idea/**/dynamic.xml
86
+ # Two projects live in this repo: PyCharm at the root and CLion at firmware/.
87
+ # Every pattern is therefore written `**/.idea/...`, not `.idea/...`: a pattern
88
+ # containing a slash anywhere but at the end is anchored to the directory of
89
+ # the .gitignore that holds it, so the plain form covered the root project only
90
+ # and left firmware/.idea/ exposed.
91
+ #
92
+ # Listed file by file rather than as a blanket `.idea/`, because two things in
93
+ # there are deliberately shared: inspectionProfiles/ (the lint settings the
94
+ # project agrees on) and vcs.xml (the Git root mapping — firmware/.idea/vcs.xml
95
+ # is what points CLion at the repository one level up).
96
+ # ---------------------------------------------------------------------------
97
+ **/.idea/workspace.xml
98
+ **/.idea/tasks.xml
99
+ **/.idea/usage.statistics.xml
100
+ **/.idea/dictionaries/
101
+ **/.idea/shelf/
102
+ **/.idea/httpRequests/
103
+ **/.idea/queries/
104
+ **/.idea/modules.xml
105
+ **/.idea/modules/
106
+ # The IDE writes its own .idea/.gitignore at project creation; this file
107
+ # supersedes it, and having both means the two can disagree.
108
+ **/.idea/.gitignore
109
+ # Machine-local: PyCharm keeps the interpreter path in misc.xml, CLion the
110
+ # PlatformIO linkage; deployment.xml holds SSH targets; editor.xml the CLion
111
+ # inspection severities. All four are regenerated by the IDE.
112
+ **/.idea/misc.xml
113
+ **/.idea/deployment.xml
114
+ **/.idea/editor.xml
115
+ **/.idea/webResources.xml
116
+ **/.idea/**/dataSources/
117
+ **/.idea/**/dataSources.ids
118
+ **/.idea/**/dataSources.local.xml
119
+ **/.idea/**/sqlDataSources.xml
120
+ **/.idea/**/dynamic.xml
121
+ # Module files, anywhere — covers **/.idea/*.iml too.
105
122
  *.iml
106
123
  *.iws
107
124
  cmake-build-*/
108
125
 
126
+ # JetBrains AI Assistant / MCP scratch state, written per developer.
127
+ **/.ai/
128
+
109
129
  # ---------------------------------------------------------------------------
110
130
  # Other editors
111
131
  # ---------------------------------------------------------------------------
@@ -138,9 +158,12 @@ $RECYCLE.BIN/
138
158
  .directory
139
159
  .fuse_hidden*
140
160
  .nfs*
141
- /.idea/webResources.xml
142
161
 
143
162
  # ---------------------------------------------------------------------------
144
163
  # Sphinx / ReadTheDocs
145
164
  # ---------------------------------------------------------------------------
146
165
  docs/_build/
166
+
167
+ # Staged by packages/totalsync_utils/hatch_build.py from firmware/ at build time;
168
+ # the tree at firmware/ is the source of truth.
169
+ /packages/totalsync_utils/totalsync_utils/data/firmware/
@@ -0,0 +1,7 @@
1
+ # This repository is git-only. The file exists to stop hatchling's search for an
2
+ # hgignore -- which walks up from the project root and only stops at a .hg directory --
3
+ # from running past the repository into the home directory of whoever runs the build and
4
+ # force-including *that* file into every source distribution.
5
+ #
6
+ # Keep it empty of patterns: .gitignore is the one that matters.
7
+ syntax: glob
@@ -1,7 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: totalsync-webinterface
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: TotalSync acquisition: serial reader, live browser interface and recorder
5
+ Project-URL: Homepage, https://github.com/NeuroNetMem/totalsync
6
+ Project-URL: Repository, https://github.com/NeuroNetMem/totalsync
7
+ Project-URL: Documentation, https://totalsync.readthedocs.io
8
+ Project-URL: Issues, https://github.com/NeuroNetMem/totalsync/issues
5
9
  Author: Ronny Eichler
6
10
  Author-email: Morgane Audrain <morganeaudrain@gmail.com>
7
11
  License-Expression: GPL-3.0-or-later
@@ -23,7 +27,7 @@ Description-Content-Type: text/markdown
23
27
 
24
28
  # totalsync-webinterface
25
29
 
26
- Acquisition side of [TotalSync](https://github.com/fpbattaglia/totalsync): reads packets from a
30
+ Acquisition side of [TotalSync](https://github.com/NeuroNetMem/totalsync): reads packets from a
27
31
  Teensy 4.1 running the TotalSync firmware over USB serial, records them, and serves a
28
32
  live view of all 42 digital/analog channels plus 8 state variables to the browser at
29
33
  1 kHz.
@@ -1,6 +1,6 @@
1
1
  # totalsync-webinterface
2
2
 
3
- Acquisition side of [TotalSync](https://github.com/fpbattaglia/totalsync): reads packets from a
3
+ Acquisition side of [TotalSync](https://github.com/NeuroNetMem/totalsync): reads packets from a
4
4
  Teensy 4.1 running the TotalSync firmware over USB serial, records them, and serves a
5
5
  live view of all 42 digital/analog channels plus 8 state variables to the browser at
6
6
  1 kHz.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "totalsync-webinterface"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "TotalSync acquisition: serial reader, live browser interface and recorder"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -38,6 +38,12 @@ extras = [
38
38
  [project.scripts]
39
39
  totalsync = "totalsync_webinterface.teensy_commander:cli_entry"
40
40
 
41
+ [project.urls]
42
+ Homepage = "https://github.com/NeuroNetMem/totalsync"
43
+ Repository = "https://github.com/NeuroNetMem/totalsync"
44
+ Documentation = "https://totalsync.readthedocs.io"
45
+ Issues = "https://github.com/NeuroNetMem/totalsync/issues"
46
+
41
47
  [build-system]
42
48
  requires = ["hatchling"]
43
49
  build-backend = "hatchling.build"
@@ -2,22 +2,23 @@ import base64
2
2
  from pathlib import Path
3
3
  from datetime import datetime
4
4
  import logging
5
- from tkinter import filedialog
6
-
7
-
8
- def browse_button():
9
- # Allow the user to select a directory to write the dump files into.
10
- filename = filedialog.askdirectory()
11
- if filename == "":
12
- filename = "DataFile/"
13
-
14
- return filename
15
5
 
16
6
 
17
7
  class SerialDump:
18
- def __init__(self):
8
+ """Writes the packets coming off the serial line into a timestamped session file.
9
+
10
+ ``output_dir`` is asked for by the caller, not by this class. It used to open a
11
+ tkinter directory chooser from here, which meant the recorder could not be used
12
+ without a GUI and -- worse -- that the dialog appeared at whatever point in startup
13
+ this object happened to be constructed, which was after the browser had already been
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.
16
+ """
19
17
 
20
- self.f_b64 = Path(browse_button() + '/' + datetime.now().strftime("%Y%m%d-%H%M%S_%f")[:-3] + '.b64')
18
+ def __init__(self, output_dir):
19
+ self.output_dir = Path(output_dir)
20
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S_%f")[:-3]
21
+ self.f_b64 = self.output_dir / f'{stamp}.b64'
21
22
  self.f_bin = self.f_b64.with_suffix('.bin')
22
23
 
23
24
  self.cobs_file = None
@@ -1,10 +1,13 @@
1
1
  import argparse
2
2
  import logging
3
+ import os
3
4
  import sys
4
5
  import threading
5
6
  import time
6
7
  import tkinter as tk
7
8
  import webbrowser
9
+ from pathlib import Path
10
+ from tkinter import filedialog
8
11
  from tkinter import messagebox as mb
9
12
 
10
13
  import serial
@@ -114,6 +117,58 @@ def choose_serial_port(parent):
114
117
  return chosen[0] if chosen else ''
115
118
 
116
119
 
120
+ def choose_output_directory():
121
+ """Modal dialog to pick the directory recordings are written into.
122
+
123
+ Returns the directory, or '' if the user cancelled.
124
+
125
+ Deliberately no ``parent=``, however obviously right passing one looks. On macOS Tk
126
+ turns a file dialog with a parent into a *sheet* attached to that window
127
+ (tkMacOSXDialog.c: "Use a sheet if -parent is specified"), and the only window
128
+ available here is the withdrawn root - which still has a backing NSWindow, 200x200
129
+ near the top left corner. The sheet is then anchored under a window a third the
130
+ panel's width, hanging off the edge of the screen, and a sheet cannot be dragged back
131
+ on. Without a parent the panel is free floating, centred and movable. Nothing is
132
+ lost by leaving it out: this dialog is shown before the browser is opened, so it comes
133
+ up in front anyway.
134
+
135
+ ``initialdir`` matters more than it looks: askdirectory() without one starts wherever
136
+ Tk was last, which on macOS is commonly '/' - a directory nobody wants to record into
137
+ and one that is not writable anyway. The working directory is where the user cd'd to
138
+ before running the command, so it is the best guess available; a Finder or desktop
139
+ launch, where the working directory is '/', is not, hence the fall back to $HOME.
140
+ """
141
+ # Not to parent the dialog, but so that a root exists at all: with no default root,
142
+ # tkinter's Dialog.show() builds a throwaway Tk() and destroys it again afterwards,
143
+ # which is the second-interpreter crash get_root() exists to prevent. With one, it
144
+ # reuses it and the teardown is a no-op.
145
+ get_root()
146
+ start = Path.cwd()
147
+ if start == Path(start.anchor) or not os.access(start, os.W_OK):
148
+ start = Path.home()
149
+ return filedialog.askdirectory(
150
+ initialdir=str(start),
151
+ title='Where should TotalSync write this session?')
152
+
153
+
154
+ def prepare_output_directory(directory):
155
+ """Make ``directory`` usable as a recording target, or raise ValueError saying why.
156
+
157
+ Called before the serial port and the servers come up, so that a bad path is reported
158
+ while nothing is running yet rather than by the first packet failing to be written.
159
+ """
160
+ directory = Path(directory).expanduser()
161
+ if directory.exists() and not directory.is_dir():
162
+ raise ValueError(f'not a directory: {directory}')
163
+ try:
164
+ directory.mkdir(parents=True, exist_ok=True)
165
+ except OSError as exc:
166
+ raise ValueError(f'cannot create {directory}: {exc}') from None
167
+ if not os.access(directory, os.W_OK):
168
+ raise ValueError(f'not writable: {directory}')
169
+ return directory
170
+
171
+
117
172
  def welcome_dialog():
118
173
  """Show the startup window.
119
174
 
@@ -127,9 +182,9 @@ def welcome_dialog():
127
182
  quitting = []
128
183
 
129
184
  def on_play():
130
- # The HTTP server is not running yet; the 'Reload' button on the next
131
- # window exists to retry once it is.
132
- open_web_interface()
185
+ # Just close the window: the browser is opened by main(), once the servers are
186
+ # actually listening. Opening it from here is what used to greet the user with
187
+ # "unable to connect".
133
188
  win.destroy()
134
189
 
135
190
  def on_quit():
@@ -161,8 +216,8 @@ def welcome_dialog():
161
216
 
162
217
 
163
218
  class TeensyCommander:
164
- def __init__(self, serial_port, http_port, ws_port, curses_screen, write_bin=False, use_dummy=False,
165
- channel_labels=None):
219
+ def __init__(self, serial_port, http_port, ws_port, curses_screen, output_dir,
220
+ write_bin=False, use_dummy=False, channel_labels=None):
166
221
  self.n_packet = 0
167
222
  self.packets_per_second = 0
168
223
  self.packet_timings = []
@@ -183,7 +238,8 @@ class TeensyCommander:
183
238
  self.shell_gui = CursesUI(self, curses_screen) if curses_screen is not None else None
184
239
  time.sleep(0.05) # give some time to let log display catch all startup messages
185
240
 
186
- self.serial_dump = SerialDump()
241
+ self.output_dir = Path(output_dir)
242
+ self.serial_dump = SerialDump(self.output_dir)
187
243
  self.serial_port = serial_port
188
244
  self.web_server = WebInterface(http_port, ws_port, self, channel_labels=channel_labels)
189
245
 
@@ -279,7 +335,6 @@ class TeensyCommander:
279
335
  logging.debug('Reset')
280
336
  try:
281
337
  reset = pack_reset_packet()
282
- SerialDump()
283
338
  if reset is None:
284
339
  logging.error('Failed to pack!')
285
340
  return
@@ -324,7 +379,8 @@ class TeensyCommander:
324
379
 
325
380
 
326
381
  def main(screen, cli_args, channel_labels=None):
327
- # SerialDump() opens a tkinter directory chooser, so a root must already exist.
382
+ # menu() builds its control window on this root, so it has to exist before
383
+ # run_forever() gets there.
328
384
  get_root()
329
385
  logging.info(
330
386
  "Known serial ports: " + repr(sorted([comport.device for comport in serial.tools.list_ports.comports()])))
@@ -335,9 +391,19 @@ def main(screen, cli_args, channel_labels=None):
335
391
  http_port=cli_args.http_port,
336
392
  ws_port=cli_args.ws_port,
337
393
  curses_screen=screen,
394
+ output_dir=cli_args.output_dir,
338
395
  write_bin=cli_args.binfile,
339
396
  use_dummy=cli_args.dummy,
340
397
  channel_labels=channel_labels)
398
+
399
+ # Here and not earlier. WebInterface(), constructed inside TeensyCommander.__init__,
400
+ # binds the HTTP port, and the serial port is opened after that -- so reaching this
401
+ # line means the whole stack came up and a request will be answered. The browser used
402
+ # to be opened from the Play button instead, before any of it existed, which is what
403
+ # produced "unable to connect" and made the Reload button necessary.
404
+ if not cli_args.no_browser:
405
+ open_web_interface()
406
+
341
407
  try:
342
408
  tc.run_forever()
343
409
  except KeyboardInterrupt:
@@ -353,8 +419,17 @@ def cli_entry():
353
419
  parser = argparse.ArgumentParser()
354
420
  parser.add_argument('-s', '--serial_port', default=None,
355
421
  help='Serial port of the Teensy. If omitted, a startup dialog asks for one.')
356
- parser.add_argument('-w', '--ws_port', default=WS_PORT)
357
- parser.add_argument('-H', '--http_port', default=HTTP_PORT)
422
+ parser.add_argument('-o', '--output-dir', default=None, metavar='DIR',
423
+ help='Directory to write the recording into. If omitted, a '
424
+ 'startup dialog asks for one.')
425
+ parser.add_argument('--no-browser', action='store_true',
426
+ help='Do not open the web interface in a browser at startup')
427
+ parser.add_argument('-w', '--ws_port', type=int, default=WS_PORT,
428
+ help=f'Websocket port (default {WS_PORT}). Note that the browser '
429
+ f'client hardcodes {WS_PORT}, so changing this stops the '
430
+ f'page from receiving data.')
431
+ parser.add_argument('-H', '--http_port', type=int, default=HTTP_PORT,
432
+ help=f'Port the web interface is served on (default {HTTP_PORT})')
358
433
  parser.add_argument('-B', '--binfile', action='store_true', help='Write decoded binary serial dump file')
359
434
  parser.add_argument('-C', '--curses', action='store_true', help='Use cursesUI in terminal')
360
435
  parser.add_argument('-D', '--dummy', action='store_true',
@@ -409,6 +484,26 @@ def cli_entry():
409
484
  return
410
485
  cli_args.serial_port = port or DEFAULT_SERIAL_PORT
411
486
 
487
+ # Before the servers and the browser, not after. Nothing downstream needs the
488
+ # directory in order to start -- the HTTP server only wants a port and the channel
489
+ # labels -- but it does have to be answered before the browser is pointed at
490
+ # anything, because the dialog blocks the main thread while it is up. Asking here
491
+ # also means it appears while this application still has the focus, rather than
492
+ # behind a browser window that has just been given it.
493
+ if cli_args.output_dir is None:
494
+ cli_args.output_dir = choose_output_directory()
495
+ if not cli_args.output_dir:
496
+ logging.info('No output directory chosen, so nothing was started.')
497
+ destroy_root()
498
+ return
499
+ try:
500
+ cli_args.output_dir = prepare_output_directory(cli_args.output_dir)
501
+ except ValueError as exc:
502
+ logging.error(f'Cannot record into that directory: {exc}')
503
+ destroy_root()
504
+ return 1
505
+ logging.info(f'Recording into {cli_args.output_dir}')
506
+
412
507
  if cli_args.curses and curses is not None:
413
508
  curses.wrapper(main, cli_args, channel_labels)
414
509
  else:
@@ -1,17 +0,0 @@
1
- # Use shell-style glob syntax
2
- syntax: glob
3
-
4
- # Compiled Python files
5
- *.pyc
6
-
7
- # Folder view configuration files
8
- .DS_Store
9
- Desktop.ini
10
-
11
- # Thumbnail cache files
12
- ._*
13
- Thumbs.db
14
-
15
- # Files that might appear on external disks
16
- .Spotlight-V100
17
- .Trashes