totalsync-webinterface 0.1.1__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 (32) hide show
  1. totalsync_webinterface/__init__.py +8 -0
  2. totalsync_webinterface/camera.py +163 -0
  3. totalsync_webinterface/curses_interface.py +142 -0
  4. totalsync_webinterface/fake_picamera.py +81 -0
  5. totalsync_webinterface/packet.py +194 -0
  6. totalsync_webinterface/pin_sheet.py +101 -0
  7. totalsync_webinterface/serial_dummy.py +92 -0
  8. totalsync_webinterface/serial_dump.py +66 -0
  9. totalsync_webinterface/teensy_commander.py +490 -0
  10. totalsync_webinterface/web/CanvasCamera.js +33 -0
  11. totalsync_webinterface/web/CanvasPlot.js +71 -0
  12. totalsync_webinterface/web/favicon.ico +0 -0
  13. totalsync_webinterface/web/index.html +31 -0
  14. totalsync_webinterface/web/interface.js +342 -0
  15. totalsync_webinterface/web/reconnecting-websocket/LICENSE.txt +21 -0
  16. totalsync_webinterface/web/reconnecting-websocket/README.md +149 -0
  17. totalsync_webinterface/web/reconnecting-websocket/package.json +19 -0
  18. totalsync_webinterface/web/reconnecting-websocket/reconnecting-websocket.js +365 -0
  19. totalsync_webinterface/web/style.css +165 -0
  20. totalsync_webinterface/web/webgl-plot/LICENSE +21 -0
  21. totalsync_webinterface/web/webgl-plot/README.md +184 -0
  22. totalsync_webinterface/web/webgl-plot/dist/webglplot.esm.js +619 -0
  23. totalsync_webinterface/web/webgl-plot/package.json +42 -0
  24. totalsync_webinterface/web_interface.py +268 -0
  25. totalsync_webinterface/websocket_server/LICENSE +21 -0
  26. totalsync_webinterface/websocket_server/__init__.py +1 -0
  27. totalsync_webinterface/websocket_server/websocket_server.py +377 -0
  28. totalsync_webinterface-0.1.1.dist-info/METADATA +41 -0
  29. totalsync_webinterface-0.1.1.dist-info/RECORD +32 -0
  30. totalsync_webinterface-0.1.1.dist-info/WHEEL +4 -0
  31. totalsync_webinterface-0.1.1.dist-info/entry_points.txt +2 -0
  32. totalsync_webinterface-0.1.1.dist-info/licenses/LICENSE +676 -0
