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.
- totalsync_webinterface/__init__.py +8 -0
- totalsync_webinterface/camera.py +163 -0
- totalsync_webinterface/curses_interface.py +142 -0
- totalsync_webinterface/fake_picamera.py +81 -0
- totalsync_webinterface/packet.py +194 -0
- totalsync_webinterface/pin_sheet.py +101 -0
- totalsync_webinterface/serial_dummy.py +92 -0
- totalsync_webinterface/serial_dump.py +66 -0
- totalsync_webinterface/teensy_commander.py +490 -0
- totalsync_webinterface/web/CanvasCamera.js +33 -0
- totalsync_webinterface/web/CanvasPlot.js +71 -0
- totalsync_webinterface/web/favicon.ico +0 -0
- totalsync_webinterface/web/index.html +31 -0
- totalsync_webinterface/web/interface.js +342 -0
- totalsync_webinterface/web/reconnecting-websocket/LICENSE.txt +21 -0
- totalsync_webinterface/web/reconnecting-websocket/README.md +149 -0
- totalsync_webinterface/web/reconnecting-websocket/package.json +19 -0
- totalsync_webinterface/web/reconnecting-websocket/reconnecting-websocket.js +365 -0
- totalsync_webinterface/web/style.css +165 -0
- totalsync_webinterface/web/webgl-plot/LICENSE +21 -0
- totalsync_webinterface/web/webgl-plot/README.md +184 -0
- totalsync_webinterface/web/webgl-plot/dist/webglplot.esm.js +619 -0
- totalsync_webinterface/web/webgl-plot/package.json +42 -0
- totalsync_webinterface/web_interface.py +268 -0
- totalsync_webinterface/websocket_server/LICENSE +21 -0
- totalsync_webinterface/websocket_server/__init__.py +1 -0
- totalsync_webinterface/websocket_server/websocket_server.py +377 -0
- totalsync_webinterface-0.1.1.dist-info/METADATA +41 -0
- totalsync_webinterface-0.1.1.dist-info/RECORD +32 -0
- totalsync_webinterface-0.1.1.dist-info/WHEEL +4 -0
- totalsync_webinterface-0.1.1.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|