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,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