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