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,268 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import http.server
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import socketserver
|
|
8
|
+
import threading
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from .websocket_server import WebsocketServer as ReconnectingWebsocketServer
|
|
15
|
+
|
|
16
|
+
WS_PORT = 5678
|
|
17
|
+
HTTP_PORT = 8000
|
|
18
|
+
|
|
19
|
+
# web/ lives inside this package rather than beside it, so the build backend ships it
|
|
20
|
+
# without any package-data declaration to keep in step -- get that wrong and the wheel
|
|
21
|
+
# installs an interface with no JS. importlib.resources then finds it wherever the
|
|
22
|
+
# package ends up, with no '..' arithmetic that a move would silently invalidate.
|
|
23
|
+
#
|
|
24
|
+
# os.fspath() because SimpleHTTPRequestHandler(directory=) and read_text() both want a
|
|
25
|
+
# real filesystem path. For a normally installed wheel the traversable *is* one; if the
|
|
26
|
+
# package were ever zipimported this raises instead of quietly serving nothing.
|
|
27
|
+
WEB_DIRECTORY = os.fspath(resources.files(__package__) / 'web')
|
|
28
|
+
|
|
29
|
+
# The pin sheet labels are baked into index.html as window.CHANNEL_LABELS. This route
|
|
30
|
+
# serves the same mapping as JSON, for checking what the server thinks the labels are
|
|
31
|
+
# ("curl http://localhost:8000/channel_labels.json"); the page does not depend on it.
|
|
32
|
+
LABELS_ROUTE = '/channel_labels.json'
|
|
33
|
+
INDEX_ROUTES = ('/', '/index.html')
|
|
34
|
+
|
|
35
|
+
# Everything in web/ is served under a /v<token>/ prefix, and a request for the index
|
|
36
|
+
# without one is redirected to the current token.
|
|
37
|
+
#
|
|
38
|
+
# A browser told nothing about how long a file stays fresh guesses, and it guesses from
|
|
39
|
+
# the file's date: index.html is dated 2024, which buys months of reuse without ever
|
|
40
|
+
# asking the server again. So an interface.js edited today keeps being served from
|
|
41
|
+
# cache, and that is what makes changes to the interface appear in one tab and not the
|
|
42
|
+
# next -- the Reload and Reset buttons open a new tab, which reads the cache, rather
|
|
43
|
+
# than reloading, which revalidates. Under the prefix, the relative URLs in index.html
|
|
44
|
+
# *and* the module imports inside interface.js all resolve to a URL the cache has never
|
|
45
|
+
# seen as soon as any one file changes, so there is nothing for it to reuse.
|
|
46
|
+
VERSION_PREFIX = re.compile(r'^/v[0-9a-f]+(?=/)')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def asset_version():
|
|
50
|
+
"""Cache-busting token for web/: the newest modification time in it.
|
|
51
|
+
|
|
52
|
+
Not a random value per run: as long as nothing changes, the URLs stay the same and
|
|
53
|
+
the browser can go on caching. It is only an edit that invalidates them.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
newest = max(p.stat().st_mtime_ns
|
|
57
|
+
for p in Path(WEB_DIRECTORY).rglob('*') if p.is_file())
|
|
58
|
+
except (OSError, ValueError):
|
|
59
|
+
# A web directory that cannot be read, or holds nothing, is a problem that the
|
|
60
|
+
# request for index.html will report; here just fall back to busting nothing.
|
|
61
|
+
newest = 0
|
|
62
|
+
return f'v{newest:x}'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def num_to_bits(num, nbits=16, reverse=True):
|
|
66
|
+
"""Integer to list of bits"""
|
|
67
|
+
# TODO: pad zeros with string formatter
|
|
68
|
+
bl = list(map(int, bin(num)[2:]))
|
|
69
|
+
# pad with leading zeros
|
|
70
|
+
bl = [0] * (nbits - len(bl)) + bl
|
|
71
|
+
return list(reversed(bl)) if reverse else bl
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class NumpyEncoder(json.JSONEncoder):
|
|
75
|
+
def default(self, obj):
|
|
76
|
+
if isinstance(obj, np.ndarray):
|
|
77
|
+
return obj.tolist()
|
|
78
|
+
return json.JSONEncoder.default(self, obj)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class HttpRequestHandler(http.server.SimpleHTTPRequestHandler):
|
|
82
|
+
# Set per request by parse_request(); a default keeps a request that never got
|
|
83
|
+
# that far from tripping over the attribute.
|
|
84
|
+
versioned = False
|
|
85
|
+
|
|
86
|
+
def __init__(self, *args, channel_labels=None, **kwargs):
|
|
87
|
+
# BaseRequestHandler.__init__() serves the request there and then, so
|
|
88
|
+
# everything do_GET needs has to be in place before delegating to it.
|
|
89
|
+
self.channel_labels = channel_labels or {}
|
|
90
|
+
super().__init__(*args, directory=WEB_DIRECTORY, **kwargs)
|
|
91
|
+
|
|
92
|
+
def parse_request(self):
|
|
93
|
+
"""Take the cache-busting prefix off the path, so nothing below sees it.
|
|
94
|
+
|
|
95
|
+
Done here rather than in do_GET because it is the one place every method
|
|
96
|
+
passes through, and translate_path resolves self.path against web/ where a
|
|
97
|
+
/v<token>/ prefix would just look like a directory that does not exist.
|
|
98
|
+
"""
|
|
99
|
+
if not super().parse_request():
|
|
100
|
+
return False
|
|
101
|
+
self.versioned = VERSION_PREFIX.match(self.path) is not None
|
|
102
|
+
self.path = VERSION_PREFIX.sub('', self.path, count=1)
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
def do_GET(self):
|
|
106
|
+
if not self.serve_route():
|
|
107
|
+
super().do_GET()
|
|
108
|
+
|
|
109
|
+
def do_HEAD(self):
|
|
110
|
+
# Routed like GET, so that a HEAD cannot describe a different resource than
|
|
111
|
+
# the one a GET returns -- which is exactly what a cache would go on to store.
|
|
112
|
+
if not self.serve_route():
|
|
113
|
+
super().do_HEAD()
|
|
114
|
+
|
|
115
|
+
def serve_route(self):
|
|
116
|
+
"""Serve the index with labels baked in, or the labels route from memory.
|
|
117
|
+
|
|
118
|
+
False when the path is neither, meaning it is a file to be read from disk.
|
|
119
|
+
"""
|
|
120
|
+
path = self.path.partition('?')[0]
|
|
121
|
+
if path in INDEX_ROUTES:
|
|
122
|
+
if self.versioned:
|
|
123
|
+
self.send_index()
|
|
124
|
+
else:
|
|
125
|
+
# A bookmark, or the URL totalsync opens. Send the browser to the
|
|
126
|
+
# prefixed index, so that page's assets are fetched from under the
|
|
127
|
+
# prefix too.
|
|
128
|
+
self.redirect_to_current_version()
|
|
129
|
+
elif path == LABELS_ROUTE:
|
|
130
|
+
self.send_body(json.dumps(self.channel_labels).encode(), 'application/json')
|
|
131
|
+
else:
|
|
132
|
+
return False
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
def redirect_to_current_version(self):
|
|
136
|
+
self.send_response(302)
|
|
137
|
+
self.send_header('Location', f'/{asset_version()}/index.html')
|
|
138
|
+
self.send_header('Content-Length', '0')
|
|
139
|
+
self.end_headers()
|
|
140
|
+
|
|
141
|
+
def send_index(self):
|
|
142
|
+
"""Serve index.html with the channel labels baked into the document.
|
|
143
|
+
|
|
144
|
+
The labels belong in the page that builds the interface. Fetching them
|
|
145
|
+
separately means the interface can be built before, or entirely without,
|
|
146
|
+
them, and a request that quietly fails leaves every trace showing its
|
|
147
|
+
default name with nothing to say why.
|
|
148
|
+
"""
|
|
149
|
+
index = Path(WEB_DIRECTORY) / 'index.html'
|
|
150
|
+
try:
|
|
151
|
+
html = index.read_text()
|
|
152
|
+
except OSError as e:
|
|
153
|
+
logging.error(f'Cannot read {index}: {e}')
|
|
154
|
+
self.send_error(404, 'index.html not found')
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
# A literal "</script>" in the JSON would close the block early; escaping the
|
|
158
|
+
# three characters that can start markup keeps whatever a pin sheet contains
|
|
159
|
+
# inert. json.dumps has already dealt with quotes and backslashes.
|
|
160
|
+
payload = (json.dumps(self.channel_labels)
|
|
161
|
+
.replace('<', '\\u003c').replace('>', '\\u003e').replace('&', '\\u0026'))
|
|
162
|
+
# A classic script in the head runs before the deferred module that reads it.
|
|
163
|
+
tag = f'<script>window.CHANNEL_LABELS = {payload};</script>\n'
|
|
164
|
+
if '</head>' in html:
|
|
165
|
+
html = html.replace('</head>', tag + '</head>', 1)
|
|
166
|
+
else:
|
|
167
|
+
logging.warning(f'{index} has no </head>: serving it without channel labels.')
|
|
168
|
+
|
|
169
|
+
self.send_body(html.encode(), 'text/html; charset=utf-8')
|
|
170
|
+
|
|
171
|
+
def send_body(self, body, content_type):
|
|
172
|
+
self.send_response(200)
|
|
173
|
+
self.send_header('Content-Type', content_type)
|
|
174
|
+
self.send_header('Content-Length', str(len(body)))
|
|
175
|
+
self.end_headers()
|
|
176
|
+
if self.command != 'HEAD':
|
|
177
|
+
self.wfile.write(body)
|
|
178
|
+
|
|
179
|
+
def end_headers(self):
|
|
180
|
+
# Says what SimpleHTTPRequestHandler leaves the browser to guess (see
|
|
181
|
+
# VERSION_PREFIX). Belt and braces next to the versioned URLs, and what keeps
|
|
182
|
+
# a tab left open for a week honest. It still allows a cheap 304; it only
|
|
183
|
+
# forbids reusing a copy without checking first.
|
|
184
|
+
self.send_header('Cache-Control', 'no-cache')
|
|
185
|
+
super().end_headers()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class HTTPServer(threading.Thread):
|
|
189
|
+
def __init__(self, http_port, channel_labels=None):
|
|
190
|
+
super(HTTPServer, self).__init__(daemon=True)
|
|
191
|
+
|
|
192
|
+
logging.info(f"Launching HTTP server for directory {WEB_DIRECTORY} on port {http_port}")
|
|
193
|
+
|
|
194
|
+
# allow reuse of previously bound endpoints
|
|
195
|
+
socketserver.TCPServer.allow_reuse_address = True
|
|
196
|
+
# The handler class is instantiated per request, so bind the labels to it
|
|
197
|
+
# rather than reaching for a module global.
|
|
198
|
+
handler = functools.partial(HttpRequestHandler, channel_labels=channel_labels)
|
|
199
|
+
self.server = socketserver.TCPServer(("", http_port), handler)
|
|
200
|
+
|
|
201
|
+
def run(self):
|
|
202
|
+
self.server.serve_forever()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class WSServer(threading.Thread):
|
|
206
|
+
def __init__(self, ws_port):
|
|
207
|
+
super(WSServer, self).__init__(daemon=True)
|
|
208
|
+
logging.info(f"Starting Websocket Server on port {ws_port}")
|
|
209
|
+
self.server = ReconnectingWebsocketServer(host="0.0.0.0", port=ws_port)
|
|
210
|
+
self.server.set_fn_new_client(self.ws_client_connect)
|
|
211
|
+
self.server.set_fn_client_left(self.ws_client_left)
|
|
212
|
+
self.server.set_fn_message_received(self.ws_msg_rcv)
|
|
213
|
+
self.msg_callback = None
|
|
214
|
+
|
|
215
|
+
def run(self):
|
|
216
|
+
self.server.run_forever()
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def ws_client_connect(client, server):
|
|
220
|
+
client_id = "Unknown" if client is None else client["id"]
|
|
221
|
+
logging.debug(f'Client {client_id} connected to WebSocket server.')
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def ws_client_left(client, server):
|
|
225
|
+
client_id = "Unknown" if client is None else client["id"]
|
|
226
|
+
logging.debug(f'Client {client_id} disconnected from WebSocket server.')
|
|
227
|
+
|
|
228
|
+
def ws_msg_rcv(self, client, server, message):
|
|
229
|
+
# TODO: Match pinout, which pins are input, which output
|
|
230
|
+
logging.debug(f"WS_msg {message} from {client}")
|
|
231
|
+
if self.msg_callback is None:
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
if message.startswith('digital'):
|
|
236
|
+
pin_type, pin_direction, instruction, pin = message.split('_')
|
|
237
|
+
instr = {'instruction': instruction, 'data': [(int(pin), 1)]}
|
|
238
|
+
logging.debug(instr)
|
|
239
|
+
self.msg_callback(instr)
|
|
240
|
+
else:
|
|
241
|
+
logging.debug('Unknown WS Message:' + message)
|
|
242
|
+
except BaseException as e:
|
|
243
|
+
logging.error(e)
|
|
244
|
+
|
|
245
|
+
def handle_packet(self, packet):
|
|
246
|
+
if packet.type == 0:
|
|
247
|
+
js = json.dumps({'us_start': packet.us_start, 'us_end': packet.us_end,
|
|
248
|
+
'analog': packet.analog, 'states': packet.states,
|
|
249
|
+
'digitalIn': num_to_bits(packet.digitalIn, nbits=16),
|
|
250
|
+
'digitalOut': num_to_bits(packet.digitalOut, nbits=16)},
|
|
251
|
+
cls=NumpyEncoder)
|
|
252
|
+
self.server.send_message_to_all(js)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class WebInterface:
|
|
256
|
+
def __init__(self, http_port, ws_port, teensy_commander, channel_labels=None):
|
|
257
|
+
self.http_port = http_port
|
|
258
|
+
self.ws_port = ws_port
|
|
259
|
+
self.http_server = HTTPServer(http_port=self.http_port, channel_labels=channel_labels)
|
|
260
|
+
self.tc = teensy_commander
|
|
261
|
+
self.ws_server = WSServer(ws_port=self.ws_port)
|
|
262
|
+
self.ws_server.msg_callback = self.tc.send
|
|
263
|
+
|
|
264
|
+
self.http_server.start()
|
|
265
|
+
self.ws_server.start()
|
|
266
|
+
|
|
267
|
+
def handle_packet(self, packet):
|
|
268
|
+
self.ws_server.handle_packet(packet)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2018 Johan Hanssen Seferidis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .websocket_server import *
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# Author: Johan Hanssen Seferidis
|
|
2
|
+
# License: MIT
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
import struct
|
|
6
|
+
from base64 import b64encode
|
|
7
|
+
from hashlib import sha1
|
|
8
|
+
import logging
|
|
9
|
+
from socket import error as SocketError
|
|
10
|
+
import errno
|
|
11
|
+
|
|
12
|
+
if sys.version_info[0] < 3:
|
|
13
|
+
from SocketServer import ThreadingMixIn, TCPServer, StreamRequestHandler
|
|
14
|
+
else:
|
|
15
|
+
from socketserver import ThreadingMixIn, TCPServer, StreamRequestHandler
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
logging.basicConfig()
|
|
19
|
+
|
|
20
|
+
'''
|
|
21
|
+
+-+-+-+-+-------+-+-------------+-------------------------------+
|
|
22
|
+
0 1 2 3
|
|
23
|
+
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
|
24
|
+
+-+-+-+-+-------+-+-------------+-------------------------------+
|
|
25
|
+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|
|
26
|
+
|I|S|S|S| (4) |A| (7) | (16/64) |
|
|
27
|
+
|N|V|V|V| |S| | (if payload len==126/127) |
|
|
28
|
+
| |1|2|3| |K| | |
|
|
29
|
+
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
|
|
30
|
+
| Extended payload length continued, if payload len == 127 |
|
|
31
|
+
+ - - - - - - - - - - - - - - - +-------------------------------+
|
|
32
|
+
| Payload Data continued ... |
|
|
33
|
+
+---------------------------------------------------------------+
|
|
34
|
+
'''
|
|
35
|
+
|
|
36
|
+
FIN = 0x80
|
|
37
|
+
OPCODE = 0x0f
|
|
38
|
+
MASKED = 0x80
|
|
39
|
+
PAYLOAD_LEN = 0x7f
|
|
40
|
+
PAYLOAD_LEN_EXT16 = 0x7e
|
|
41
|
+
PAYLOAD_LEN_EXT64 = 0x7f
|
|
42
|
+
|
|
43
|
+
OPCODE_CONTINUATION = 0x0
|
|
44
|
+
OPCODE_TEXT = 0x1
|
|
45
|
+
OPCODE_BINARY = 0x2
|
|
46
|
+
OPCODE_CLOSE_CONN = 0x8
|
|
47
|
+
OPCODE_PING = 0x9
|
|
48
|
+
OPCODE_PONG = 0xA
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# -------------------------------- API ---------------------------------
|
|
52
|
+
|
|
53
|
+
class API():
|
|
54
|
+
|
|
55
|
+
def run_forever(self):
|
|
56
|
+
try:
|
|
57
|
+
logger.info("Listening on port %d for clients.." % self.port)
|
|
58
|
+
self.serve_forever()
|
|
59
|
+
except KeyboardInterrupt:
|
|
60
|
+
self.server_close()
|
|
61
|
+
logger.info("Server terminated.")
|
|
62
|
+
except Exception as e:
|
|
63
|
+
logger.error(str(e), exc_info=True)
|
|
64
|
+
exit(1)
|
|
65
|
+
|
|
66
|
+
def new_client(self, client, server):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
def client_left(self, client, server):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
def message_received(self, client, server, message):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def set_fn_new_client(self, fn):
|
|
76
|
+
self.new_client = fn
|
|
77
|
+
|
|
78
|
+
def set_fn_client_left(self, fn):
|
|
79
|
+
self.client_left = fn
|
|
80
|
+
|
|
81
|
+
def set_fn_message_received(self, fn):
|
|
82
|
+
self.message_received = fn
|
|
83
|
+
|
|
84
|
+
def send_message(self, client, msg):
|
|
85
|
+
self._unicast_(client, msg)
|
|
86
|
+
|
|
87
|
+
def send_message_to_all(self, msg):
|
|
88
|
+
self._multicast_(msg)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ------------------------- Implementation -----------------------------
|
|
92
|
+
|
|
93
|
+
class WebsocketServer(ThreadingMixIn, TCPServer, API):
|
|
94
|
+
"""
|
|
95
|
+
A websocket server waiting for clients to connect.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
port(int): Port to bind to
|
|
99
|
+
host(str): Hostname or IP to listen for connections. By default 127.0.0.1
|
|
100
|
+
is being used. To accept connections from any client, you should use
|
|
101
|
+
0.0.0.0.
|
|
102
|
+
loglevel: Logging level from logging module to use for logging. By default
|
|
103
|
+
warnings and errors are being logged.
|
|
104
|
+
|
|
105
|
+
Properties:
|
|
106
|
+
clients(list): A list of connected clients. A client is a dictionary
|
|
107
|
+
like below.
|
|
108
|
+
{
|
|
109
|
+
'id' : id,
|
|
110
|
+
'handler' : handler,
|
|
111
|
+
'address' : (addr, port)
|
|
112
|
+
}
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
allow_reuse_address = True
|
|
116
|
+
daemon_threads = True # comment to keep threads alive until finished
|
|
117
|
+
|
|
118
|
+
clients = []
|
|
119
|
+
id_counter = 0
|
|
120
|
+
|
|
121
|
+
def __init__(self, port, host='127.0.0.1', loglevel=logging.WARNING):
|
|
122
|
+
logger.setLevel(loglevel)
|
|
123
|
+
TCPServer.__init__(self, (host, port), WebSocketHandler)
|
|
124
|
+
self.port = self.socket.getsockname()[1]
|
|
125
|
+
|
|
126
|
+
def _message_received_(self, handler, msg):
|
|
127
|
+
self.message_received(self.handler_to_client(handler), self, msg)
|
|
128
|
+
|
|
129
|
+
def _ping_received_(self, handler, msg):
|
|
130
|
+
handler.send_pong(msg)
|
|
131
|
+
|
|
132
|
+
def _pong_received_(self, handler, msg):
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
def _new_client_(self, handler):
|
|
136
|
+
self.id_counter += 1
|
|
137
|
+
client = {
|
|
138
|
+
'id': self.id_counter,
|
|
139
|
+
'handler': handler,
|
|
140
|
+
'address': handler.client_address
|
|
141
|
+
}
|
|
142
|
+
self.clients.append(client)
|
|
143
|
+
self.new_client(client, self)
|
|
144
|
+
|
|
145
|
+
def _client_left_(self, handler):
|
|
146
|
+
client = self.handler_to_client(handler)
|
|
147
|
+
self.client_left(client, self)
|
|
148
|
+
if client in self.clients:
|
|
149
|
+
self.clients.remove(client)
|
|
150
|
+
|
|
151
|
+
def _unicast_(self, to_client, msg):
|
|
152
|
+
to_client['handler'].send_message(msg)
|
|
153
|
+
|
|
154
|
+
def _multicast_(self, msg):
|
|
155
|
+
for client in self.clients:
|
|
156
|
+
self._unicast_(client, msg)
|
|
157
|
+
|
|
158
|
+
def handler_to_client(self, handler):
|
|
159
|
+
for client in self.clients:
|
|
160
|
+
if client['handler'] == handler:
|
|
161
|
+
return client
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class WebSocketHandler(StreamRequestHandler):
|
|
165
|
+
|
|
166
|
+
def __init__(self, socket, addr, server):
|
|
167
|
+
self.server = server
|
|
168
|
+
StreamRequestHandler.__init__(self, socket, addr, server)
|
|
169
|
+
|
|
170
|
+
def setup(self):
|
|
171
|
+
StreamRequestHandler.setup(self)
|
|
172
|
+
self.keep_alive = True
|
|
173
|
+
self.handshake_done = False
|
|
174
|
+
self.valid_client = False
|
|
175
|
+
|
|
176
|
+
def handle(self):
|
|
177
|
+
while self.keep_alive:
|
|
178
|
+
if not self.handshake_done:
|
|
179
|
+
try:
|
|
180
|
+
self.handshake()
|
|
181
|
+
except AssertionError:
|
|
182
|
+
logging.debug(f"Websocket handshake failed for client {self.client_address}. Most likely a stale connection.")
|
|
183
|
+
self.keep_alive = False
|
|
184
|
+
elif self.valid_client:
|
|
185
|
+
self.read_next_message()
|
|
186
|
+
|
|
187
|
+
def read_bytes(self, num):
|
|
188
|
+
# python3 gives ordinal of byte directly
|
|
189
|
+
bytes = self.rfile.read(num)
|
|
190
|
+
if sys.version_info[0] < 3:
|
|
191
|
+
return map(ord, bytes)
|
|
192
|
+
else:
|
|
193
|
+
return bytes
|
|
194
|
+
|
|
195
|
+
def read_next_message(self):
|
|
196
|
+
try:
|
|
197
|
+
b1, b2 = self.read_bytes(2)
|
|
198
|
+
except SocketError as e: # to be replaced with ConnectionResetError for py3
|
|
199
|
+
if e.errno == errno.ECONNRESET:
|
|
200
|
+
logger.info("Client closed connection.")
|
|
201
|
+
self.keep_alive = 0
|
|
202
|
+
return
|
|
203
|
+
b1, b2 = 0, 0
|
|
204
|
+
except ValueError as e:
|
|
205
|
+
b1, b2 = 0, 0
|
|
206
|
+
|
|
207
|
+
fin = b1 & FIN
|
|
208
|
+
opcode = b1 & OPCODE
|
|
209
|
+
masked = b2 & MASKED
|
|
210
|
+
payload_length = b2 & PAYLOAD_LEN
|
|
211
|
+
|
|
212
|
+
if opcode == OPCODE_CLOSE_CONN:
|
|
213
|
+
logger.info("Client asked to close connection.")
|
|
214
|
+
self.keep_alive = 0
|
|
215
|
+
return
|
|
216
|
+
if not masked:
|
|
217
|
+
logger.debug("Client must always be masked.")
|
|
218
|
+
self.keep_alive = 0
|
|
219
|
+
return
|
|
220
|
+
if opcode == OPCODE_CONTINUATION:
|
|
221
|
+
logger.warn("Continuation frames are not supported.")
|
|
222
|
+
return
|
|
223
|
+
elif opcode == OPCODE_BINARY:
|
|
224
|
+
logger.warn("Binary frames are not supported.")
|
|
225
|
+
return
|
|
226
|
+
elif opcode == OPCODE_TEXT:
|
|
227
|
+
opcode_handler = self.server._message_received_
|
|
228
|
+
elif opcode == OPCODE_PING:
|
|
229
|
+
opcode_handler = self.server._ping_received_
|
|
230
|
+
elif opcode == OPCODE_PONG:
|
|
231
|
+
opcode_handler = self.server._pong_received_
|
|
232
|
+
else:
|
|
233
|
+
logger.warn("Unknown opcode %#x." % opcode)
|
|
234
|
+
self.keep_alive = 0
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
if payload_length == 126:
|
|
238
|
+
payload_length = struct.unpack(">H", self.rfile.read(2))[0]
|
|
239
|
+
elif payload_length == 127:
|
|
240
|
+
payload_length = struct.unpack(">Q", self.rfile.read(8))[0]
|
|
241
|
+
|
|
242
|
+
masks = self.read_bytes(4)
|
|
243
|
+
message_bytes = bytearray()
|
|
244
|
+
for message_byte in self.read_bytes(payload_length):
|
|
245
|
+
message_byte ^= masks[len(message_bytes) % 4]
|
|
246
|
+
message_bytes.append(message_byte)
|
|
247
|
+
opcode_handler(self, message_bytes.decode('utf8'))
|
|
248
|
+
|
|
249
|
+
def send_message(self, message):
|
|
250
|
+
self.send_text(message)
|
|
251
|
+
|
|
252
|
+
def send_pong(self, message):
|
|
253
|
+
self.send_text(message, OPCODE_PONG)
|
|
254
|
+
|
|
255
|
+
def send_text(self, message, opcode=OPCODE_TEXT):
|
|
256
|
+
"""
|
|
257
|
+
Important: Fragmented(=continuation) messages are not supported since
|
|
258
|
+
their usage cases are limited - when we don't know the payload length.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
# Validate message
|
|
262
|
+
if isinstance(message, bytes):
|
|
263
|
+
message = try_decode_UTF8(message) # this is slower but ensures we have UTF-8
|
|
264
|
+
if not message:
|
|
265
|
+
logger.warning("Can\'t send message, message is not valid UTF-8")
|
|
266
|
+
return False
|
|
267
|
+
elif sys.version_info < (3,0) and (isinstance(message, str) or isinstance(message, unicode)):
|
|
268
|
+
pass
|
|
269
|
+
elif isinstance(message, str):
|
|
270
|
+
pass
|
|
271
|
+
else:
|
|
272
|
+
logger.warning('Can\'t send message, message has to be a string or bytes. Given type is %s' % type(message))
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
header = bytearray()
|
|
276
|
+
payload = encode_to_UTF8(message)
|
|
277
|
+
payload_length = len(payload)
|
|
278
|
+
|
|
279
|
+
# Normal payload
|
|
280
|
+
if payload_length <= 125:
|
|
281
|
+
header.append(FIN | opcode)
|
|
282
|
+
header.append(payload_length)
|
|
283
|
+
|
|
284
|
+
# Extended payload
|
|
285
|
+
elif payload_length >= 126 and payload_length <= 65535:
|
|
286
|
+
header.append(FIN | opcode)
|
|
287
|
+
header.append(PAYLOAD_LEN_EXT16)
|
|
288
|
+
header.extend(struct.pack(">H", payload_length))
|
|
289
|
+
|
|
290
|
+
# Huge extended payload
|
|
291
|
+
elif payload_length < 18446744073709551616:
|
|
292
|
+
header.append(FIN | opcode)
|
|
293
|
+
header.append(PAYLOAD_LEN_EXT64)
|
|
294
|
+
header.extend(struct.pack(">Q", payload_length))
|
|
295
|
+
|
|
296
|
+
else:
|
|
297
|
+
raise Exception("Message is too big. Consider breaking it into chunks.")
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
self.request.send(header + payload)
|
|
301
|
+
|
|
302
|
+
def read_http_headers(self):
|
|
303
|
+
headers = {}
|
|
304
|
+
# first line should be HTTP GET
|
|
305
|
+
http_get = self.rfile.readline().decode().strip()
|
|
306
|
+
if not http_get.upper().startswith('GET'):
|
|
307
|
+
if http_get:
|
|
308
|
+
logging.error('faulty get "{}"'.format(http_get))
|
|
309
|
+
assert http_get.upper().startswith('GET')
|
|
310
|
+
# remaining should be headers
|
|
311
|
+
while True:
|
|
312
|
+
header = self.rfile.readline().decode().strip()
|
|
313
|
+
if not header:
|
|
314
|
+
break
|
|
315
|
+
head, value = header.split(':', 1)
|
|
316
|
+
headers[head.lower().strip()] = value.strip()
|
|
317
|
+
return headers
|
|
318
|
+
|
|
319
|
+
def handshake(self):
|
|
320
|
+
headers = self.read_http_headers()
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
assert headers['upgrade'].lower() == 'websocket'
|
|
324
|
+
except AssertionError:
|
|
325
|
+
self.keep_alive = False
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
key = headers['sec-websocket-key']
|
|
330
|
+
except KeyError:
|
|
331
|
+
logger.warning("Client tried to connect but was missing a key")
|
|
332
|
+
self.keep_alive = False
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
response = self.make_handshake_response(key)
|
|
336
|
+
self.handshake_done = self.request.send(response.encode())
|
|
337
|
+
self.valid_client = True
|
|
338
|
+
self.server._new_client_(self)
|
|
339
|
+
|
|
340
|
+
@classmethod
|
|
341
|
+
def make_handshake_response(cls, key):
|
|
342
|
+
return \
|
|
343
|
+
'HTTP/1.1 101 Switching Protocols\r\n'\
|
|
344
|
+
'Upgrade: websocket\r\n' \
|
|
345
|
+
'Connection: Upgrade\r\n' \
|
|
346
|
+
'Sec-WebSocket-Accept: %s\r\n' \
|
|
347
|
+
'\r\n' % cls.calculate_response_key(key)
|
|
348
|
+
|
|
349
|
+
@classmethod
|
|
350
|
+
def calculate_response_key(cls, key):
|
|
351
|
+
GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
352
|
+
hash = sha1(key.encode() + GUID.encode())
|
|
353
|
+
response_key = b64encode(hash.digest()).strip()
|
|
354
|
+
return response_key.decode('ASCII')
|
|
355
|
+
|
|
356
|
+
def finish(self):
|
|
357
|
+
self.server._client_left_(self)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def encode_to_UTF8(data):
|
|
361
|
+
try:
|
|
362
|
+
return data.encode('UTF-8')
|
|
363
|
+
except UnicodeEncodeError as e:
|
|
364
|
+
logger.error("Could not encode data to UTF-8 -- %s" % e)
|
|
365
|
+
return False
|
|
366
|
+
except Exception as e:
|
|
367
|
+
raise(e)
|
|
368
|
+
return False
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def try_decode_UTF8(data):
|
|
372
|
+
try:
|
|
373
|
+
return data.decode('utf-8')
|
|
374
|
+
except UnicodeDecodeError:
|
|
375
|
+
return False
|
|
376
|
+
except Exception as e:
|
|
377
|
+
raise(e)
|