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,8 @@
|
|
|
1
|
+
"""Serial reader and browser interface for the TotalSync Teensy synchroniser.
|
|
2
|
+
|
|
3
|
+
The `totalsync` command is `teensy_commander.cli_entry`: it reads packets off the
|
|
4
|
+
Teensy's serial port and serves them to the browser interface in `web/`, which this
|
|
5
|
+
package ships as package data (see `web_interface.WEB_DIRECTORY`).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = '0.1.0'
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# https://picamera.readthedocs.io/en/release-1.13/recipes2.html#web-streaming
|
|
2
|
+
import argparse
|
|
3
|
+
import io
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import socketserver
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from http import server
|
|
9
|
+
from threading import Condition
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import picamera
|
|
14
|
+
except ImportError:
|
|
15
|
+
# picamera is Raspberry Pi only. Fall back to the stub so the module is at
|
|
16
|
+
# least importable and runnable elsewhere.
|
|
17
|
+
from . import fake_picamera as picamera
|
|
18
|
+
|
|
19
|
+
# Create the html page for the stream
|
|
20
|
+
PAGE = """\
|
|
21
|
+
<html>
|
|
22
|
+
<head>
|
|
23
|
+
<title>MouseCam</title>
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<center>
|
|
27
|
+
<h1>Streaming the setup</h1>
|
|
28
|
+
<img src="stream.mjpg" width="1280" height="768" />
|
|
29
|
+
</center>
|
|
30
|
+
</body>
|
|
31
|
+
</html>
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
PORT = 8111
|
|
35
|
+
http_stream = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Define a class for the streaming output
|
|
39
|
+
class StreamingOutput(object):
|
|
40
|
+
def __init__(self):
|
|
41
|
+
self.frame = None
|
|
42
|
+
self.buffer = io.BytesIO()
|
|
43
|
+
self.condition = Condition()
|
|
44
|
+
|
|
45
|
+
def write(self, buf):
|
|
46
|
+
if buf.startswith(b'\xff\xd8'):
|
|
47
|
+
# New frame, copy the existing buffer's content and notify all
|
|
48
|
+
# clients it's available
|
|
49
|
+
self.buffer.truncate()
|
|
50
|
+
with self.condition:
|
|
51
|
+
self.frame = self.buffer.getvalue()
|
|
52
|
+
self.condition.notify_all()
|
|
53
|
+
self.buffer.seek(0)
|
|
54
|
+
return self.buffer.write(buf)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Create handler for the stream, showing the stream or an error message
|
|
58
|
+
class StreamingHandler(server.BaseHTTPRequestHandler):
|
|
59
|
+
def do_GET(self):
|
|
60
|
+
if self.path == '/':
|
|
61
|
+
self.send_response(301)
|
|
62
|
+
self.send_header('Location', '/index.html')
|
|
63
|
+
self.end_headers()
|
|
64
|
+
elif self.path == '/index.html':
|
|
65
|
+
content = PAGE.encode('utf-8')
|
|
66
|
+
self.send_response(200)
|
|
67
|
+
self.send_header('Content-Type', 'text/html')
|
|
68
|
+
self.send_header('Content-Length', str(len(content)))
|
|
69
|
+
self.end_headers()
|
|
70
|
+
self.wfile.write(content)
|
|
71
|
+
elif self.path == '/stream.mjpg':
|
|
72
|
+
self.send_response(200)
|
|
73
|
+
self.send_header('Age', str(0))
|
|
74
|
+
self.send_header('Cache-Control', 'no-cache, private')
|
|
75
|
+
self.send_header('Pragma', 'no-cache')
|
|
76
|
+
self.send_header('Content-Type', 'multipart/x-mixed-replace; boundary=FRAME')
|
|
77
|
+
self.end_headers()
|
|
78
|
+
try:
|
|
79
|
+
while True:
|
|
80
|
+
with http_stream.condition:
|
|
81
|
+
http_stream.condition.wait()
|
|
82
|
+
frame = http_stream.frame
|
|
83
|
+
self.wfile.write(b'--FRAME\r\n')
|
|
84
|
+
self.send_header('Content-Type', 'image/jpeg')
|
|
85
|
+
self.send_header('Content-Length', str(len(frame)))
|
|
86
|
+
self.end_headers()
|
|
87
|
+
self.wfile.write(frame)
|
|
88
|
+
self.wfile.write(b'\r\n')
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logging.warning(
|
|
91
|
+
'Removed streaming client %s: %s',
|
|
92
|
+
self.client_address, str(e))
|
|
93
|
+
else:
|
|
94
|
+
self.send_error(404)
|
|
95
|
+
self.end_headers()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class StreamingServer(socketserver.ThreadingMixIn, server.HTTPServer):
|
|
99
|
+
allow_reuse_address = True
|
|
100
|
+
daemon_threads = True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main():
|
|
104
|
+
parser = argparse.ArgumentParser()
|
|
105
|
+
parser.add_argument('-p', '--port', default=PORT)
|
|
106
|
+
parser.add_argument('-v', '--verbose', action='count', default=2, help="Increase logging verbosity")
|
|
107
|
+
parser.add_argument('-o', '--output', help='Output directory, created if not existing', default='/home/pi/data')
|
|
108
|
+
parser.add_argument('-f', '--fps', default=30, help='Framerate', type=float)
|
|
109
|
+
parser.add_argument('-r', '--resolution', default='600x800', help='Resolution for video written to disk')
|
|
110
|
+
parser.add_argument('-m', '--mode', default=5, help='Camera mode', type=int)
|
|
111
|
+
parser.add_argument('-I', '--iso', default=800, help='Camera iso sensitivity', type=int)
|
|
112
|
+
parser.add_argument('--downscale', default=0.5, help='Downscaling factor of MJPG web stream', type=float)
|
|
113
|
+
parser.add_argument('-R', '--rotation', default=90, help='Rotate the image (in degrees, steps of 90°', type=int)
|
|
114
|
+
|
|
115
|
+
cli_args = parser.parse_args()
|
|
116
|
+
|
|
117
|
+
# Create date and time for the filename
|
|
118
|
+
dt_obj = datetime.now()
|
|
119
|
+
timestamp = dt_obj.strftime("%Y-%b-%d_%H-%M-%S")
|
|
120
|
+
|
|
121
|
+
save_dir = Path(cli_args.output).resolve()
|
|
122
|
+
if not save_dir.is_dir():
|
|
123
|
+
print(save_dir)
|
|
124
|
+
assert save_dir.is_dir()
|
|
125
|
+
|
|
126
|
+
if not os.path.exists(save_dir):
|
|
127
|
+
os.makedirs(save_dir)
|
|
128
|
+
|
|
129
|
+
filename = save_dir / f'{timestamp}_picamera.h264'
|
|
130
|
+
|
|
131
|
+
frame_width, frame_height = map(int, cli_args.resolution.split('x'))
|
|
132
|
+
frame_width_web = int(frame_width * cli_args.downscale)
|
|
133
|
+
frame_height_web = int(frame_height * cli_args.downscale)
|
|
134
|
+
logging.debug(f'resolution: {frame_width} x {frame_height} : {frame_width_web} x {frame_height_web}')
|
|
135
|
+
|
|
136
|
+
with picamera.PiCamera(resolution=f'{frame_width}x{frame_height}', framerate=cli_args.fps,
|
|
137
|
+
sensor_mode=cli_args.mode) as camera:
|
|
138
|
+
camera.rotation = cli_args.rotation
|
|
139
|
+
camera.exposure_mode = 'fixedfps'
|
|
140
|
+
camera.awb_mode = 'auto'
|
|
141
|
+
camera.awb_gains = (1.5, 1.2)
|
|
142
|
+
camera.iso = cli_args.iso
|
|
143
|
+
|
|
144
|
+
global http_stream
|
|
145
|
+
http_stream = StreamingOutput()
|
|
146
|
+
|
|
147
|
+
# record to local disk in full resolution
|
|
148
|
+
camera.start_recording(str(filename), splitter_port=2)
|
|
149
|
+
|
|
150
|
+
# record to stream in reduced resolution
|
|
151
|
+
camera.start_recording(http_stream, format='mjpeg', resize=(frame_width_web, frame_height_web))
|
|
152
|
+
|
|
153
|
+
camera.wait_recording(1)
|
|
154
|
+
try:
|
|
155
|
+
address = ('', cli_args.port)
|
|
156
|
+
http_server = StreamingServer(address, StreamingHandler)
|
|
157
|
+
http_server.serve_forever()
|
|
158
|
+
finally:
|
|
159
|
+
camera.stop_recording()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == '__main__':
|
|
163
|
+
main()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import curses
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CursesHandler(logging.Handler):
|
|
11
|
+
def __init__(self, window):
|
|
12
|
+
logging.Handler.__init__(self)
|
|
13
|
+
self.window = window
|
|
14
|
+
|
|
15
|
+
def emit(self, record):
|
|
16
|
+
try:
|
|
17
|
+
msg = self.format(record)
|
|
18
|
+
lvl = record.levelno
|
|
19
|
+
color = 0
|
|
20
|
+
if lvl > 10:
|
|
21
|
+
color = 3
|
|
22
|
+
if lvl > 20:
|
|
23
|
+
color = 13
|
|
24
|
+
if lvl > 30:
|
|
25
|
+
color = 5
|
|
26
|
+
if color > 40:
|
|
27
|
+
color = 5
|
|
28
|
+
|
|
29
|
+
self.window.addstr(msg + "\n", curses.color_pair(color))
|
|
30
|
+
|
|
31
|
+
except (KeyboardInterrupt, SystemExit):
|
|
32
|
+
raise
|
|
33
|
+
except:
|
|
34
|
+
logging.error("curses handler error!")
|
|
35
|
+
self.handleError(record)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CursesUI(threading.Thread):
|
|
39
|
+
def __init__(self, commander, screen, daemon=True):
|
|
40
|
+
super(CursesUI, self).__init__(daemon=daemon)
|
|
41
|
+
self.alive = True
|
|
42
|
+
self.screen = screen
|
|
43
|
+
self.commander = commander
|
|
44
|
+
curses.start_color()
|
|
45
|
+
curses.use_default_colors()
|
|
46
|
+
num_colors = min(16, curses.COLORS)
|
|
47
|
+
for i in range(0, num_colors):
|
|
48
|
+
curses.init_pair(i+1, i, -1)
|
|
49
|
+
curses.curs_set(0)
|
|
50
|
+
curses.setsyx(-1, -1)
|
|
51
|
+
self.screen.nodelay(1)
|
|
52
|
+
|
|
53
|
+
maxy, maxx = self.screen.getmaxyx()
|
|
54
|
+
self.width = maxx - 1
|
|
55
|
+
begin_x = 0
|
|
56
|
+
|
|
57
|
+
# Title Bar
|
|
58
|
+
tbw_height = 2
|
|
59
|
+
self.tb_window = curses.newwin(tbw_height, self.width, 0, begin_x)
|
|
60
|
+
self.tb_window.scrollok(True)
|
|
61
|
+
self.tb_window.idlok(True)
|
|
62
|
+
self.tb_window.leaveok(True)
|
|
63
|
+
title_str = "Commander Teensy v3"
|
|
64
|
+
self.tb_window.addstr(0, (self.width - len(title_str)) // 2, title_str, curses.color_pair(3))
|
|
65
|
+
|
|
66
|
+
# Main Window
|
|
67
|
+
mw_height = 10
|
|
68
|
+
self.main_window = curses.newwin(8, self.width, tbw_height, begin_x)
|
|
69
|
+
self.main_window.scrollok(True)
|
|
70
|
+
self.main_window.idlok(True)
|
|
71
|
+
self.main_window.leaveok(True)
|
|
72
|
+
# for i in range(num_colors):
|
|
73
|
+
# self.main_window.addstr(3, i, str(i), curses.color_pair(i))
|
|
74
|
+
|
|
75
|
+
# Logging Window
|
|
76
|
+
self.lw_height = maxy - mw_height - tbw_height
|
|
77
|
+
self.log_window = curses.newwin(self.lw_height, self.width, tbw_height+mw_height, begin_x)
|
|
78
|
+
self.log_window.scrollok(True)
|
|
79
|
+
self.log_window.idlok(True)
|
|
80
|
+
self.log_window.leaveok(True)
|
|
81
|
+
mh = CursesHandler(self.log_window)
|
|
82
|
+
|
|
83
|
+
mh.setFormatter('LogFile/'+logging.Formatter('|%(asctime)-8s|%(name)-10s|%(levelname)-7s|%(message)-s', '%H:%M:%S'))
|
|
84
|
+
self.logger = logging.getLogger("")
|
|
85
|
+
self.logger.addHandler(mh)
|
|
86
|
+
|
|
87
|
+
self.start()
|
|
88
|
+
|
|
89
|
+
def handle_packet(self, packet):
|
|
90
|
+
try:
|
|
91
|
+
w = self.main_window
|
|
92
|
+
w.erase()
|
|
93
|
+
if self.commander.serial_port != "DUMMY":
|
|
94
|
+
w.addstr(0, 2, "Serial " + self.commander.serial_port)
|
|
95
|
+
else:
|
|
96
|
+
w.addstr(0, 2, "Serial " + self.commander.serial_port, curses.color_pair(5))
|
|
97
|
+
status = self.commander.serial.is_open
|
|
98
|
+
color = curses.color_pair(3) if status else curses.color_pair(6)
|
|
99
|
+
status_txt = "OK" if status else "ERROR"
|
|
100
|
+
w.addstr(0, 10+len(self.commander.serial_port), status_txt, color)
|
|
101
|
+
|
|
102
|
+
w.addstr(1, 2, f"{self.commander.packets_per_second:06.1f} packets/s")
|
|
103
|
+
|
|
104
|
+
# Raw arrivals vs. successfully unpacked packets; a growing gap between
|
|
105
|
+
# the two means packets are coming in but not decoding.
|
|
106
|
+
w.addstr(2, 2, f"{self.commander.serial_dump.n_raw_packets} received, "
|
|
107
|
+
f"{self.commander.n_packet} decoded")
|
|
108
|
+
|
|
109
|
+
w.addstr(4, 2, f"PacketID {packet.packetID}")
|
|
110
|
+
# w.refresh()
|
|
111
|
+
except KeyboardInterrupt:
|
|
112
|
+
logging.critical("INTERRUPT")
|
|
113
|
+
self.alive = False
|
|
114
|
+
except BaseException as e:
|
|
115
|
+
logging.error(f"packet printing fail: {e}")
|
|
116
|
+
|
|
117
|
+
def run(self):
|
|
118
|
+
while self.alive:
|
|
119
|
+
time.sleep(.1)
|
|
120
|
+
time_str = datetime.now().strftime("%H:%M:%S %d.%m.%Y")
|
|
121
|
+
self.tb_window.addstr(0, self.width-len(time_str), time_str)
|
|
122
|
+
curses.noecho()
|
|
123
|
+
if self.screen.getch() == ord('q'):
|
|
124
|
+
logging.info("User requested exit.")
|
|
125
|
+
self.alive = False
|
|
126
|
+
# try:
|
|
127
|
+
# key = self.screen.getkey()
|
|
128
|
+
# logging.info(key)
|
|
129
|
+
# except:
|
|
130
|
+
# pass
|
|
131
|
+
|
|
132
|
+
# curses.echo()
|
|
133
|
+
self.tb_window.refresh()
|
|
134
|
+
self.main_window.refresh()
|
|
135
|
+
self.log_window.border()
|
|
136
|
+
self.log_window.refresh()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == '__main__':
|
|
140
|
+
screen = curses.initscr()
|
|
141
|
+
gui = CursesUI(screen=screen, commander=None, daemon=False)
|
|
142
|
+
curses.echo()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Create a fake picamera
|
|
3
|
+
"""
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BGR(object):
|
|
10
|
+
"""Fake class"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, sz):
|
|
13
|
+
self.array = np.random.rand(*sz)
|
|
14
|
+
|
|
15
|
+
def truncate(self, num):
|
|
16
|
+
# refreshes the fake image
|
|
17
|
+
self.array = np.random.rand(*self.array.shape)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PiCamera:
|
|
21
|
+
"""Fake class"""
|
|
22
|
+
resolution = (0, 0)
|
|
23
|
+
|
|
24
|
+
def __init__(self, resolution=None, framerate=None, **options):
|
|
25
|
+
# **options absorbs the rest of the real PiCamera signature (sensor_mode,
|
|
26
|
+
# led_pin, ...); camera.py passes sensor_mode=, which would be a TypeError.
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def __enter__(self):
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
33
|
+
self.close()
|
|
34
|
+
|
|
35
|
+
def start_preview(self, **options):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
def stop_preview(self):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
def add_overlay(self, *args, **kwargs):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def remove_overlay(self, *args, **kwargs):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
def capture(self, output, format=None, use_video_port=False, resize=None, splitter_port=0, bayer=False, **options):
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
def start_recording(self, output, format=None, resize=None, splitter_port=1, **options):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def split_recording(self, *args, **kwargs):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
def wait_recording(self, timeout=0, splitter_port=1):
|
|
57
|
+
if timeout:
|
|
58
|
+
time.sleep(timeout)
|
|
59
|
+
|
|
60
|
+
def stop_recording(self):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
def record_sequence(self, *args, **kwargs):
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
def capture_sequence(self, *args, **kwargs):
|
|
67
|
+
raise NotImplementedError
|
|
68
|
+
|
|
69
|
+
def capture_continuous(self, *args, **kwargs):
|
|
70
|
+
raise NotImplementedError
|
|
71
|
+
|
|
72
|
+
def close(self):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class array:
|
|
77
|
+
"""Fake class"""
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def PiRGBArray(cam, size):
|
|
81
|
+
return BGR(size)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import logging
|
|
3
|
+
import struct
|
|
4
|
+
import traceback
|
|
5
|
+
from collections import namedtuple
|
|
6
|
+
|
|
7
|
+
from cobs import cobs
|
|
8
|
+
from serial.threaded import Packetizer
|
|
9
|
+
|
|
10
|
+
# uint8_t type; // 1 B, packet type
|
|
11
|
+
# uint8_t size; // 1 B, packet size
|
|
12
|
+
# uint16_t crc16; // 2 B, CRC16
|
|
13
|
+
# unsigned long packetID;// 4 B, running packet count
|
|
14
|
+
|
|
15
|
+
# unsigned long ts_start;// 4 B, gather start timestamp
|
|
16
|
+
# unsigned long ts_end; // 4 B, transmit timestamp
|
|
17
|
+
# uint16_t analog[8]; // 16 B, ADC values
|
|
18
|
+
# long states[8]; 32 16 B, state variables (encoder, speed, etc)
|
|
19
|
+
|
|
20
|
+
# uint16_t digitalIn; // 2 B, digital inputs
|
|
21
|
+
# uint16_t digitalOut; // 2 B, digital outputs
|
|
22
|
+
# uint8_t padding[1]; // 1 B, align to 4B
|
|
23
|
+
|
|
24
|
+
DataPacketDesc = {'type': 'B',
|
|
25
|
+
'size': 'B',
|
|
26
|
+
'crc16': 'H',
|
|
27
|
+
'packetID': 'I',
|
|
28
|
+
'us_start': 'I',
|
|
29
|
+
'us_end': 'I',
|
|
30
|
+
'analog': '8H',
|
|
31
|
+
'states': '8l',
|
|
32
|
+
'digitalIn': '2H',
|
|
33
|
+
'digitalOut': '3B',
|
|
34
|
+
'padding': 'x'}
|
|
35
|
+
|
|
36
|
+
DataPacket = namedtuple('DataPacket', DataPacketDesc.keys())
|
|
37
|
+
DataPacketStruct = '<' + ''.join(DataPacketDesc.values())
|
|
38
|
+
DataPacketSize = struct.calcsize(DataPacketStruct)
|
|
39
|
+
|
|
40
|
+
Instructions = {'low': 0,
|
|
41
|
+
'high': 1,
|
|
42
|
+
'toggle': 2,
|
|
43
|
+
'pulse': 3,
|
|
44
|
+
'state': 4,
|
|
45
|
+
'unity': 5,
|
|
46
|
+
'handshake': 149,
|
|
47
|
+
'reset': 6}
|
|
48
|
+
InstructionsStructs = {
|
|
49
|
+
'low': 'B',
|
|
50
|
+
'high': 'B',
|
|
51
|
+
'toggle': 'B',
|
|
52
|
+
'pulse': 'L',
|
|
53
|
+
'state': 'l',
|
|
54
|
+
'unity': 'B',
|
|
55
|
+
'reset': 'B'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
CommandPacketHeaderDesc = {'type': 'B',
|
|
59
|
+
'size': 'B',
|
|
60
|
+
'crc16': 'H',
|
|
61
|
+
'instruction': 'B'}
|
|
62
|
+
CommandPacketHeader = namedtuple('CommandPacket', CommandPacketHeaderDesc.keys())
|
|
63
|
+
CommandPacketHeaderStruct = '<' + ''.join(CommandPacketHeaderDesc.values())
|
|
64
|
+
CommandPacketHeaderSize = struct.calcsize(CommandPacketHeaderStruct)
|
|
65
|
+
|
|
66
|
+
PinPulsePacket = {'pin': 'B',
|
|
67
|
+
'duration': 'H'}
|
|
68
|
+
PinPulsePacketStruct = '<BHx'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def pack_data_packet(packet_obj):
|
|
72
|
+
raise NotImplemented('data packing not ready.')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def pack_command_packet(packet_obj):
|
|
76
|
+
logging.info(f'Packing CommandPacket {packet_obj}')
|
|
77
|
+
data = packet_obj['data']
|
|
78
|
+
instruction = packet_obj['instruction']
|
|
79
|
+
data_arr = b''
|
|
80
|
+
for ds in data:
|
|
81
|
+
data_arr += struct.pack('<B' + InstructionsStructs[instruction], *ds)
|
|
82
|
+
cmd_p = struct.pack(CommandPacketHeaderStruct,
|
|
83
|
+
1, CommandPacketHeaderSize + len(data_arr), 0, Instructions[instruction])
|
|
84
|
+
arr = cmd_p + data_arr
|
|
85
|
+
logging.info(cobs.encode(arr) + b'\0');
|
|
86
|
+
return cobs.encode(arr) + b'\0'
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def pack_reset_packet():
|
|
90
|
+
logging.info(f'Packing CommandPacket ')
|
|
91
|
+
data = [(0,1)]
|
|
92
|
+
instruction = 'reset'
|
|
93
|
+
data_arr = b''
|
|
94
|
+
for ds in data:
|
|
95
|
+
data_arr += struct.pack('<B' + InstructionsStructs[instruction], *ds)
|
|
96
|
+
cmd_p = struct.pack(CommandPacketHeaderStruct,
|
|
97
|
+
1, CommandPacketHeaderSize + len(data_arr), 0, Instructions[instruction])
|
|
98
|
+
arr = cmd_p + data_arr
|
|
99
|
+
logging.info(cobs.encode(arr) + b'\0');
|
|
100
|
+
return cobs.encode(arr) + b'\0'
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class PacketReceiver(Packetizer):
|
|
104
|
+
raw_callbacks = []
|
|
105
|
+
decoded_callbacks = []
|
|
106
|
+
packet_callbacks = []
|
|
107
|
+
|
|
108
|
+
def connection_made(self, transport):
|
|
109
|
+
super(PacketReceiver, self).connection_made(transport)
|
|
110
|
+
|
|
111
|
+
def handle_packet(self, encoded):
|
|
112
|
+
"""Handle an incoming packet from the serial port. The Packetizer has stripped the
|
|
113
|
+
line-termination \0 byte from it already.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
assert (len(encoded))
|
|
117
|
+
for cb in self.raw_callbacks:
|
|
118
|
+
cb(encoded)
|
|
119
|
+
except BaseException as e:
|
|
120
|
+
logging.critical(e)
|
|
121
|
+
|
|
122
|
+
# COBS decode the array
|
|
123
|
+
try:
|
|
124
|
+
decoded = cobs.decode(encoded)
|
|
125
|
+
except cobs.DecodeError as e:
|
|
126
|
+
logging.warning(str(e))
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
for cb in self.decoded_callbacks:
|
|
131
|
+
cb(encoded)
|
|
132
|
+
except BaseException as e:
|
|
133
|
+
logging.critical(e)
|
|
134
|
+
|
|
135
|
+
# Unpack given the type of data
|
|
136
|
+
packet_type = decoded[0]
|
|
137
|
+
if packet_type == 0:
|
|
138
|
+
self.unpack_data_packet(decoded)
|
|
139
|
+
|
|
140
|
+
elif packet_type == 1:
|
|
141
|
+
self.unpack_command_packet(decoded)
|
|
142
|
+
|
|
143
|
+
elif packet_type == 2:
|
|
144
|
+
logging.error(f'Received error packet {decoded}')
|
|
145
|
+
|
|
146
|
+
else:
|
|
147
|
+
logging.error(f'Received unknown packet type: {packet_type} in packet {decoded}')
|
|
148
|
+
|
|
149
|
+
def unpack_data_packet(self, arr):
|
|
150
|
+
"""Handle a data packet by extracting its fields.
|
|
151
|
+
"""
|
|
152
|
+
if len(arr) != DataPacketSize:
|
|
153
|
+
logging.warning(f"Incorrect data size. Is: {len(arr)}, expected: {DataPacketSize}. Packet: {arr}")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# stupid manual struct unpacking is stupid
|
|
157
|
+
s = struct.unpack(DataPacketStruct, arr)
|
|
158
|
+
dp = DataPacket(type=s[0], size=s[1], crc16=s[2], packetID=s[3], us_start=s[4], us_end=s[5],
|
|
159
|
+
analog=s[6:14], states=s[14:22], digitalIn=s[22], digitalOut=s[23], padding=None)
|
|
160
|
+
|
|
161
|
+
# hand over packets to interested parties...
|
|
162
|
+
for fn_packet_callback in self.packet_callbacks:
|
|
163
|
+
try:
|
|
164
|
+
fn_packet_callback(dp)
|
|
165
|
+
# TODO: EVIL! DON'T! NO! NO! NO!
|
|
166
|
+
except BaseException as e:
|
|
167
|
+
logging.critical(e)
|
|
168
|
+
raise
|
|
169
|
+
|
|
170
|
+
def unpack_command_packet(self, arr):
|
|
171
|
+
"""Handle a command packet by extracting its fields.
|
|
172
|
+
"""
|
|
173
|
+
raise NotImplementedError
|
|
174
|
+
# if len(arr) != CommandPacketHeaderSize:
|
|
175
|
+
# logging.warning(f"Incorrect data size. Is: {len(arr)}, expected: {CommandPacketHeaderSize}. Packet: {arr}")
|
|
176
|
+
# return
|
|
177
|
+
#
|
|
178
|
+
# # stupid manual struct unpacking is stupid
|
|
179
|
+
# s = struct.unpack(CommandPacketHeaderStruct, arr)
|
|
180
|
+
# dp = CommandPacketHeader(type=s[0], size=s[1], crc16=s[2], instruction=s[3], target=s[4], message=s[5:18],
|
|
181
|
+
# padding=None)
|
|
182
|
+
#
|
|
183
|
+
# # hand over packets to interested parties...
|
|
184
|
+
# for fn_packet_callback in self.packet_callbacks:
|
|
185
|
+
# try:
|
|
186
|
+
# fn_packet_callback(dp)
|
|
187
|
+
# except BaseException as e:
|
|
188
|
+
# logging.critical(e)
|
|
189
|
+
|
|
190
|
+
def connection_lost(self, exc):
|
|
191
|
+
if exc:
|
|
192
|
+
print('Serial connection loss: ', exc)
|
|
193
|
+
logging.debug(f'Serial connection loss: {exc}')
|
|
194
|
+
traceback.print_exc()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Read a pinSheet.json and turn it into display labels for the web interface.
|
|
2
|
+
|
|
3
|
+
The same file describes a recording for the .b64 reader utilities, so the channel
|
|
4
|
+
names used here are the ones in the sheet's "name" fields ("digital_input_0",
|
|
5
|
+
"analog_input_3", ...). Those happen to be exactly the names the web interface
|
|
6
|
+
generates for its traces, which is what makes the substitution a plain lookup.
|
|
7
|
+
|
|
8
|
+
A pin's "name" only says where it sits on the Teensy; "for" says what it is wired
|
|
9
|
+
to in a given rig, and that is what the labels should show. "for" is null for the
|
|
10
|
+
pins that are not used, and those keep their generated name.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_pin_labels(path):
|
|
18
|
+
"""Map channel name -> display label for every channel the pin sheet describes.
|
|
19
|
+
|
|
20
|
+
Channels the sheet has no meaning for are simply absent from the result, so
|
|
21
|
+
the web interface falls back to its own name for them.
|
|
22
|
+
|
|
23
|
+
Raises SystemExit if the file cannot be read or is not a pin sheet. The
|
|
24
|
+
option is only ever given explicitly, so a mistyped path should be reported
|
|
25
|
+
rather than quietly ignored; individual unusable entries are only warned
|
|
26
|
+
about, since one bad line should not cost you the other labels.
|
|
27
|
+
"""
|
|
28
|
+
path = Path(path)
|
|
29
|
+
try:
|
|
30
|
+
with path.open() as f:
|
|
31
|
+
sheet = json.load(f)
|
|
32
|
+
except OSError as e:
|
|
33
|
+
logging.error(f'Cannot read pin sheet {path}: {e}')
|
|
34
|
+
raise SystemExit(1)
|
|
35
|
+
except json.JSONDecodeError as e:
|
|
36
|
+
logging.error(f'Pin sheet {path} is not valid JSON: {e}')
|
|
37
|
+
raise SystemExit(1)
|
|
38
|
+
|
|
39
|
+
if not isinstance(sheet, dict):
|
|
40
|
+
logging.error(f'Pin sheet {path} should hold a JSON object, found {type(sheet).__name__}.')
|
|
41
|
+
raise SystemExit(1)
|
|
42
|
+
|
|
43
|
+
labels = _pin_labels(sheet.get('pins'), path)
|
|
44
|
+
labels.update(_state_labels(sheet.get('states'), path))
|
|
45
|
+
|
|
46
|
+
logging.info(f'Pin sheet {path} ({sheet.get("title", "untitled")}): '
|
|
47
|
+
f'{len(labels)} channels labelled.')
|
|
48
|
+
return labels
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _pin_labels(pins, path):
|
|
52
|
+
"""Labels from the "pins" section, e.g. name 'digital_input_2', for 'Wheel Encoder A'."""
|
|
53
|
+
labels = {}
|
|
54
|
+
if pins is None:
|
|
55
|
+
logging.warning(f'Pin sheet {path} has no "pins" section.')
|
|
56
|
+
return labels
|
|
57
|
+
if not isinstance(pins, list):
|
|
58
|
+
logging.warning(f'Pin sheet {path}: "pins" should be a list, found '
|
|
59
|
+
f'{type(pins).__name__}. Ignoring it.')
|
|
60
|
+
return labels
|
|
61
|
+
|
|
62
|
+
for entry in pins:
|
|
63
|
+
if not isinstance(entry, dict):
|
|
64
|
+
logging.warning(f'Pin sheet {path}: skipping non-object pin entry {entry!r}.')
|
|
65
|
+
continue
|
|
66
|
+
name, meaning = entry.get('name'), entry.get('for')
|
|
67
|
+
if not isinstance(name, str) or not name:
|
|
68
|
+
logging.warning(f'Pin sheet {path}: skipping pin entry without a "name": {entry!r}.')
|
|
69
|
+
continue
|
|
70
|
+
# An unused pin has "for": null, which is not an error: it just means the
|
|
71
|
+
# interface should go on calling that channel by its own name.
|
|
72
|
+
if isinstance(meaning, str) and meaning.strip():
|
|
73
|
+
labels[name] = meaning.strip()
|
|
74
|
+
return labels
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _state_labels(states, path):
|
|
78
|
+
"""Labels from the "states" section, e.g. idx 0, name 'uncorrected_distance'.
|
|
79
|
+
|
|
80
|
+
States have no "for" field: unlike a pin, a state's name already *is* its
|
|
81
|
+
meaning. The web interface numbers these channels states_0 ... states_7.
|
|
82
|
+
"""
|
|
83
|
+
labels = {}
|
|
84
|
+
if states is None:
|
|
85
|
+
return labels
|
|
86
|
+
if not isinstance(states, list):
|
|
87
|
+
logging.warning(f'Pin sheet {path}: "states" should be a list, found '
|
|
88
|
+
f'{type(states).__name__}. Ignoring it.')
|
|
89
|
+
return labels
|
|
90
|
+
|
|
91
|
+
for entry in states:
|
|
92
|
+
if not isinstance(entry, dict):
|
|
93
|
+
logging.warning(f'Pin sheet {path}: skipping non-object state entry {entry!r}.')
|
|
94
|
+
continue
|
|
95
|
+
idx, name = entry.get('idx'), entry.get('name')
|
|
96
|
+
# bool is an int subclass, and "idx": true is not an index.
|
|
97
|
+
if not isinstance(idx, int) or isinstance(idx, bool) or not isinstance(name, str) or not name.strip():
|
|
98
|
+
logging.warning(f'Pin sheet {path}: skipping unusable state entry {entry!r}.')
|
|
99
|
+
continue
|
|
100
|
+
labels[f'states_{idx}'] = name.strip()
|
|
101
|
+
return labels
|