@@ -0,0 +1,92 @@
1
+ import logging
2
+ import math
3
+ import struct
4
+ import threading
5
+ import time
6
+
7
+ import serial
8
+ from cobs import cobs
9
+
10
+ from .packet import DataPacketStruct
11
+
12
+
13
+ # uint8_t type; // 1 B, packet type
14
+ # uint8_t size; // 1 B, packet size
15
+ # uint16_t crc16; // 2 B, CRC16
16
+ # unsigned long packetID;// 4 B, running packet count
17
+
18
+ # unsigned long ts_start;// 4 B, gather start timestamp
19
+ # unsigned long ts_end; // 4 B, transmit timestamp
20
+ # uint16_t analog[8]; // 16 B, ADC values
21
+ # int16_t states[8]; // 16 B, state variables (encoder, speed, etc)
22
+
23
+ # uint16_t digitalIn; // 2 B, digital inputs
24
+ # uint8_t digitalOut; // 1 B, digital outputs
25
+ # uint8_t padding[0]; // 1 B, align to 4B
26
+
27
+ class SerialDummy(threading.Thread):
28
+ def __init__(self):
29
+ super(SerialDummy, self).__init__()
30
+ self.ser = serial.serial_for_url('loop://')
31
+ self.daemon = True
32
+ self.n_packet = 0
33
+ self.interval = 1
34
+
35
+ self.start()
36
+
37
+ def run(self):
38
+ logging.info('SerialDummy starting')
39
+ while self.is_alive() and self.ser.is_open:
40
+ self.emit()
41
+ time.sleep(self.interval / 1000)
42
+ self.ser.close()
43
+
44
+ def emit(self):
45
+ for n in range(10):
46
+ rp = self.random_packet()
47
+ if rp:
48
+ self.ser.write(rp)
49
+ self.n_packet += 1
50
+
51
+ def random_packet(self):
52
+ packet_type = 0
53
+ packet_size = struct.calcsize(DataPacketStruct)
54
+ packet_crc = 777
55
+ packet_id = self.n_packet
56
+
57
+ packet_us_start = self.n_packet * self.interval * 1000 % (2 ** 32 - 1)
58
+ packet_us_end = self.n_packet * self.interval * 1000 % (2 ** 32 - 1)
59
+ packet_analog = self.analog()
60
+ packet_states = self.states()
61
+
62
+ packet_digitalIn = int(self.n_packet / 10 % 2 ** 16)
63
+ packet_digitalOut = int(self.n_packet / 10 % 2 ** 8)
64
+
65
+ packet = []
66
+ packet.extend([packet_type, packet_size, packet_crc, packet_id,
67
+ packet_us_start, packet_us_end, *packet_analog, *packet_states,
68
+ packet_digitalIn, packet_digitalOut])
69
+
70
+ try:
71
+ ps = struct.pack(DataPacketStruct, *packet)
72
+ except struct.error as e:
73
+ logging.error(e)
74
+ print(packet)
75
+ return
76
+
77
+ enc = cobs.encode(ps)
78
+ return enc + b'\0'
79
+
80
+ def analog(self):
81
+ arr = []
82
+ for n in range(8):
83
+ val = int((math.sin((self.n_packet + n) / (2 ** (n + 1))) + 1) * (2 ** (n + 8) - 1))
84
+ arr.append(val)
85
+ return arr
86
+
87
+ def states(self):
88
+ arr = []
89
+ for n in range(8):
90
+ val = int((math.sin((self.n_packet + n + 500) / (2 ** (n + 1)))) * (2 ** (n + 8) - 1))
91
+ arr.append(val)
92
+ return arr
@@ -0,0 +1,66 @@
1
+ import base64
2
+ from pathlib import Path
3
+ from datetime import datetime
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
+
16
+
17
+ class SerialDump:
18
+ def __init__(self):
19
+
20
+ self.f_b64 = Path(browse_button() + '/' + datetime.now().strftime("%Y%m%d-%H%M%S_%f")[:-3] + '.b64')
21
+ self.f_bin = self.f_b64.with_suffix('.bin')
22
+
23
+ self.cobs_file = None
24
+ self.bin_file = None
25
+
26
+ # Counts every framed packet that came off the serial line, including those
27
+ # that later fail to COBS-decode or unpack: handle_raw runs before any of
28
+ # that happens (see Packet.PacketReceiver.handle_packet). Comparing it with
29
+ # TeensyCommander.n_packet, which only counts fully unpacked data packets,
30
+ # is what makes a flaky connection visible.
31
+ self.n_raw_packets = 0
32
+
33
+ def handle_raw(self, arr):
34
+ """Write the cobs-encoded, 0-terminated data as base64 encoded string to log file,
35
+ one line per packet.
36
+
37
+ To allow re-parsing of the array with a cobs-decoder, we re-add the 0 terminator that was
38
+ removed by the serial packetizer. (This is completely unnecessary and here for backwards
39
+ compatibility).
40
+ """
41
+ # Count the arrival, not the successful write, so a full disk or a closed
42
+ # file does not hide the fact that the packet did come in. Only the serial
43
+ # ReaderThread ever increments this, so a plain += needs no lock.
44
+ self.n_raw_packets += 1
45
+
46
+ # For future parsing, we need to re-append the line termination symbol \0.
47
+ if self.cobs_file is None:
48
+ logging.info(f"Opening COBS+Base64 serial dump file {self.f_b64.absolute()}")
49
+ self.cobs_file = open(self.f_b64, 'w+b')
50
+ self.cobs_file.write(base64.b64encode(arr + b'\0') + b'\n')
51
+
52
+ def handle_array(self, arr):
53
+ """Write the cobs-decoded byte-array packet into a .bin file.
54
+
55
+ This allows use of a mmap to interpret the file without having to decode again."""
56
+ if self.bin_file is None:
57
+ logging.info(f"Opening binary serial dump file {self.f_bin.absolute()}")
58
+ self.bin_file = open(self.f_bin, 'w+b')
59
+ self.bin_file.write(arr)
60
+
61
+ def __del__(self):
62
+ # Either file is still None if no packet of that kind ever arrived, which is
63
+ # the normal case for .bin unless -B was given.
64
+ for handle in (self.cobs_file, self.bin_file):
65
+ if handle is not None:
66
+ handle.close()
@@ -0,0 +1,490 @@
1
+ import argparse
2
+ import logging
3
+ import sys
4
+ import threading
5
+ import time
6
+ import tkinter as tk
7
+ import webbrowser
8
+ from tkinter import messagebox as mb
9
+
10
+ import serial
11
+ import serial.threaded
12
+ import serial.tools.list_ports
13
+ import zmq
14
+
15
+ try:
16
+ import curses
17
+ except ImportError:
18
+ curses = None
19
+
20
+ from .packet import PacketReceiver, pack_command_packet, pack_reset_packet
21
+ from .pin_sheet import load_pin_labels
22
+ from .serial_dump import SerialDump
23
+ from .serial_dummy import SerialDummy
24
+ from .web_interface import WebInterface, WS_PORT, HTTP_PORT
25
+ from .curses_interface import CursesUI
26
+
27
+ ZMQ_SERVER_PUB_PORT = 5680
28
+ ZMQ_SERVER_SUB_PORT = 5681
29
+
30
+ # The web interface is served by our own HTTP server, see WebInterface.WEB_DIRECTORY.
31
+ WEB_URL = f'http://localhost:{HTTP_PORT}/index.html'
32
+
33
+
34
+ def open_web_interface():
35
+ """Open the interface in a browser.
36
+
37
+ The nonce makes the URL one the browser has never seen, so the request has to
38
+ reach the server rather than being answered from cache. Without it, an
39
+ index.html cached before this port started serving redirects would be reused,
40
+ and the redirect that points at the current assets never seen.
41
+ """
42
+ webbrowser.open_new(f'{WEB_URL}?t={time.time_ns():x}')
43
+
44
+
45
+ # Fallback when neither --serial_port nor the startup dialog supplies a port. There is
46
+ # no useful cross-platform default: on POSIX the devices are /dev/cu.* or /dev/ttyUSB*
47
+ # and differ per machine, so fall back to an empty string and let the SerialException
48
+ # handler in TeensyCommander report it (or use -D to switch to the dummy).
49
+ DEFAULT_SERIAL_PORT = 'COM9' if sys.platform == 'win32' else ''
50
+
51
+ _root = None
52
+
53
+
54
+ def get_root():
55
+ """Return the process-wide hidden Tk root, creating it on first use.
56
+
57
+ Tk supports exactly one root per process. Additional tk.Tk() instances are
58
+ separate Tcl interpreters that cannot share variables or `after` callbacks,
59
+ and destroying one on macOS leaves a dangling Aqua idle handler that
60
+ segfaults the next event loop. Every window below is a Toplevel of this root.
61
+ """
62
+ global _root
63
+ if _root is None:
64
+ _root = tk.Tk()
65
+ _root.withdraw()
66
+ return _root
67
+
68
+
69
+ def destroy_root():
70
+ """Tear down the Tk root so the process can exit without a stuck NSApplication."""
71
+ global _root
72
+ if _root is None:
73
+ return
74
+ try:
75
+ # update() first: it drains the idle queue. On macOS a Tk idle handler
76
+ # left unserviced across a destroy() segfaults the next event loop.
77
+ _root.update()
78
+ _root.destroy()
79
+ except tk.TclError:
80
+ pass
81
+ _root = None
82
+
83
+
84
+ # sys.exit() must never be called from inside a Tk callback: the SystemExit it
85
+ # raises is discarded by `tkwait` (unlike mainloop(), Tkapp_Call does not restore
86
+ # the pending exception), so the caller silently carries on. The dialogs below
87
+ # therefore report "the user asked to quit" through their return value instead.
88
+ QUIT = object()
89
+
90
+
91
+ def choose_serial_port(parent):
92
+ """Modal dialog to pick a serial port. Returns the device, or '' if cancelled."""
93
+ ports = sorted(comport.device for comport in serial.tools.list_ports.comports())
94
+ if not ports:
95
+ mb.showwarning('No serial ports', 'No serial ports were found on this machine.',
96
+ parent=parent)
97
+ return ''
98
+
99
+ win = tk.Toplevel(parent)
100
+ win.title('Select the serial port you want to use')
101
+ win.geometry('500x40')
102
+ win.transient(parent)
103
+
104
+ selected = tk.StringVar(win)
105
+ chosen = []
106
+
107
+ def choice(value):
108
+ chosen.append(value)
109
+ win.destroy()
110
+
111
+ tk.OptionMenu(win, selected, *ports, command=choice).pack()
112
+ win.grab_set()
113
+ win.wait_window()
114
+ return chosen[0] if chosen else ''
115
+
116
+
117
+ def welcome_dialog():
118
+ """Show the startup window.
119
+
120
+ Returns the serial port the user picked ('' if none), or QUIT if the user
121
+ asked to quit.
122
+ """
123
+ win = tk.Toplevel(get_root())
124
+ win.title('Totalsync')
125
+ win.geometry('400x150')
126
+ port = tk.StringVar(win)
127
+ quitting = []
128
+
129
+ 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()
133
+ win.destroy()
134
+
135
+ def on_quit():
136
+ if mb.askyesno('Verify', 'Really quit?', parent=win):
137
+ quitting.append(True)
138
+ win.destroy()
139
+ return
140
+ mb.showinfo('No', 'Quit has been cancelled', parent=win)
141
+
142
+ def on_close():
143
+ if mb.askokcancel('Quit', 'Do you want to quit?', parent=win):
144
+ quitting.append(True)
145
+ win.destroy()
146
+
147
+ def on_choose():
148
+ device = choose_serial_port(win)
149
+ if device:
150
+ port.set(device)
151
+ logging.info(f'Selected serial port: {device}')
152
+
153
+ win.protocol('WM_DELETE_WINDOW', on_close)
154
+ tk.Label(win, text='Welcome to TotalSync').pack()
155
+ tk.Button(win, text='Quit', command=on_quit).pack()
156
+ tk.Button(win, text='Play', command=on_play).pack()
157
+ tk.Button(win, text='choose COM', command=on_choose).pack()
158
+
159
+ win.wait_window()
160
+ return QUIT if quitting else port.get()
161
+
162
+
163
+ class TeensyCommander:
164
+ def __init__(self, serial_port, http_port, ws_port, curses_screen, write_bin=False, use_dummy=False,
165
+ channel_labels=None):
166
+ self.n_packet = 0
167
+ self.packets_per_second = 0
168
+ self.packet_timings = []
169
+ # Set up front so shutdown()/__del__ stay safe if __init__ bails out early.
170
+ self.serial = None
171
+ self.serial_reader = None
172
+ self.reader_thread = None
173
+ self.dummy = None
174
+ self.zmq_ctx = zmq.Context()
175
+ self.zmq_pub = self.zmq_ctx.socket(zmq.PUB)
176
+ self.zmq_pub.bind(f'tcp://*:{ZMQ_SERVER_PUB_PORT}')
177
+
178
+ self.zmq_sub = self.zmq_ctx.socket(zmq.SUB)
179
+ self.zmq_sub.setsockopt_string(zmq.SUBSCRIBE, "")
180
+ self.zmq_sub.bind(f'tcp://*:{ZMQ_SERVER_SUB_PORT}')
181
+
182
+ self.alive = True
183
+ self.shell_gui = CursesUI(self, curses_screen) if curses_screen is not None else None
184
+ time.sleep(0.05) # give some time to let log display catch all startup messages
185
+
186
+ self.serial_dump = SerialDump()
187
+ self.serial_port = serial_port
188
+ self.web_server = WebInterface(http_port, ws_port, self, channel_labels=channel_labels)
189
+
190
+ try:
191
+ self.serial = serial.Serial(self.serial_port)
192
+ self.serial.flushInput()
193
+ except serial.SerialException as e:
194
+ logging.error("Can't find serial device: {}".format(e))
195
+ if use_dummy:
196
+ logging.warning('Using serial dummy')
197
+ self.dummy = SerialDummy()
198
+ self.serial = self.dummy.ser
199
+ self.serial_port = 'DUMMY'
200
+ else:
201
+ self.shutdown()
202
+ raise SystemExit(1)
203
+
204
+ # __enter__() returns the PacketReceiver protocol, not the thread, so keep a
205
+ # reference to the thread as well; shutdown() needs it to stop reading.
206
+ self.reader_thread = serial.threaded.ReaderThread(self.serial, PacketReceiver)
207
+ self.serial_reader = self.reader_thread.__enter__()
208
+
209
+ # raw data consumers
210
+ self.serial_reader.raw_callbacks.append(self.serial_dump.handle_raw)
211
+
212
+ # decoded data consumers
213
+ if write_bin:
214
+ self.serial_reader.raw_callbacks.append(self.serial_dump.handle_array)
215
+ # unpacked data consumers
216
+ self.serial_reader.packet_callbacks.append(self.handle_packet)
217
+ self.serial_reader.packet_callbacks.append(self.web_server.handle_packet)
218
+ if self.shell_gui:
219
+ self.serial_reader.packet_callbacks.append(self.shell_gui.handle_packet)
220
+
221
+ self.zmq_subscriber = threading.Thread(target=self.subscriber, daemon=True)
222
+
223
+ def run_forever(self):
224
+ """Run the session, returning once the user closes the control window.
225
+
226
+ Tk's event loop *is* the main loop: every worker (serial reader, HTTP and
227
+ WebSocket servers, ZMQ subscriber) is a daemon thread, so the main thread
228
+ only has to keep Tk responsive. It used to `time.sleep(1)` in a loop here
229
+ instead, which left the macOS event queue unattended and made the OS
230
+ report the process as "application not responding".
231
+ """
232
+ self.zmq_subscriber.start()
233
+ menu(self)
234
+
235
+ def handle_packet(self, packet):
236
+ self.n_packet += 1
237
+ self.zmq_pub.send_pyobj(packet)
238
+
239
+ # calculate packet rate
240
+ t_now = time.time_ns() * 0.000000001
241
+ self.packet_timings.append(t_now)
242
+ t_delta = t_now - (self.packet_timings[0] if len(self.packet_timings) < 1000 else self.packet_timings.pop(0))
243
+ try:
244
+ self.packets_per_second = len(self.packet_timings) / t_delta
245
+ except ZeroDivisionError:
246
+ self.packets_per_second = 0
247
+
248
+ def subscriber(self):
249
+ while True:
250
+ try:
251
+ msg = self.zmq_sub.recv_pyobj()
252
+ except zmq.ZMQBaseError as e:
253
+ if e.errno == zmq.ETERM:
254
+ break
255
+ else:
256
+ raise
257
+ if msg:
258
+ self.send(msg)
259
+
260
+ def send(self, msg):
261
+ if msg is not None:
262
+ logging.debug('Send message: ' + str(msg))
263
+ self.pack_packet(msg)
264
+ else:
265
+ logging.error('Asked to send "None" message, skipping.')
266
+
267
+ def pack_packet(self, instruction):
268
+ logging.debug(f'Packing instruction: {instruction}')
269
+ try:
270
+ packed = pack_command_packet(instruction)
271
+ if packed is None:
272
+ logging.error('Failed to pack!')
273
+ return
274
+ self.send_packet(packed)
275
+ except ValueError:
276
+ logging.debug('Unknown type!')
277
+
278
+ def reset_packet(self):
279
+ logging.debug('Reset')
280
+ try:
281
+ reset = pack_reset_packet()
282
+ SerialDump()
283
+ if reset is None:
284
+ logging.error('Failed to pack!')
285
+ return
286
+ self.send_packet(reset)
287
+ except ValueError:
288
+ logging.debug('Unknown type!')
289
+
290
+ def send_packet(self, packet):
291
+ logging.debug('Serial write: ' + str(packet))
292
+ try:
293
+ self.serial.write(packet)
294
+ except (ValueError, serial.SerialException) as e:
295
+ logging.error(f'Serial write failed: {e}')
296
+
297
+ def shutdown(self):
298
+ """Release the serial port and the ZMQ resources. Safe to call twice."""
299
+ self.alive = False
300
+
301
+ if self.reader_thread is not None:
302
+ logging.debug('Stopping serial reader.')
303
+ # Stops the reader thread and closes the port, which also ends the
304
+ # SerialDummy loop (it writes to this very port).
305
+ self.reader_thread.close()
306
+ self.reader_thread = None
307
+ self.serial_reader = None
308
+ elif self.serial is not None and self.serial.is_open:
309
+ self.serial.close()
310
+
311
+ if self.zmq_ctx is not None:
312
+ logging.debug('Terminating ZMQ context.')
313
+ # linger=0 drops whatever is still queued. With the default LINGER of
314
+ # -1, term() waits forever for a subscriber that may never connect.
315
+ # term() also makes the subscriber thread's recv_pyobj() raise ETERM,
316
+ # which is how that thread breaks out of its loop.
317
+ self.zmq_ctx.destroy(linger=0)
318
+ self.zmq_ctx = None
319
+
320
+ def __del__(self):
321
+ logging.debug('Making sure serial port is closed on exit.')
322
+ if self.serial is not None and self.serial.is_open:
323
+ self.serial.close()
324
+
325
+
326
+ def main(screen, cli_args, channel_labels=None):
327
+ # SerialDump() opens a tkinter directory chooser, so a root must already exist.
328
+ get_root()
329
+ logging.info(
330
+ "Known serial ports: " + repr(sorted([comport.device for comport in serial.tools.list_ports.comports()])))
331
+ logging.info(
332
+ f"Launching Teensy Commander on serial port {cli_args.serial_port} and the web interface "
333
+ f"on ports HTTP:{cli_args.http_port} and WS:{cli_args.ws_port}")
334
+ tc = TeensyCommander(serial_port=cli_args.serial_port,
335
+ http_port=cli_args.http_port,
336
+ ws_port=cli_args.ws_port,
337
+ curses_screen=screen,
338
+ write_bin=cli_args.binfile,
339
+ use_dummy=cli_args.dummy,
340
+ channel_labels=channel_labels)
341
+ try:
342
+ tc.run_forever()
343
+ except KeyboardInterrupt:
344
+ logging.info('Interrupted, shutting down.')
345
+ finally:
346
+ # Both must happen on the way out: a live ZMQ context or Tk root keeps the
347
+ # process up long enough for macOS to call it unresponsive.
348
+ tc.shutdown()
349
+ destroy_root()
350
+
351
+
352
+ def cli_entry():
353
+ parser = argparse.ArgumentParser()
354
+ parser.add_argument('-s', '--serial_port', default=None,
355
+ 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)
358
+ parser.add_argument('-B', '--binfile', action='store_true', help='Write decoded binary serial dump file')
359
+ parser.add_argument('-C', '--curses', action='store_true', help='Use cursesUI in terminal')
360
+ parser.add_argument('-D', '--dummy', action='store_true',
361
+ help='Use serial dummy if no valid serial device available')
362
+ parser.add_argument('-v', '--verbose', action='count', default=2, help="Increase logging verbosity")
363
+ parser.add_argument('--pinsheet', default=None, metavar='PATH',
364
+ help='pinSheet.json describing what each pin is wired to. Channels with '
365
+ 'a "for" entry are labelled with it in the web interface; the rest '
366
+ 'keep their default names.')
367
+
368
+ cli_args = parser.parse_args()
369
+
370
+ try:
371
+ loglevel = {
372
+ 0: logging.ERROR,
373
+ 1: logging.WARN,
374
+ 2: logging.INFO,
375
+ }[cli_args.verbose]
376
+ except KeyError:
377
+ loglevel = logging.DEBUG
378
+
379
+ log_format = '[%(asctime)s]{%(filename)s:%(lineno)d} %(levelname)s - %(message)s'
380
+ logging.basicConfig(level=loglevel,
381
+ format=log_format,
382
+ datefmt='%H:%M:%S')
383
+
384
+ console = logging.StreamHandler()
385
+ console.setLevel(loglevel)
386
+ console.setFormatter(logging.Formatter(log_format))
387
+
388
+ # start_time_str = datetime.now().strftime("%Y%m%d-%H%M%S")
389
+ # log_file = logging.FileHandler('../totalsync/LogFile/' + f'{start_time_str}_teensy_commander.log', mode='w')
390
+ # log_file.setLevel(logging.DEBUG)
391
+ # log_file.setFormatter(logging.Formatter(log_format))
392
+
393
+ logging.getLogger().handlers.clear()
394
+ logging.getLogger().addHandler(console)
395
+ # logging.getLogger().addHandler(log_file)
396
+ logging.getLogger().setLevel(loglevel)
397
+
398
+ # Read the pin sheet before anything opens: a bad path should fail here, not
399
+ # after the serial port and the servers are already up.
400
+ channel_labels = load_pin_labels(cli_args.pinsheet) if cli_args.pinsheet else {}
401
+
402
+ # Only ask interactively when no port was given on the command line, so that
403
+ # --help and scripted runs never open a window.
404
+ if cli_args.serial_port is None:
405
+ port = welcome_dialog()
406
+ if port is QUIT:
407
+ logging.info('Quit requested in the startup dialog.')
408
+ destroy_root()
409
+ return
410
+ cli_args.serial_port = port or DEFAULT_SERIAL_PORT
411
+
412
+ if cli_args.curses and curses is not None:
413
+ curses.wrapper(main, cli_args, channel_labels)
414
+ else:
415
+ main(None, cli_args, channel_labels)
416
+
417
+
418
+ def menu(commander):
419
+ """Run the control window for a running commander. Returns when it is closed.
420
+
421
+ This window stays up for the whole session, so its event loop is what keeps
422
+ the process responsive; see TeensyCommander.run_forever.
423
+ """
424
+ win = tk.Toplevel(get_root())
425
+ win.title('Totalsync')
426
+
427
+ def quit_app():
428
+ # Never sys.exit() here, see the comment on QUIT: clearing `alive` and
429
+ # closing the window is what actually ends the session.
430
+ commander.alive = False
431
+ win.destroy()
432
+
433
+ def on_quit():
434
+ if mb.askyesno('Verify', 'Really quit?', parent=win):
435
+ quit_app()
436
+ return
437
+ mb.showinfo('No', 'Quit has been cancelled', parent=win)
438
+
439
+ def on_reset():
440
+ commander.reset_packet()
441
+ open_web_interface()
442
+
443
+ def on_reload():
444
+ open_web_interface()
445
+
446
+ def on_close():
447
+ if mb.askokcancel('Quit', 'Do you want to quit?', parent=win):
448
+ quit_app()
449
+
450
+ # Raw packet count at the previous tick, so the delta can be reported.
451
+ last_count = [0]
452
+
453
+ def report_packet_count():
454
+ """Log the received-packet counters, once per watchdog tick."""
455
+ received = commander.serial_dump.n_raw_packets
456
+ new = received - last_count[0]
457
+ last_count[0] = received
458
+ msg = (f'Serial packets: {received} received (+{new}), '
459
+ f'{commander.n_packet} decoded, {commander.packets_per_second:.1f}/s')
460
+ if new:
461
+ logging.info(msg)
462
+ else:
463
+ # A silent port is the failure this counter exists to make visible, so
464
+ # say so at a level that survives the default verbosity.
465
+ logging.warning(msg + ' - nothing received since the last check!')
466
+
467
+ def watchdog():
468
+ """Periodic health check, rescheduled on the Tk event loop."""
469
+ if not commander.alive:
470
+ quit_app()
471
+ return
472
+ report_packet_count()
473
+ if commander.shell_gui and commander.shell_gui.alive and not commander.shell_gui.is_alive():
474
+ logging.critical("Shell GUI died!")
475
+ # TODO: attempt to restart the shell GUI
476
+ win.after(1000, watchdog)
477
+
478
+ win.protocol('WM_DELETE_WINDOW', on_close)
479
+ tk.Label(win, text='TotalSync is now in use').pack()
480
+ tk.Button(win, text='Quit', command=on_quit).pack()
481
+ tk.Button(win, text='Reset', command=on_reset).pack()
482
+ tk.Button(win, text='Reload', command=on_reload).pack()
483
+
484
+ win.after(1000, watchdog)
485
+ win.wait_window()
486
+ commander.alive = False
487
+
488
+
489
+ if __name__ == "__main__":
490
+ cli_entry()
@@ -0,0 +1,33 @@
1
+ export class CameraView {
2
+ constructor(host_uri, port=8111) {
3
+ this.videoUri = `http://169.254.14.129:${port}/stream.mjpg`;
4
+ this.view = document.getElementById('videoStream');
5
+ this.view.src = this.videoUri;
6
+ this.t_last_frame = null;
7
+
8
+ this.view.onerror = (e) => {
9
+ console.error(e);
10
+ this.view.src = '';
11
+ };
12
+
13
+ this.view.onload = (e) => {
14
+ // each time a new frame is loaded?
15
+ if (this.t_last_frame != null) {
16
+ let elapsed = Date.now() - this.t_last_frame;
17
+
18
+ // if (elapsed > 1.0) {
19
+ // console.log()
20
+ // }
21
+ }
22
+
23
+ this.t_last_frame = Date.now();
24
+ };
25
+
26
+ const reload_button = document.getElementById("videoStreamButton");
27
+ reload_button.onclick = this.reload
28
+ }
29
+
30
+ reload = () => {
31
+ this.view.src = this.videoUri;
32
+ }
33
+ }