39 | [ back to network selection ] 40 |
41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /build/minify.py: -------------------------------------------------------------------------------- 1 | """ 2 | Minify build/ to min/ 3 | 1. (Broken - too much memory) Concatenate lib/ scripts into one minified file 4 | 2. Update imports & minify other python scripts 5 | 3. Minify html/js/css files 6 | 4. Copy everything else (TODO other compression methods) 7 | 5. If __main__, sync to Pico 8 | 9 | Prerequisites: 10 | pip3 install python_minifier 11 | npm install -g html-minifier 12 | """ 13 | import os, re, json 14 | try: import python_minifier 15 | except: python_minifier = None 16 | 17 | 18 | def minify(): 19 | print('\nRun python/html/js/css minifier') 20 | _formatBuildFile = lambda x: x.replace('build/out', '') 21 | 22 | lib_scripts = set() 23 | script_paths = [] 24 | for root, dir, files in os.walk('build/out'): 25 | for file in files: 26 | match = re.search(r'\.py$', file) 27 | if match: 28 | path = os.path.join(root, file) 29 | script_paths.append(path) 30 | 31 | if lib_scripts: 32 | print('\nConcatenate:') 33 | [print(_formatBuildFile(x)) for x in sorted(lib_scripts)] 34 | print('\nUpdate imports:') 35 | [print(_formatBuildFile(x)) for x in sorted(script_paths) if x not in lib_scripts] 36 | 37 | """ 38 | Read files & order according to imports 39 | e.g. if A imports B, C imports A, order as B C A 40 | """ 41 | user_modules = {} 42 | script_contents = {} 43 | dep_tree = {} 44 | _parse_first_regex_group = lambda x: x.group(1) if x else None 45 | for path in script_paths: 46 | with open(path, 'r') as f: 47 | lines = [] 48 | deps = set() 49 | for line in f.readlines(): 50 | module = _parse_first_regex_group(re.search(r'from (\S+)', line)) 51 | if not module: 52 | module = _parse_first_regex_group(re.search(r'import (\S+)', line)) 53 | 54 | if module and module not in user_modules: 55 | file = 'build/out/' + module.replace('.', '/') + '.py' 56 | if not os.path.exists(file): 57 | file = file.replace('.py', '/__init__.py') 58 | if os.path.exists(file): user_modules[module] = file 59 | else: module = False 60 | 61 | if module in user_modules: 62 | if path in lib_scripts: deps.add(user_modules[module]) 63 | # else: lines.append(re.sub(module, 'pico_fi', line)) 64 | # TODO better way of replacing non-concatenated imports 65 | # Non-urgent since concatenated file is too large 66 | else: lines.append(line) 67 | else: lines.append(line) 68 | 69 | script_contents[path] = lines 70 | if path in lib_scripts: dep_tree[path] = list(deps) 71 | 72 | if dep_tree: 73 | print() 74 | [print( 75 | _formatBuildFile(k), 76 | [_formatBuildFile(x) for x in v]) 77 | for k,v in sorted(dep_tree.items(), key=lambda x: len(x[1]))] 78 | 79 | # Start with pool of files without dependencies 80 | # Remove added files from remaining 81 | # Adding once deps == 0 82 | order = [] 83 | import time 84 | print('\nFlattening dependencies') 85 | while dep_tree: 86 | added = set() 87 | for file,deps in dep_tree.items(): 88 | if not deps: 89 | order.append(file) 90 | added.add(file) 91 | for file in added: 92 | del dep_tree[file] 93 | for file in dep_tree.keys(): 94 | dep_tree[file] = [x for x in dep_tree[file] if x not in added] 95 | time.sleep(1) 96 | 97 | print('\nOrder:') 98 | [print(_formatBuildFile(x)) for x in order] 99 | 100 | # Concat into one file (without imports) and sync /public non-script artifacts 101 | print('\nConcat, minify:') 102 | compiled = '\n\n\n'.join(''.join(script_contents[x]) for x in order) 103 | os.makedirs('min/public', exist_ok=True) 104 | with open('min/pico_fi.py', 'w') as f: 105 | compiled = python_minifier.minify(compiled) 106 | f.write(compiled) 107 | 108 | 109 | # Replace import references across lib/main.py and lib/packs/ with pico_fi 110 | # and minify 111 | print('\nMinify python?', bool(python_minifier)) 112 | if not python_minifier: 113 | print('To support minification of python files: "pip3 install python_minifier"') 114 | unminified = { 'bootsel.py', } 115 | for src_path in [x for x in script_paths if x not in lib_scripts]: 116 | contents = ''.join(script_contents[src_path]) 117 | if python_minifier and not src_path.split('/')[-1] in unminified: 118 | print(_formatBuildFile(src_path)) 119 | contents = python_minifier.minify(contents) 120 | min_path = src_path.replace('out', 'min') 121 | os.makedirs(re.sub(r'/[^/]+$', '', min_path), exist_ok=True) 122 | with open(min_path, 'w') as f: f.write(contents) 123 | 124 | # Sync & minify public files 125 | import shutil 126 | html_minifier_installed = bool(shutil.which('html-minifier')) 127 | print('\nMinify html/js/css?', html_minifier_installed) 128 | if not html_minifier_installed: 129 | print('To support minification of python files: "npm install -g html-minifier"') 130 | for root, dir, files in os.walk('build/out/public'): 131 | for file in files: 132 | src_path = os.path.join(root, file) 133 | min_path = src_path.replace('out', 'min') 134 | os.makedirs(re.sub(r'/[^/]+$', '', min_path), exist_ok=True) 135 | if html_minifier_installed and re.search(r'\.html$', file): 136 | print(_formatBuildFile(src_path)) 137 | os.system(f"""html-minifier --collapse-boolean-attributes --collapse-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --remove-tag-whitespace --minify-css true --minify-js true -o {min_path} {src_path}""") 138 | else: 139 | os.system(f'cp {src_path} {min_path}') 140 | 141 | print('\nMinification complete\n') 142 | 143 | if __name__ == '__main__': 144 | minify() 145 | -------------------------------------------------------------------------------- /src/lib/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | miscellaneous utilities 3 | """ 4 | import io 5 | from random import choice 6 | import re 7 | 8 | class enum: 9 | def __init__(self, value): self.value = value 10 | def __repr__(self): return self.value 11 | 12 | class enumstr(enum): 13 | def __repr__(self): 14 | if isinstance(self.value, tuple): 15 | return tuple( 16 | x.decode() if isinstance(x, (bytes, bytearray)) else x 17 | for x 18 | in self.value) 19 | return ( 20 | self.value.decode() 21 | if isinstance(self.value, (bytes, bytearray)) 22 | else self.value) 23 | 24 | 25 | class defaulter_dict(dict): 26 | def __init__(self, *r, **k): 27 | super().__init__(*r, **k) 28 | 29 | def get(self, key, defaulter=None): 30 | value = super().get(key) 31 | if value is None and defaulter: 32 | value = defaulter(key) 33 | self[key] = value 34 | return value 35 | 36 | 37 | def compose(*funcs): 38 | """return composition of functions: f(*x) g(x) h(x) => h(g(f(*x)))""" 39 | def _inner(*args): 40 | value = funcs[0](*args) 41 | for func in funcs[1:]: value = func(value) 42 | return value 43 | return _inner 44 | 45 | def chain(value, *funcs): 46 | """pass value through sequence of functions""" 47 | return compose(*funcs)(value) 48 | 49 | 50 | def encode(input: str or bytes) -> bytes: 51 | """given string or bytes, return bytes""" 52 | return input.encode() if isinstance(input, str) else input 53 | 54 | def decode(input: str or bytes) -> str: 55 | """given string or bytes, return string""" 56 | return input.decode() if isinstance(input, bytes) else input 57 | 58 | def encode_bytes(raw: int, n_bytes: int) -> bytes: 59 | """fill exact length of bytes""" 60 | b = bytearray() 61 | for i in range(n_bytes): b.append(raw >> 8 * (n_bytes - 1 - i)) 62 | return bytes(b) 63 | 64 | def decode_bytes(raw: bytes) -> int: 65 | """read bytes as integer""" 66 | x = 0 67 | for i in range(len(raw)): x = (x << 8) + raw[i] 68 | return x 69 | 70 | def format_bytes(input: bytes or int) -> str: 71 | if isinstance(input, int): input = bytes(input) 72 | return ' '.join(f'{hex(b)[2:]:>02}' for b in input) 73 | 74 | 75 | def unquote(string: str or bytes) -> str: 76 | return unquote_to_bytes(string).decode() 77 | def unquote_to_bytes(string: str or bytes) -> bytes: 78 | """fill-in for urllib.parse unquote_to_bytes""" 79 | 80 | # return with first two chars after an escape char converted from base 16 81 | bits = encode(string).split(b'%') 82 | return bits[0] + b''.join(bytes([int(bit[:2], 16)]) + bit[2:] for bit in bits[1:]) 83 | 84 | 85 | def choices(list, n): return [choice(list) for _ in range(n)] 86 | 87 | class string: 88 | """incomplete fill-in for string module""" 89 | ascii_lowercase = 'abcdefghijklmnopqrstuvwxyz' 90 | ascii_uppercase = ascii_lowercase.upper() 91 | ascii_letters = ascii_lowercase + ascii_uppercase 92 | digits = '0123456789' 93 | 94 | _alphanum = string.digits + string.ascii_letters 95 | _lower_alphanum = string.digits + string.ascii_lowercase 96 | _hex = string.digits + string.ascii_uppercase[:6] 97 | def randtokens(tokens: str, n: int) -> str: return ''.join(choices(tokens, n)) 98 | def randalpha(n: int) -> str: return randtokens(string.ascii_letters, n) 99 | def randalphanum(n: int) -> str: return randtokens(_alphanum, n) 100 | def randlower(n: int) -> str: return randtokens(string.ascii_lowercase, n) 101 | def randloweralphanum(n: int) -> str: return randtokens(_lower_alphanum, n) 102 | def randhex(n: int) -> str: return randtokens(_hex, n) 103 | 104 | 105 | def part(str, n): 106 | """split string into groups of n tokens""" 107 | return [str[x:x+n] for x in range(0, len(str), n)] 108 | def delimit(str, n, sep): 109 | """add delimiter to string between groups of n tokens""" 110 | return sep.join(part(str, n)) 111 | 112 | def coroutine(func): 113 | """ 114 | name async functions starting with async_ to detect here & await properly 115 | (hacky. I haven't found a better option) 116 | """ 117 | if 'async_' in func.__name__: return func 118 | async def async_func(*a, **k): return func(*a, **k) 119 | return async_func 120 | def fork(func): uasyncio.create_task(coroutine(func)()) 121 | 122 | class MergedReadInto: 123 | """merge multiple str / bytes / IO streams into one""" 124 | def __init__(self, streams: list[io.BytesIO or io.FileIO or bytes or str]): 125 | self.iter = iter( 126 | io.BytesIO(encode(x)) if isinstance(x, (bytes, str)) else x 127 | for x 128 | in streams) 129 | self.curr = next(self.iter) 130 | 131 | def readinto(self, bytes_like_object): 132 | view = memoryview(bytes_like_object) 133 | max_write_len = len(view) 134 | total_bytes_written = 0 135 | while self.curr and total_bytes_written < max_write_len: 136 | bytes_written = self.curr.readinto(view[total_bytes_written:]) 137 | if bytes_written == 0: 138 | try: self.curr = next(self.iter) 139 | except StopIteration: self.curr = None 140 | else: total_bytes_written += bytes_written 141 | return total_bytes_written 142 | 143 | 144 | import time 145 | from machine import Pin, PWM 146 | import uasyncio 147 | class LED: 148 | """LED with brightness setting. Supports on, off, set, toggle, pulse""" 149 | PWM_DUTY_CYCLE_MAX = 65535 150 | 151 | def __init__(self, pin='LED', brightness=1): 152 | pins = pin if isinstance(pin, list) else [pin] 153 | self.pwms = [] 154 | for pin in pins: 155 | if not isinstance(pin, Pin): pin = Pin(pin, Pin.OUT) 156 | pwm = None 157 | try: 158 | pwm = PWM(pin) 159 | pwm.freq(1000) 160 | except ValueError as e: 161 | if 'expecting a regular GPIO Pin' in str(e): 162 | pwm = LED._PWM_Mock(pin) 163 | else: raise e 164 | self.pwms.append(pwm) 165 | self.brightness = brightness 166 | 167 | def on(self, brightness=None): 168 | duty = int((brightness if type(brightness) is float else self.brightness) * LED.PWM_DUTY_CYCLE_MAX) 169 | [x.duty_u16(duty) for x in self.pwms] 170 | def off(self): [x.duty_u16(0) for x in self.pwms] 171 | 172 | def get(self): 173 | return max(x.duty_u16() / LED.PWM_DUTY_CYCLE_MAX for x in self.pwms) 174 | def set(self, on): 175 | if type(on) is float: 176 | self.brightness = on 177 | if self.get(): self.on(on) 178 | else: 179 | self.on(on) if on else self.off() 180 | def toggle(self): self.off() if self.get() else self.on() 181 | 182 | def pulse(self, seconds=.1): 183 | async def _inner(): 184 | self.toggle() 185 | time.sleep(seconds) 186 | self.toggle() 187 | uasyncio.create_task(_inner()) 188 | 189 | class Mock: 190 | def on(self, *a): pass 191 | def off(self, *a): pass 192 | def set(self, *a): pass 193 | def toggle(self, *a): pass 194 | def pulse(self, *a): pass 195 | 196 | class _PWM_Mock: 197 | def __init__(self, pin): self.pin = pin 198 | def duty_u16(self, x=None): 199 | if x is None: return self.pin.value() * LED.PWM_DUTY_CYCLE_MAX 200 | else: self.pin.value(x / LED.PWM_DUTY_CYCLE_MAX) 201 | -------------------------------------------------------------------------------- /src/lib/bootsel.py: -------------------------------------------------------------------------------- 1 | """ 2 | pico-bootsel: read the state of the BOOTSEL button 3 | 4 | Credit to github@pdg137 5 | https://github.com/micropython/micropython/issues/6852#issuecomment-1350081346 6 | It would be great to have the PR merged into the Micropython core. But for 7 | now I have been using this inline assembly to do it, which seems to work 8 | fine at least with single-core code: 9 | [code provided below] 10 | 11 | This simply packages that implementation as a git repo while waiting on 12 | an official release 13 | 14 | Usage: 15 | import bootsel 16 | bootsel.read_bootsel() # raw value (0 - pressed, 1 - unpressed) 17 | bootsel.pressed() # boolean (True - pressed, False - unpressed) 18 | """ 19 | def pressed(): 20 | return not read_bootsel() 21 | 22 | @micropython.asm_thumb 23 | def read_bootsel(): 24 | # disable interrupts 25 | cpsid(0x0) 26 | 27 | # set r2 = addr of GPIO_QSI_SS registers, at 0x40018000 28 | # GPIO_QSPI_SS_CTRL is at +0x0c 29 | # GPIO_QSPI_SS_STATUS is at +0x08 30 | # is there no easier way to load a 32-bit value? 31 | mov(r2, 0x40) 32 | lsl(r2, r2, 8) 33 | mov(r1, 0x01) 34 | orr(r2, r1) 35 | lsl(r2, r2, 8) 36 | mov(r1, 0x80) 37 | orr(r2, r1) 38 | lsl(r2, r2, 8) 39 | 40 | # set bit 13 (OEOVER[1]) to disable output 41 | mov(r1, 1) 42 | lsl(r1, r1, 13) 43 | str(r1, [r2, 0x0c]) 44 | 45 | # delay about 3us 46 | # seems to work on the Pico - tune for your system 47 | mov(r0, 0x16) 48 | label(DELAY) 49 | sub(r0, 1) 50 | bpl(DELAY) 51 | 52 | # check GPIO_QSPI_SS_STATUS bit 17 - input value 53 | ldr(r0, [r2, 0x08]) 54 | lsr(r0, r0, 17) 55 | mov(r1, 1) 56 | and_(r0, r1) 57 | 58 | # clear bit 13 to re-enable, or it crashes 59 | mov(r1, 0) 60 | str(r1, [r2, 0x0c]) 61 | 62 | # re-enable interrupts 63 | cpsie(0x0) 64 | -------------------------------------------------------------------------------- /src/lib/handle/dns.py: -------------------------------------------------------------------------------- 1 | """ 2 | DNS handler 3 | """ 4 | import gc 5 | import select 6 | from lib.logging import log 7 | 8 | from lib.server import Protocol, Server 9 | 10 | 11 | class DNS(Server): 12 | """ 13 | redirect DNS requests to local server 14 | TODO pass requests through once connected internet 15 | """ 16 | 17 | def __init__(self, orchestrator, ip): 18 | super().__init__(orchestrator, 53, Protocol.DNS) 19 | self.ip = ip 20 | 21 | def handle(self, sock, event): 22 | if sock is not self.sock: return True # this server doesn't spawn sockets 23 | if event == select.POLLHUP: return True # ignore UDP socket hangups 24 | try: 25 | data, sender = sock.recvfrom(1024) 26 | request = DNS.Query(data) 27 | 28 | log.info('DNS @ {:s} -> {:s}'.format(request.domain, self.ip)) 29 | sock.sendto(request.answer(self.ip.decode()), sender) 30 | 31 | del request 32 | gc.collect() 33 | except Exception as e: 34 | log.exception(e, 'DNS server exception') 35 | 36 | 37 | class Query: 38 | def __init__(self, data): 39 | self.data = data 40 | self.domain = "" 41 | # header is bytes 0-11, so question starts on byte 12 42 | head = 12 43 | # length of this label defined in first byte 44 | length = data[head] 45 | while length != 0: 46 | label = head + 1 47 | # add the label to the requested domain and insert a dot after 48 | self.domain += data[label : label + length].decode('utf-8') + '.' 49 | # check if there is another label after this one 50 | head += length + 1 51 | length = data[head] 52 | 53 | def answer(self, ip): 54 | # ** create the answer header ** 55 | # copy the ID from incoming request 56 | packet = self.data[:2] 57 | # set response flags (assume RD=1 from request) 58 | packet += b'\x81\x80' 59 | # copy over QDCOUNT and set ANCOUNT equal 60 | packet += self.data[4:6] + self.data[4:6] 61 | # set NSCOUNT and ARCOUNT to 0 62 | packet += b'\x00\x00\x00\x00' 63 | 64 | # ** create the answer body ** 65 | # respond with original domain name question 66 | packet += self.data[12:] 67 | # pointer back to domain name (at byte 12) 68 | packet += b'\xC0\x0C' 69 | # set TYPE and CLASS (A record and IN class) 70 | packet += b'\x00\x01\x00\x01' 71 | # set TTL to 60sec 72 | packet += b'\x00\x00\x00\x3C' 73 | # set response length to 4 bytes (to hold one IPv4 address) 74 | packet += b'\x00\x04' 75 | # now actually send the IP address as 4 bytes (without the '.'s) 76 | packet += bytes(map(int, ip.split('.'))) 77 | 78 | return packet 79 | -------------------------------------------------------------------------------- /src/lib/handle/http.py: -------------------------------------------------------------------------------- 1 | """ 2 | HTTP handler 3 | """ 4 | import io 5 | import json 6 | import select 7 | import socket 8 | from collections import namedtuple 9 | import re 10 | import micropython 11 | 12 | from lib.handle.ws import WebSocket 13 | from lib.stream.tcp import TCP 14 | from lib import encode, unquote, fork 15 | from lib.logging import comment, log 16 | from lib.server import Orchestrator, Protocol, Server, connection, IpSink 17 | 18 | 19 | class HTTP(Server): 20 | """ 21 | serve single index.html page, get/set persistent state API, and upgrade connections to websocket 22 | """ 23 | 24 | NL = b'\r\n' 25 | END = NL + NL 26 | 27 | class Method: 28 | GET = 'GET' 29 | POST = 'POST' 30 | PUT = 'PUT' 31 | DELETE = 'DELETE' 32 | HEAD = 'HEAD' 33 | 34 | class ContentType: 35 | class Value: 36 | TEXT = b'text/plain'; TXT=TEXT 37 | JSON = b'application/json' 38 | HTML = b'text/html'; HTM=HTML 39 | FORM = b'application/x-www-form-urlencoded' 40 | PNG = b'image/png' 41 | JPG = b'image/jpeg'; JPEG=JPG 42 | GIF = b'image/gif' 43 | SVG = b'image/svg+xml' 44 | MP3 = b'audio/mpeg' 45 | MP4 = b'video/mp4' 46 | PDF = b'application/pdf' 47 | 48 | _MIME_REGEX = '^[A-Za-z0-9_-]/[A-Za-z0-9_-]$' 49 | _FILE_EXT_REGEX = '^([^/]*/)?[^/.]+\.([A-Za-z0-9_-.]+)$' 50 | 51 | @staticmethod 52 | def of(ext_or_type: str or bytes): 53 | if not re.match(HTTP.ContentType._MIME_REGEX, ext_or_type): 54 | match = re.match(HTTP.ContentType._FILE_EXT_REGEX, ext_or_type) 55 | ext_or_type = ( 56 | getattr(HTTP.ContentType.Value, match.group(2).upper()) 57 | if hasattr(HTTP.ContentType.Value, match.group(2).upper()) 58 | else b'') if match else None 59 | return b'Content-Type: ' + encode(ext_or_type) if ext_or_type else b'' 60 | 61 | Request = namedtuple( 62 | 'Request', 63 | 'host method path raw_query query headers body socket_id') 64 | 65 | class Response: 66 | class Status: 67 | OK = b'HTTP/1.1 200 OK' 68 | REDIRECT = b'HTTP/1.1 307 Temporary Redirect' 69 | BAD_REQUEST = b'HTTP/1.1 400 Bad Request' 70 | UNAUTHORIZED = b'HTTP/1.1 401 Unauthorized' 71 | NOT_FOUND = b'HTTP/1.1 404 Not Found' 72 | SERVER_ERROR = b'HTTP/1.1 500 Internal Server Error' 73 | 74 | @staticmethod 75 | def of(code: int): return { 76 | 200: HTTP.Response.Status.OK, 77 | 307: HTTP.Response.Status.REDIRECT, 78 | 400: HTTP.Response.Status.BAD_REQUEST, 79 | 401: HTTP.Response.Status.UNAUTHORIZED, 80 | 404: HTTP.Response.Status.NOT_FOUND, 81 | 500: HTTP.Response.Status.SERVER_ERROR, 82 | }.get(code, HTTP.Response.Status.SERVER_ERROR) 83 | 84 | def __init__(self, http, sock): 85 | self.http: HTTP = http 86 | self.sock = sock 87 | self.sent = False 88 | 89 | def send( 90 | self, 91 | header: bytes or int or list[bytes], 92 | body: bytes or str or io.BytesIO = b''): 93 | 94 | if isinstance(header, int): 95 | header = HTTP.Response.Status.of(header) 96 | if isinstance(header, list): 97 | header = HTTP.NL.join(header) 98 | if header[-len(HTTP.NL):] != HTTP.NL: header += HTTP.NL 99 | self.http.prepare(self.sock, header, encode(body)) 100 | self.sent = True 101 | 102 | def ok(self, body: bytes or str = b''): 103 | self.send(HTTP.Response.Status.OK, body) 104 | def error(self, message: bytes or str): 105 | self.send(HTTP.Response.Status.SERVER_ERROR, message) 106 | def redirect(self, url: bytes or str): 107 | self.send( 108 | [HTTP.Response.Status.REDIRECT, b'Location: ' + encode(url)]) 109 | def content(self, type: bytes or str, content: bytes or str): 110 | self.send( 111 | [HTTP.Response.Status.OK, HTTP.ContentType.of(type)], 112 | content) 113 | 114 | def text(self, content: bytes or str): self.content('txt', content) 115 | def json(self, data): self.content('json', json.dumps(data)) 116 | def html(self, content: bytes or str): self.content('text/html', content) 117 | 118 | def file(self, path: bytes or str): 119 | log.info('open file for response', path) 120 | try: self.content(HTTP.Response.Status.OK, open(path, 'rb')) 121 | except Exception as e: 122 | log.exception(e, 'error reading file', path) 123 | self.send(HTTP.Response.Status.NOT_FOUND) 124 | 125 | def fork(self, func): 126 | self.sent = True 127 | fork(func) 128 | 129 | 130 | def __init__(self, orch: Orchestrator, ip_sink: IpSink, routes: dict[bytes, bytes or function]): 131 | super().__init__(orch, 80, Protocol.HTTP) 132 | self.tcp = TCP(orch.poller) 133 | self.ip_sink = ip_sink 134 | self.ip = ip_sink.get() 135 | self.routes = routes 136 | self.ws_upgrades = set() 137 | 138 | # queue up to 5 connection requests before refusing 139 | self.sock.listen(5) 140 | self.sock.setblocking(False) 141 | 142 | @micropython.native 143 | def handle(self, sock, event): 144 | if sock is self.sock: self.accept(sock) # new connection 145 | elif event & select.POLLIN: self.read(sock) # connection has data to read 146 | elif event & select.POLLOUT: self.write(sock) # connection has space to send data 147 | else: return True # pass to next handler 148 | 149 | def accept(self, server_sock): 150 | """accept a new client socket and register it for polling""" 151 | 152 | try: client_sock, addr = server_sock.accept() 153 | except Exception as e: return log.exception(e, 'failed to accept connection request on', server_sock) 154 | 155 | # allow requests 156 | client_sock.setblocking(False) 157 | client_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 158 | 159 | self.orch.register(connection(self.proto.transport, client_sock), self) # register HTTP handler in orchestrator 160 | self.poller.register(client_sock, select.POLLIN) # register as POLLIN to trigger read 161 | 162 | def parse_request(self, raw_req: bytes): 163 | """parse a raw HTTP request""" 164 | 165 | header_bytes, body_bytes = raw_req.split(HTTP.END) 166 | header_lines = header_bytes.split(HTTP.NL) 167 | req_type, full_path, *_ = header_lines[0].split(b' ') 168 | path, *rest = full_path.split(b'?', 1) 169 | log.info('HTTP REQUEST:', b' '.join((req_type, path)).decode()) 170 | raw_query = rest[0] if len(rest) else None 171 | query = { 172 | unquote(key): unquote(val) 173 | for key, val in [param.split(b'=') for param in raw_query.split(b'&')] 174 | } if raw_query else {} 175 | headers = { 176 | key: val 177 | for key, val in [line.split(b': ', 1) for line in header_lines[1:]] 178 | } 179 | host = headers.get(b'Host', None) 180 | socket_id = headers.get(b'X-Pico-Fi-Socket-Id', None) 181 | 182 | return HTTP.Request(host, req_type, path, raw_query, query, headers, body_bytes, socket_id) 183 | 184 | def parse_route(self, req: Request): 185 | prefix = b'/'+(req.path.split(b'/')+[b''])[1] 186 | log.debug('HTTP PARSE ROUTE', prefix, self.routes.get(prefix, None), self.routes) 187 | return (req.host == self.ip or not self.ip_sink.get()) and self.routes.get(prefix, None) 188 | def handle_request(self, sock, req: Request): 189 | """respond to an HTTP request""" 190 | 191 | if WebSocket.KeyHeader in req.headers: 192 | comment('upgrade HTTP to WebSocket') 193 | self.ws_upgrades.add(connection.of(sock)) 194 | self.prepare(sock, WebSocket.get_http_upgrade_header(req.headers[WebSocket.KeyHeader])) 195 | return 196 | 197 | res = HTTP.Response(self, sock) 198 | route = self.parse_route(req) 199 | ip_redirect = self.ip_sink.get() 200 | if route: 201 | if isinstance(route, bytes): res.file(route) 202 | elif callable(route): 203 | result = route(req, res) 204 | if not res.sent: res.ok() if result is None else res.ok(result) 205 | else: res.send(HTTP.Response.Status.NOT_FOUND) 206 | 207 | # redirect non-matches to landing switch 208 | elif ip_redirect: res.redirect(b'http://{:s}/portal{:s}'.format(ip_redirect, b'?'+req.raw_query if req.raw_query else b'')) 209 | 210 | # attempt to send file from public folder 211 | else: res.file(b'/public' + req.path) 212 | 213 | def read(self, sock): 214 | """read client request data from socket""" 215 | 216 | request = self.tcp.read(sock) 217 | if not request: self.tcp.end(sock) # empty stream, close immediately 218 | elif request[-4:] == HTTP.END: 219 | # end of HTTP request, parse & handle 220 | req = self.parse_request(request) 221 | self.handle_request(sock, req) 222 | 223 | def prepare(self, sock, headers: bytes, body: bytes or io.BytesIO=None): 224 | log.info('HTTP RESPONSE', 225 | f': body length {len(body) if isinstance(body, bytes) else "unknown"}' if body else '', 226 | '\n', encode(headers).decode().strip(), sep='') 227 | self.tcp.prepare(sock, headers, *([b'\n', body] if body else [])) 228 | 229 | def write(self, sock): 230 | if self.tcp.write(sock): 231 | conn = connection.of(sock) 232 | if conn in self.ws_upgrades: 233 | # we upgraded this to a WebSocket, switch handler but keep open 234 | self.orch.register(conn, Protocol.WebSocket) 235 | self.poller.register(sock, select.POLLIN) # switch back to read 236 | comment('upgraded HTTP to WebSocket') 237 | self.tcp.clear(sock) 238 | self.ws_upgrades.remove(conn) 239 | else: 240 | self.tcp.end(sock) # HTTP response complete, end connection 241 | -------------------------------------------------------------------------------- /src/lib/handle/ws.py: -------------------------------------------------------------------------------- 1 | """ 2 | WebSocket handler 3 | """ 4 | import binascii 5 | import hashlib 6 | import select 7 | import socket 8 | 9 | from lib.stream.ws import WS 10 | from lib import chain, decode 11 | from lib.logging import log 12 | from lib.server import Orchestrator, Protocol, ProtocolHandler, connection 13 | 14 | 15 | class WebSocket(ProtocolHandler): 16 | """ 17 | WebSocket: continuous two-way communication with client over TCP 18 | """ 19 | """ 20 | Unlike HTTP and DNS, this handler isn't responsible for accepting new connections 21 | HTTP connections are upgraded & handed over instead 22 | 23 | Multiple writes & reads can be queued per socket 24 | Register a handler to receive reads & send writes 25 | """ 26 | 27 | class Message: 28 | def __init__(self, ws, sock, opcode: WS.Opcode, data: bytes): 29 | self._ws = ws 30 | self.s_id = id(sock) 31 | self.opcode = opcode 32 | self.data = data 33 | if opcode == WS.Opcode.TEXT: 34 | [self.type, *_content] = data.split(b' ', 1) 35 | self.content = decode(b' '.join(_content)) 36 | else: self.type = None 37 | 38 | def reply(self, *data: bytes or str or dict, opcode=WS.Opcode.TEXT): 39 | self._ws.emit(*data, opcode=opcode, socket_id=self.s_id) 40 | 41 | def share(self, *data, opcode=WS.Opcode.TEXT): 42 | other_ids = [ 43 | s_id 44 | for s_id in [x.sock for x in self._ws.conns] 45 | if s_id != self.s_id] 46 | for s_id in other_ids: 47 | self._ws.emit(*data, opcode=opcode, socket_id=s_id) 48 | 49 | def all(self, *data, opcode=WS.Opcode.TEXT): 50 | self._ws.emit(*data, opcode=opcode) 51 | 52 | def __repr__(self) -> str: 53 | return f'{self.s_id} {WS.Opcode.name(self.opcode)} {self.data}' 54 | 55 | 56 | def __init__(self, orch: Orchestrator, events={}): 57 | super().__init__(orch, Protocol.WebSocket) 58 | self.io = WS(orch.poller) 59 | self.events = events 60 | self.conns: set[connection] = set() 61 | 62 | KeyHeader = b'Sec-WebSocket-Key' 63 | _AcceptMagicNumber = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11' 64 | @staticmethod 65 | def get_http_upgrade_header(key): 66 | accept = chain(key, 67 | lambda x: x + WebSocket._AcceptMagicNumber, 68 | lambda x: hashlib.sha1(x).digest(), 69 | lambda x: binascii.b2a_base64(x)) 70 | return ( 71 | b'HTTP/1.1 101 Switching Protocols\r\n' + 72 | b'Upgrade: websocket\r\n' + 73 | b'Connection: Upgrade\r\n' + 74 | b'Sec-WebSocket-Accept: ' + accept + b'\r\n' 75 | ) 76 | 77 | def handle(self, sock, event): 78 | conn = connection.of(sock) 79 | log.debug('SOCKET EVENT', conn, event & select.POLLOUT, event & select.POLLIN) 80 | if conn not in self.conns: self.conns.add(conn) 81 | elif event & select.POLLOUT: self.write(sock) # we have data to write 82 | else: self.read(sock) 83 | 84 | def emit(self, *data: str or bytes, opcode=WS.Opcode.TEXT, socket_id=0): 85 | if opcode == WS.Opcode.TEXT: message = ' '.join(str(x) for x in data) 86 | else: message = b''.join(x for x in data) 87 | sent = False 88 | for sock in [x.sock for x in self.conns]: 89 | if socket_id and id(sock) != socket_id: continue 90 | try: 91 | log.debug('WebSocket send', id(sock), WS.Opcode.name(opcode)) 92 | self.io.send(sock, opcode, message) 93 | sent = True 94 | # write & read immediately 95 | log.debug('WebSocket flush and read') 96 | while not self.io.write(sock): pass 97 | self.read(sock) 98 | except Exception as e: 99 | log.exception(e) 100 | self.conns.remove(connection.of(sock)) 101 | return sent 102 | 103 | def read(self, sock: socket.socket): 104 | """read WebSocket frame from client and pass to handler""" 105 | result = self.io.read(sock) 106 | if result: 107 | [opcode, data] = result 108 | if opcode == WS.Opcode.CLOSE: self.conns.remove(connection.of(sock)) 109 | if opcode == WS.Opcode.PING: self.io.send(sock, WS.Opcode.PONG) 110 | if data: 111 | msg = WebSocket.Message(self, sock, opcode, data) 112 | handler = self.events.get(msg.type, None) 113 | log.debug('WebSocket read', msg, handler) 114 | if not handler: 115 | if msg.type == b'connect': 116 | self.emit('connected', msg.s_id, socket_id=msg.s_id) 117 | elif opcode in self.events: handler = self.events[opcode] 118 | handler and handler(msg) 119 | 120 | def write(self, sock): 121 | # if write complete, switch back to read 122 | if self.io.write(sock): self.poller.modify(sock, select.POLLIN) 123 | -------------------------------------------------------------------------------- /src/lib/logging.py: -------------------------------------------------------------------------------- 1 | """ 2 | timestamped level-logging 3 | """ 4 | import io 5 | import sys 6 | from collections import namedtuple 7 | 8 | import machine 9 | import uasyncio 10 | 11 | from lib import defaulter_dict 12 | 13 | CRITICAL = 50 14 | ERROR = 40 15 | WARNING = 30 16 | INFO = 20 17 | DEBUG = 10 18 | NOTSET = 0 19 | _level_dict = { 20 | CRITICAL: 'CRIT', 21 | ERROR: 'ERROR', 22 | WARNING: 'WARN', 23 | INFO: 'INFO', 24 | DEBUG: 'DEBUG', 25 | } 26 | 27 | _stream = sys.stderr 28 | 29 | 30 | def str_print(*args, **kwargs): 31 | output = io.StringIO() 32 | print(*args, **({ 'end':'' } | kwargs | { 'file':output })) 33 | value = output.getvalue() 34 | output.close() 35 | return value 36 | 37 | DO_DEVICE_LOG = False 38 | f = DO_DEVICE_LOG and open('log.txt', 'w') 39 | class AtomicPrint: 40 | """print sequentially from multiple threads""" 41 | _lock = uasyncio.Lock() 42 | _loop = uasyncio.get_event_loop() 43 | _tasks = [] 44 | 45 | async def _atomic_print(*args, **kwargs): 46 | async with AtomicPrint._lock: 47 | print(*args, **kwargs) 48 | if DO_DEVICE_LOG: f.write(str_print(*args, **kwargs) + '\n') 49 | 50 | def print(*args, **kwargs): 51 | task = uasyncio.create_task(AtomicPrint._atomic_print(*args, **kwargs)) 52 | AtomicPrint._tasks.append(task) 53 | AtomicPrint._loop.run_until_complete(task) 54 | AtomicPrint._tasks.remove(task) 55 | 56 | def flush(): 57 | AtomicPrint._loop.run_until_complete(uasyncio.gather(*AtomicPrint._tasks)) 58 | 59 | def atomic_print(*args, **kwargs): AtomicPrint.print(*args, **kwargs) 60 | def flush(): AtomicPrint.flush() 61 | 62 | 63 | rtc = machine.RTC() 64 | def timestamp(): 65 | [year, month, mday, hour, minute, second, weekday, yearday] = rtc.datetime() 66 | return f'{year}/{month}/{mday} {hour}:{minute:02}:{second:02}' 67 | 68 | 69 | class Logger: 70 | """atomic logger""" 71 | Record = namedtuple('Record', 'levelname levelno message name') 72 | 73 | @staticmethod 74 | def default_handler(r): 75 | """output message as [LEVEL:logger] timestamp message""" 76 | 77 | # move newlines above [LEVEL] 78 | message = r.message.lstrip('\n') 79 | spacer = '\n' * (len(r.message) - len(message)) 80 | if '\n' in message: 81 | message = '\n ' + '\n '.join(message.split('\n')) 82 | AtomicPrint.print( 83 | spacer, 84 | *['[', r.levelname, r.name and ':'+r.name, '] ', timestamp(), ' '] if message else '', 85 | message, sep='', file=_stream) 86 | 87 | def __init__(self, name, level=NOTSET): 88 | self.name = name 89 | self.level = level 90 | self.handlers = [Logger.default_handler] 91 | 92 | def set_level(self, level): self.level = level 93 | def add_handler(self, handler): self.handlers.append(handler) 94 | def enabled_for(self, level): return level >= (self.level or _level) 95 | 96 | def log(self, level, *r, **k): 97 | if self.enabled_for(level): 98 | record = Logger.Record( 99 | levelname=_level_dict.get(level) or 'LVL%s' % level, 100 | levelno=level, 101 | message=str_print(*r, **k), 102 | name=self.name) 103 | for h in self.handlers: h(record) 104 | 105 | def debug(self, *r, **k): self.log(DEBUG, *r, **k) 106 | def info(self, *r, **k): self.log(INFO, *r, **k) 107 | def warning(self, *r, **k): self.log(WARNING, *r, **k) 108 | def error(self, *r, **k): self.log(ERROR, *r, **k) 109 | def critical(self, *r, **k): self.log(CRITICAL, *r, **k) 110 | def exception(self, e, *r, **k): 111 | self.error(*r, **k) 112 | sys.print_exception(e, _stream) 113 | 114 | 115 | _level = INFO 116 | # _level = DEBUG 117 | _loggers = defaulter_dict() 118 | 119 | def config(level=_level, stream=None): 120 | global _level, _stream 121 | _level = level 122 | if stream: _stream = stream 123 | 124 | def instance(name=""): return _loggers.get(name, Logger) 125 | 126 | root = instance() 127 | def debug(*r, **k): root.debug(*r, **k) 128 | def info(*r, **k): root.info(*r, **k) 129 | def warning(*r, **k): root.warning(*r, **k) 130 | def error(*r, **k): root.error(*r, **k) 131 | def critical(*r, **k): root.critical(*r, **k) 132 | def exception(e, msg='', *r, **k): root.exception(e, msg, *r, **k) 133 | class log: 134 | def __init__(self, *r, **k): info(*r, **k) 135 | debug = debug 136 | info = info 137 | warning = warning 138 | error = error 139 | critical = critical 140 | exception = exception 141 | 142 | config = config 143 | instance = instance 144 | flush = flush 145 | 146 | def comment(*r, **k): root.info(*r, **k) 147 | -------------------------------------------------------------------------------- /src/lib/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | common server interfaces 3 | """ 4 | 5 | import select 6 | import socket 7 | 8 | from lib.logging import log 9 | from lib import encode 10 | 11 | """ 12 | handler 13 | """ 14 | class SocketPollHandler: 15 | """handle events from a pool of sockets registered to a poller""" 16 | def __init__(self, poller: select.poll, name: str): 17 | self.poller: select.poll = poller 18 | self.name = name 19 | 20 | def handle(self, sock: socket.socket, event: int): log.exception("missing 'handle' implementation for", self) 21 | def __repr__(self): return f'
6 |
7 | ### Setup
8 | 1. Install [pico-fi](/README.md#install)
9 | 1. Build with **data-view** enabled
10 | ```
11 | python3 build -a data-view
12 | ```
13 |
--------------------------------------------------------------------------------
/src/packs/data-view/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
34 | This page has been viewed times
35 |
45 |
46 | Edit src/public/index.html or src/main.py to serve content from the Pico W (< 750KB total)
47 |
48 | [ back to network selection ]
49 |