├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── attribution ├── README └── esp8266-captive-portal │ └── LICENSE ├── build ├── __main__.py ├── empty.html └── minify.py └── src ├── lib ├── __init__.py ├── bootsel.py ├── handle │ ├── dns.py │ ├── http.py │ └── ws.py ├── logging.py ├── server.py ├── store.py └── stream │ ├── tcp.py │ └── ws.py ├── main.py ├── packs ├── basic-led │ ├── __init__.py │ └── public │ │ └── index.html ├── basic-socket │ ├── README.md │ ├── __init__.py │ └── public │ │ └── index.html ├── cpu-temp │ ├── README.md │ ├── __init__.py │ └── public │ │ └── index.html ├── cyrus-led-6 │ ├── __init__.py │ └── public │ │ └── index.html ├── cyrus-server │ ├── __init__.py │ └── public │ │ └── index.html ├── data-view │ ├── README.md │ └── public │ │ └── index.html ├── hello-world │ └── __init__.py ├── led-pulse │ └── __init__.py ├── led-toggle │ ├── __init__.py │ └── public │ │ └── index.html ├── lobby │ ├── README.md │ ├── __init__.py │ └── public │ │ └── index.html ├── mastermind │ ├── README.md │ ├── __init__.py │ └── public │ │ └── index.html ├── remote-repl │ ├── README.md │ ├── __init__.py │ └── public │ │ └── index.html ├── snake │ ├── README.md │ └── public │ │ └── index.html ├── z-broken--chess │ ├── README.md │ ├── __init__.py │ └── public │ │ └── index.html └── z-broken--led-indicator │ ├── README.md │ └── __init__.py ├── pico_fi.py └── public ├── icon.png ├── index.html ├── lib.js └── portal.html /.gitignore: -------------------------------------------------------------------------------- 1 | build/out/ 2 | build/min/ 3 | build/sync/ 4 | build/save/ 5 | **/*.zip 6 | micropython.uf2 7 | 8 | .DS_STORE 9 | __pycache__/ -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorTheme": "Default Dark+" 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Cyrus Freshman 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## pico-fi 2 | 3 | A MicroPython webserver framework for the Pico W with a captive portal for wireless network login 4 | 5 | > **Quickstart** Hold BOOTSEL & plug in Pico W 6 | > ```sh 7 | > git clone https://github.com/cfreshman/pico-fi && cd pico-fi 8 | > python3 build -a --packs remote-repl 9 | > ``` 10 | > Then connect to `w-pico` (password: `pico1234`) 11 | > See [packs/remote-repl](./src/packs/remote-repl/) for examples 12 | 13 | Network Login | Landing | `--packs remote-repl` 14 | --- | --- | --- 15 | ![](https://freshman.dev/api/file/public-pico-fi-portal-1.png) | ![](https://freshman.dev/api/file/public-pico-fi-default-index.png) | ![](https://freshman.dev/api/file/public-pico-remote-repl-mobile.png) 16 | 17 | ##### For example, toggle the on-board LED: 18 | ```python 19 | from machine import Pin 20 | import pico_fi 21 | 22 | # Connect to the Pico W's network: 23 | app = pico_fi.App(id='w-pico', password='pico1234') 24 | 25 | # (on-board LED will be on if started successfully) 26 | led = Pin('LED', Pin.OUT) 27 | @app.started 28 | def started(): led.on() 29 | 30 | # Wait for the login screen to appear 31 | # Once logged in, you'll see a button to toggle the LED 32 | @app.route('/led') 33 | def toggle_led(req, res): led.toggle() 34 | @app.route('/') 35 | def index(req, res): res.html("""""") 36 | 37 | app.run() 38 | ``` 39 | 40 | Weighs `76K` - `156K` depending on configuration, supporting minified apps up to `774K` 41 | (`2000K` Pico W flash storage - MicroPython (`1150K`) - pico-fi (`76K`)) 42 | 43 | 44 | ### Features 45 | 1. Connect the Pico to internet with your phone 46 | 1. Serve HTML from the Pico 47 | 1. Handle HTTP, WebSocket, and DNS requests 48 | 1. Persist state on the Pico with provided get & set APIs 49 | 1. Define new 'packs' for custom routes and behavior: 50 | Basic example - [packs/hello-world](./src/packs/hello-world/__init__.py) 51 | Toggle LED - [packs/led-toggle](./src/packs/led-toggle) 52 | Web console for your Pico - [packs/remote-repl](./src/packs/remote-repl) 53 | 1. Automatically build, minify, and sync changes to the Pico 54 | ``` 55 | python3 build --packs hello-world,remote-repl --minify --sync --watch 56 | ``` 57 | 58 | 59 | ### Prerequisites 60 | 61 | Hardware 62 | 1. Pico W 63 | 1. USB to Micro USB data cable 64 | 1. LED _(optional - defaults to on-board LED)_ 65 | > [I've created a starter kit with these items](https://freshman.dev/pico-packet) 66 | 67 | Software 68 | > pico-fi now installs the required software (MicroPython and rshell) for you 69 | 70 | ### Install 71 | 72 | 1. Plug in your Pico W while holding the BOOTSEL button 73 | 1. Download pico-fi & build 74 | ``` 75 | git clone https://github.com/cfreshman/pico-fi 76 | cd pico-fi 77 | python3 build --auto 78 | ``` 79 | This will automatically install MicroPython/rshell and start pico-fi on your Pico 80 | > **See [build](./build/__main__.py) for options** or run `python3 build -h` 81 | 82 | #### Connect to the internet 83 | You should see a new `w-pico` wireless network appear (password: `pico1234`). Connect to this network with your computer or smartphone. If the portal doesn't open automatically, try opening http://192.128.4.1/portal. **This may take a minute** - the Pico is doing its best. 84 | 85 | > Alternatively, specify the network credentials at build time: `python3 build -a -n "network:password"` 86 | 87 | 88 | ### Post-install 89 | 90 | Edit the network name/password or add functionality in [main.py](./src/main.py), HTML in [public/index.html](./src/public/index.html) 91 | 92 | If your [main.py](./src/main.py) grows too complex, split into separate concerns under [packs/](./src/packs/) and include each in the build: `python3 build -a pack-a,pack-b,pack-c`. Or build without minifying for accurate stack trace line numbers: `python3 build -ws pack-a,pack-b,pack-c` 93 | 94 | See [packs/hello-world](./src/packs/remote-repl/__init__.py) for a simple showcase of pico-fi features 95 | 96 | Note: prefix non-index.html files with the pack name, like cards-icon.png, because all files are built into the same base directory 97 | 98 | #### Looking for project ideas? 99 | * A multiplayer chess/checkers app anyone in the area can connect to 100 | * Publish sensor data with MQTT https://www.tomshardware.com/how-to/send-and-receive-data-raspberry-pi-pico-w-mqtt 101 | 102 | 103 | ### Potential upcoming features 104 | - [x] WebSocket event handlers 105 | - [x] remote-repl logs in real-time 106 | - [ ] Internet access through the Pico directly for connected devices (right now, devices have to reconnect to the base wifi network) 107 | - [x] Minification step to support app sizes >750K 108 | - [ ] [Create a new request](https://github.com/cfreshman/cfreshman/issues/new/choose) 109 | 110 | ### Third-party packs 111 | * (Send me any packs you make and I'll add them here) 112 | -------------------------------------------------------------------------------- /attribution/README: -------------------------------------------------------------------------------- 1 | esp8266-captive-portal: https://github.com/anson-vandoren/esp8266-captive-portal 2 | - the HTTP & DNS handling was originally forked from this library 3 | -------------------------------------------------------------------------------- /attribution/esp8266-captive-portal/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Anson VanDoren 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. 22 | -------------------------------------------------------------------------------- /build/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PICO-FI BUILD 3 | 4 | python3 build --help 5 | 6 | # Basic automatic minified builds 7 | python3 build --minify --sync --watch 8 | python3 build --auto 9 | 10 | # Minimize app size 11 | python3 build -packs basic --clean --minify --sync --watch 12 | 13 | # Enable web-based MicroPython console 14 | python3 build -p remote-repl -mcws 15 | """ 16 | 17 | import argparse, os, time, re, sys, signal, subprocess, json 18 | 19 | try: 20 | 21 | module_options = os.listdir('src/packs') 22 | default_board_name = 'w-pico' 23 | def kill(pid): 24 | if pid: 25 | os.kill(pid, signal.SIGINT) 26 | time.sleep(.1) 27 | os.system(f'kill -9 {pid}') 28 | def rshell(command, seconds=5, no_output=False): 29 | if args.verbose: print(' ', 'rshell', command) 30 | r, w = os.pipe() 31 | process = pid = None 32 | try: 33 | pid = os.fork() 34 | if pid: 35 | os.close(w) 36 | with os.fdopen(r) as f: 37 | import select 38 | i, o, e = select.select([f], [], [], seconds) 39 | if i: return f.read() 40 | elif no_output: return None 41 | else: print('rshell command timed out - try reconnecting your Pico') 42 | else: 43 | os.close(r) 44 | _r, _w = os.pipe() 45 | with os.fdopen(_w, 'w') as f: 46 | process = subprocess.Popen( 47 | f'rshell --quiet "{command}"', shell=True, 48 | stdout=f, stderr=f) 49 | with os.fdopen(_r) as f: result = f.read() 50 | if args.verbose: [print(' ', x) for x in result.split('\n')] 51 | with os.fdopen(w, 'w') as f: f.write(result) 52 | except KeyboardInterrupt: pass 53 | except Exception as e: print(e) 54 | finally: 55 | kill(pid) 56 | kill(process and process.pid) 57 | sys.exit(0) # exit if value not returned 58 | def indent(command): 59 | process = subprocess.Popen( 60 | command, shell=True, 61 | stdout=subprocess.PIPE, stderr=subprocess.PIPE, 62 | encoding='utf-8', bufsize=1) 63 | while process.poll() is None: 64 | line = process.stdout.readline() 65 | if line: print(' ', line, end='', flush=True) 66 | 67 | parser = argparse.ArgumentParser( 68 | prog='python3 build', 69 | description='Build and minify pico-fi', 70 | epilog='Automatically build and sync remote-repl:\npython3 build -a --packs remote-repl') 71 | parser.add_argument('-p', '--packs', action='append', 72 | help=', '.join(module_options)) 73 | parser.add_argument('-m', '--minify', action='store_true', 74 | help="reduce app size (50%% on average)") 75 | parser.add_argument('-w', '--watch', action='store_true', 76 | help="rebuild when files change") 77 | parser.add_argument('-s', '--sync', action='store_true', 78 | help="sync build to Pico and restart") 79 | parser.add_argument('-b', '--board', action='store', 80 | help="specify Pico board for sync if more than one connected") 81 | parser.add_argument('-c', '--clean', action='store_true', 82 | help="WARNING - may result in data loss: clear extra on-board files") 83 | parser.add_argument('-a', '--auto', action='store_true', 84 | help="same as --watch --minify --sync") 85 | parser.add_argument('-v', '--verbose', action='store_true', 86 | help="show additional output (rshell commands)") 87 | parser.add_argument('-n', '--network', 88 | help='wireless network as name:password') 89 | parser.add_argument('--no-wait', action='store_true', 90 | help='skip waits between commands') 91 | parser.add_argument('-M', '--no-minify', action='store_true', 92 | help="skip minification for --auto builds (to preserve line numbers)") 93 | parser.add_argument('PACKS', nargs='*') 94 | 95 | args = parser.parse_args() 96 | packs = [] 97 | for pack in (args.packs or []) + (args.PACKS or []): 98 | packs.extend(pack.split(',')) 99 | args.packs = packs 100 | if args.auto: 101 | args.watch = args.minify = True 102 | if not args.sync: args.sync = True 103 | if args.no_minify: args.minify = False 104 | if args.sync: 105 | # Check for board mounted with BOOTSEL 106 | rpi_mount_dir = os.popen(f""" 107 | echo $( \\ 108 | [ $(uname) == Darwin ] && echo /Volumes || echo /media/$USER \\ 109 | )/RPI-RP2 110 | """).readline().strip() 111 | def _mounted(): 112 | return 'y' in os.popen( 113 | f"[ -d {rpi_mount_dir} ] && echo y || echo ''").read().strip() 114 | if _mounted(): 115 | print(f"Found uninitialized Pico at {rpi_mount_dir}") 116 | mp_response = input( 117 | "Install MicroPython for Pico W? [Y/n] ") or 'y' 118 | if mp_response.lower()[0] == 'y': 119 | wait_from = time.time() 120 | if os.popen(f'[ -f build/micropython.uf2 ] || echo n').read().strip(): 121 | os.system(f""" 122 | curl -L http://micropython.org/download/rp2-pico-w/rp2-pico-w-latest.uf2 > build/micropython.uf2 123 | """) 124 | os.system(f""" 125 | cp build/micropython.uf2 {rpi_mount_dir}/ 126 | """) 127 | print( 128 | 'MicroPython installed, waiting for Pico to disconnect and restart') 129 | for i in range(10): time.sleep(1) 130 | 131 | # Verify rshell is installed 132 | if not os.popen('which rshell').read().strip(): 133 | rshell_response = input( 134 | "Unable to find rshell, install it now? [Y/n] ") or 'y' 135 | if rshell_response.lower()[0] == 'y': os.system("pip3 install rshell") 136 | 137 | def _read_boards(): 138 | result = rshell('boards') or '' 139 | return [x.split(' @ ')[0] for x in result.split('\n') if '@' in x] 140 | board_names = _read_boards() 141 | print('Connected boards:', ''.join(board_names) or None) 142 | if not board_names: 143 | print(f"Retrying in 10s") 144 | time.sleep(10) 145 | board_names = _read_boards() 146 | if not board_names: 147 | print(f"Connect your Pico or run without --sync") 148 | print(f"If your Pico is already connected, reconnect while holding BOOTSEL and re-run this script to reinstall MicroPython") 149 | sys.exit(0) 150 | if args.board: args.sync = args.board 151 | elif args.sync: args.sync = board_names[0] 152 | print(board_names, args.sync) 153 | 154 | if args.sync not in board_names: 155 | print(f"Couldn't find board \"{args.sync}\"") 156 | sys.exit(0) 157 | wait = lambda: 0 if args.no_wait else time.sleep(3) 158 | 159 | print(args) 160 | 161 | # 1. Clear out/ and min/ 162 | # 1. Copy src/ to out/ 163 | # 2. Copy packs to out/ 164 | # 3. (Optional) Minify 165 | # 4. (Optional) Sync to board 166 | # 4. (Optional) Watch for updates 167 | 168 | if 'help' in args: print(parser.print_help()) 169 | else: 170 | while True: 171 | start = time.time() 172 | print('\nBuilding pico-fi') 173 | 174 | os.system('rm -rf build/out && rm -rf build/min') 175 | if args.sync and args.clean: 176 | print("\nCleaning existing files from Pico") 177 | wait() 178 | os.system(f""" 179 | mkdir -p build/save 180 | """) 181 | for save in ['board.py', 'store.json', 'network.json']: 182 | rshell(f'cp /{args.sync}/{save} build/save/') 183 | rshell(f'rm -rf /{args.sync}') 184 | rshell(f'cp build/save/* /pyboard') 185 | os.system('rm -rf build/sync') 186 | 187 | # Only copy new files & changes to avoid excessive rsync 188 | # Copy src/* to out/ 189 | # Copy src/packs//* to out/packs 190 | # Copy src/packs//public/* to out/public 191 | # Write module list to out/packs/__init__.py 192 | potential_conflicts = set() 193 | index_compilation = [] 194 | for (pack, name, walk) in [ 195 | (None, 'src', os.walk('src')), 196 | *((x, 'packs', os.walk(f'src/packs/{x}')) for x in args.packs or [])]: 197 | for root, dirs, files in walk: 198 | if 'src' == name and 'packs' in root: continue 199 | for file in files: 200 | if 'README' in file: continue 201 | src_path = os.path.join(root, file) 202 | src_modified = os.path.getmtime(src_path) 203 | # always copy public files from packs 204 | if 'packs' == name and '/public/' in src_path: 205 | build_path = 'build/out/public/'+file 206 | build_modified = 0 207 | else: 208 | build_path = src_path.replace('src', 'build/out') 209 | try: build_modified = os.path.getmtime(build_path) 210 | except Exception as e: build_modified = 0 211 | build_dir = build_path.replace(file, '') 212 | index_page = pack and build_path == 'build/out/public/index.html' 213 | if index_page: index_compilation.append([pack, src_path]) 214 | if build_modified < src_modified: 215 | print(src_path, '->', build_path) 216 | if name != 'src' and not index_page: 217 | if build_path in potential_conflicts: 218 | raise Exception(f'pack path conflict: {build_path}') 219 | else: 220 | potential_conflicts.add(build_path) 221 | os.system(f""" 222 | mkdir -p {build_dir} && cp -rf {src_path} {build_dir} 223 | """) 224 | 225 | if len(index_compilation) > 1: 226 | print('\nGenerating compiled index for multiple packs') 227 | # Compile index page for multiple packs 228 | # Copy packs as public/.html (to preserve relative links) 229 | build_dir = 'build/out/public/' 230 | os.system(f""" 231 | mkdir -p {build_dir} 232 | """) 233 | links = [] 234 | for (pack, src_path) in index_compilation: 235 | file_name = f'./{pack}.html' 236 | links.append([pack, file_name]) 237 | os.system(f""" 238 | cp -rf {src_path} {build_dir}{file_name} 239 | """) 240 | links_html = '\n'.join( 241 | f'{pack}' for (pack, href) in links) 242 | html_root = """ 243 |
[ installed packs ]
244 |
245 |
249 | %s 250 |
251 | 277 | """ % (links_html) 278 | 279 | html = None 280 | with open('build/empty.html') as f: html = f.read() 281 | if html: 282 | html = html.replace('%ROOT%', html_root) 283 | with open('build/out/public/index.html', 'w') as f: f.write(html) 284 | 285 | packs_path = 'build/out/packs/__init__.py' 286 | os.makedirs(re.sub(r'/[^/]+$', '', packs_path), exist_ok=True) 287 | with open(packs_path, 'w') as f: f.write(f'packs = {args.packs}') 288 | 289 | print(f'\nBuilt in {time.time() - start:.2f}s') 290 | 291 | sync_dir = 'build/out' 292 | if args.minify: 293 | print('\nMinify out/ -> min/') 294 | wait() 295 | indent('python3 build/minify.py') 296 | sync_dir = 'build/min' 297 | 298 | # Sync build to board 299 | class WatchInterrupt(Exception): pass 300 | try: 301 | repl_pid = process = None 302 | if args.sync: 303 | print(f'\nSyncing to {args.sync}') 304 | result = rshell(f'ls /{args.sync}') 305 | if 'Cannot access' in result: 306 | print(f"Couldn't find {args.sync}") 307 | sys.exit(0) 308 | wait() 309 | 310 | if args.network: 311 | net_list = [] 312 | net_logins = {} 313 | for ssid_key in args.network.split(','): 314 | ssid, key = ssid_key.split(':', maxsplit=1) 315 | net_list.append(ssid) 316 | net_logins[ssid] = key 317 | print('Writing network credentials:', net_logins) 318 | with open(f'{sync_dir}/network.json', 'w') as f: 319 | f.write(json.dumps({ 'list': net_list, 'logins': net_logins })) 320 | 321 | # Only sync changed files 322 | os.system(f'mkdir -p build/sync') 323 | diff_output = os.popen(f'diff -qr {sync_dir} build/sync').read() 324 | updated = [] 325 | restart = True # False 326 | for line in diff_output.split('\n'): 327 | # parse file names after 'Only in' or 'Files' 328 | filepath = None 329 | if f'Only in {sync_dir}' in line: 330 | # Only in : 331 | [only, name] = line.split(': ') 332 | dir = only.split(' ')[-1] 333 | filepath = os.path.join(dir, name) 334 | if 'Files' in line and 'differ' in line: 335 | # Files and differ 336 | filepath = line.replace('Files ', '').split(' ')[0] 337 | 338 | if filepath: 339 | updated.append(filepath) 340 | # only restart if files outside public/ changed 341 | if not 'public/' in filepath: restart = True 342 | print('Updated files:\n' + '\n'.join(updated or ['(none)'])) 343 | for filepath in updated: 344 | sync_filepath = filepath.replace(sync_dir, 'build/sync') 345 | sync_filedir = re.sub(r'/[^/]*$', '', sync_filepath) 346 | os.system(f""" 347 | mkdir -p {sync_filedir} 348 | cp -rf {filepath} {sync_filepath} 349 | """) 350 | 351 | rshell(f'rsync build/sync /{args.sync}', 60) 352 | 353 | print('\nRestarting device') 354 | # if restart: 355 | # try: rshell('repl ~ machine.reset() ~', 1, True) 356 | # except: pass # expected 357 | # time.sleep(5) 358 | process = subprocess.Popen( 359 | ('rshell', 'repl ~ machine.soft_reset()' if restart else 'repl'), 360 | stderr=None) 361 | if args.watch: 362 | # fork process to open rshell console & watch files at the same time 363 | repl_pid = os.fork() 364 | if not repl_pid: process.communicate() 365 | if not repl_pid: sys.exit(0) 366 | 367 | # Watch 'src' and 'packs' for file changes 368 | print(f'\nWatching for changes') 369 | print(process and process.pid, repl_pid) 370 | while True: 371 | time.sleep(1) 372 | for walk in [os.walk('src')]: 373 | for root, dir, files in walk: 374 | for file in files: 375 | path = os.path.join(root, file) 376 | if start < os.path.getmtime(path): 377 | print('Update:', path) 378 | kill(process.pid) 379 | kill(repl_pid) 380 | raise WatchInterrupt 381 | except WatchInterrupt: pass 382 | except KeyboardInterrupt: pass 383 | -------------------------------------------------------------------------------- /build/empty.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | pico-fi 5 | 6 | 7 | 8 | 22 | 33 | 34 | 35 | 36 |
%ROOT%
37 | 38 |

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'' 22 | 23 | 24 | """ 25 | transport 26 | """ 27 | import socket 28 | 29 | from lib import defaulter_dict, enumstr 30 | 31 | 32 | class transport(enumstr): 33 | def __init__(self, value, sock_type): 34 | super().__init__(value) 35 | self.sock_type = sock_type 36 | 37 | class Transport: 38 | UDP = transport(b'UDP', socket.SOCK_DGRAM) 39 | TCP = transport(b'TCP', socket.SOCK_STREAM) 40 | 41 | @staticmethod 42 | def of(sock_type: int): return { 43 | socket.SOCK_DGRAM: Transport.UDP, 44 | socket.SOCK_STREAM: Transport.TCP 45 | }[sock_type] 46 | 47 | 48 | class connection: 49 | _instances: dict[socket.SocketKind, object] = defaulter_dict() 50 | 51 | def __init__(self, tran: transport, sock: socket.socket=None): 52 | self.tran = tran 53 | if not sock: sock = socket.socket(socket.AF_INET, tran.sock_type) 54 | self.sock = sock 55 | connection._instances[id(sock)] = self 56 | 57 | def __repr__(self): return f'' 58 | def __hash__(self): return id(self.sock) 59 | 60 | @staticmethod 61 | def of(sock: socket.socket): 62 | return connection._instances.get(id(sock), lambda x: log.info('missing socket for', id(sock))) 63 | 64 | 65 | class protocol(enumstr): 66 | _transports: dict[enumstr, transport] = {} 67 | def __init__(self, value, transport: transport): 68 | super().__init__(value) 69 | if self in self._transports: 70 | if self._transports[self] != transport: 71 | raise Exception(f'multiple transports ({self._transports[self], transport}) for protocol {self}') 72 | else: self._transports[self] = transport 73 | 74 | @property 75 | def transport(self): return self._transports[self] 76 | 77 | class Protocol: 78 | DNS = protocol(b'DNS', Transport.UDP) 79 | HTTP = protocol(b'HTTP', Transport.TCP) 80 | WebSocket = protocol(b'WebSocket', Transport.TCP) 81 | 82 | 83 | """ 84 | orchestrator 85 | """ 86 | import select 87 | import socket 88 | 89 | 90 | class Orchestrator(SocketPollHandler): 91 | """direct socket events through registered handlers""" 92 | 93 | def __init__(self, poller: select.poll): 94 | super().__init__(poller, 'Orchestrator') 95 | self.handlers: dict[int or protocol, SocketPollHandler or protocol] = {} 96 | # TODO allow for handler chain 97 | 98 | def register(self, 99 | conn: connection or protocol or transport, 100 | handler: SocketPollHandler or protocol or transport): 101 | 102 | log.info('register', conn, 'to', handler) 103 | self.handlers[conn] = handler 104 | 105 | def unregister(self, 106 | conn: connection or protocol or transport, 107 | handler: SocketPollHandler or protocol or transport): 108 | 109 | log.info('unregister', handler, 'for', conn) 110 | if self.handlers[conn] == handler: del self.handlers[conn] 111 | 112 | def handle(self, sock: socket.socket, event): 113 | conn = connection.of(sock) 114 | 115 | # resolve handler for connection 116 | handler = self.handlers.get(conn) 117 | if isinstance(handler, transport): handler = self.handlers.get(handler) 118 | if isinstance(handler, protocol): handler = self.handlers.get(handler) 119 | if handler and not isinstance(handler, SocketPollHandler): 120 | raise Exception(f'failed handler resolution (sock -> transport -> protocol -> handler), ended with protocol {handler}') 121 | 122 | if handler: 123 | # log.info('route', conn, 'to', handler) 124 | handler.handle(sock, event) # TODO pass to next handler if True 125 | else: log.info('no handler for', conn, 'in', self.handlers) 126 | 127 | 128 | """ 129 | protocol handler 130 | """ 131 | import socket 132 | 133 | 134 | class ProtocolHandler(SocketPollHandler): 135 | """handle socket events according to protocol""" 136 | 137 | def __init__(self, orch: Orchestrator, proto: protocol, name: str=None): 138 | super().__init__(orch.poller, name or proto) 139 | self.orch = orch 140 | self.proto = proto 141 | self.orch.register(proto, self) 142 | 143 | def stop(self): 144 | self.orch.unregister(self.proto, self) 145 | 146 | 147 | """ 148 | server 149 | """ 150 | import select 151 | import socket 152 | 153 | 154 | class Server(ProtocolHandler): 155 | def __init__(self, orch: Orchestrator, port: int, proto: protocol, name: str=None): 156 | orch.register(proto.transport, proto) # register proto as default for transport 157 | super().__init__(orch, proto, name) 158 | 159 | # create server socket 160 | self.conn = connection(proto.transport) 161 | self.sock = self.conn.sock 162 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # allow overlapped reads 163 | 164 | self.orch.register(self.conn, self) # register as handler for connection 165 | self.poller.register(self.sock, select.POLLIN) # register socket for event polling 166 | 167 | # bind address to socket 168 | addr = socket.getaddrinfo('0.0.0.0', port)[0][-1] 169 | self.sock.bind(addr) 170 | 171 | log.info(self.name, f'listening on :{port}', proto.transport, proto) 172 | 173 | def stop(self): 174 | self.poller.unregister(self.sock) 175 | self.conn.sock.close() 176 | super().stop() 177 | 178 | 179 | 180 | """ 181 | other: IP sink 182 | """ 183 | class IpSink: 184 | def __init__(self, ip: bytes or str): self.ip = encode(ip) if ip else None 185 | def get(self): return self.ip 186 | def set(self, ip): self.ip = encode(ip) if ip else None 187 | -------------------------------------------------------------------------------- /src/lib/store.py: -------------------------------------------------------------------------------- 1 | """ 2 | simple global persistent state 3 | """ 4 | 5 | import json 6 | 7 | from lib.logging import log 8 | 9 | 10 | # global state object 11 | store = {} 12 | class Store: 13 | store=store 14 | def read(data: dict, store=store): 15 | # read data keys out of store 16 | for k in (data if data else store): 17 | if k in store: 18 | v = store[k] 19 | if type(v) is dict and type(data[k]) is dict: 20 | Store.read(data[k], v) 21 | else: 22 | data[k] = v 23 | return data 24 | def get(key: str, default=None): 25 | log.info(key, json.dumps(store)) 26 | return Store.read({ key: default })[key] 27 | 28 | def write(data: dict, store=store): 29 | # write data values onto store 30 | # prevent changes to specificity (swapping b/n dict & non-dict values) 31 | for k, v in data.items(): 32 | write_dict = type(v) is dict 33 | has_k = k in store 34 | is_dict = has_k and type(store[k]) is dict 35 | if write_dict and is_dict: Store.write(v, store[k]) 36 | elif has_k and not (v is None or store[k] is None) and is_dict != write_dict: pass 37 | elif v is None: del store[k] 38 | else: store[k] = v 39 | log.debug('store write', k, v, k in store and store[k]) 40 | 41 | def save(): 42 | with open('store.json', 'w') as f: f.write(json.dumps(store)) 43 | log.debug('saved store', store) 44 | def load(): 45 | try: 46 | f = open('store.json', 'r') 47 | except Exception as e: 48 | # log.exception(e, 'unable to load store\n(for first-time set up, touch store.json)') 49 | # sys.exit(1) 50 | f = open('store.json', 'x') 51 | f.write('') 52 | 53 | Store.write(json.loads(f.read() or json.dumps({}))) 54 | f.close() 55 | log.debug('loaded store', store) 56 | -------------------------------------------------------------------------------- /src/lib/stream/tcp.py: -------------------------------------------------------------------------------- 1 | """ 2 | TCP stream read & write 3 | """ 4 | from collections import namedtuple 5 | import gc 6 | import io 7 | import select 8 | import socket 9 | 10 | from lib import MergedReadInto, defaulter_dict 11 | from lib.logging import log 12 | from lib.server import connection 13 | 14 | 15 | class TCP: 16 | """read & write data to TCP""" 17 | 18 | # TCP/IP MSS is 536 bytes 19 | MSS = 536 20 | Writer = namedtuple('Writer', 'data buff buffmv range') 21 | 22 | def __init__(self, poller: select.poll): 23 | self._poller = poller 24 | self._reads: dict[int, bytes] = {} 25 | self._writes: defaulter_dict[int, list[TCP.Writer]] = defaulter_dict() 26 | 27 | def read(self, sock: socket.socket): 28 | """read client request data from socket""" 29 | 30 | # append data to full request 31 | sid = id(sock) 32 | try: 33 | request = self._reads.get(sid, b'') + sock.read() 34 | except: 35 | request = b'' 36 | self.end(sock) 37 | self._reads[sid] = request 38 | return request 39 | 40 | def prepare(self, sock: socket.socket, *data: list[bytes or io.BufferedIOBase]): 41 | """prepare data for transmission and signal write event to poller""" 42 | 43 | data = MergedReadInto(io.BytesIO(x) if isinstance(x, bytes) else x for x in data) 44 | 45 | # fill buffer of TCP/IP MSS bytes 46 | buff = bytearray(b'00' * TCP.MSS) 47 | self._writes.get(id(sock), lambda x: []).append( 48 | TCP.Writer(data, buff, memoryview(buff), [0, data.readinto(buff)])) 49 | self._poller.modify(sock, select.POLLOUT) 50 | 51 | def write(self, sock: socket.socket) -> True or None: 52 | """write next packet, return True if all packets written""" 53 | 54 | writers = self._writes.get(id(sock)) 55 | if not writers: return True # no data to write 56 | curr: TCP.Writer = writers[0] 57 | 58 | # write next range of bytes from body buffer (limited by TCP/IP MSS) 59 | try: bytes_written = sock.write(curr.buffmv[curr.range[0]:curr.range[1]]) 60 | except OSError as e: 61 | writers.remove(curr) 62 | log.exception(e, 'cannot write to a closed socket') 63 | return True 64 | 65 | log.debug(bytes_written, 'bytes written to', connection.of(sock)) 66 | if bytes_written == curr.range[1] - curr.range[0]: 67 | # write next section of body into buffer 68 | curr.range[0] = 0 69 | curr.range[1] = curr.data.readinto(curr.buff) 70 | if curr.range[1] == 0: 71 | # out of data, remove writer from list 72 | writers.remove(curr) 73 | # return True if all pending writes written 74 | if not writers: return True 75 | else: 76 | # didn't write entire range, increment start for next write 77 | curr.range[0] += bytes_written 78 | 79 | def clear(self, sock: socket.socket): 80 | """clear stored data for socket, but leave socket open and untouched""" 81 | 82 | sid = id(sock) 83 | if sid in self._reads: del self._reads[sid] 84 | if sid in self._writes: del self._writes[sid] 85 | gc.collect() 86 | 87 | def end(self, sock: socket.socket): 88 | """close socket, unregister from poller, and clear data""" 89 | 90 | while not self.write(sock): pass 91 | log.info('end', connection.of(sock)) 92 | sock.close() 93 | self._poller.unregister(sock) 94 | self.clear(sock) 95 | -------------------------------------------------------------------------------- /src/lib/stream/ws.py: -------------------------------------------------------------------------------- 1 | import io 2 | import socket 3 | import select 4 | 5 | from lib.stream.tcp import TCP 6 | from lib import decode_bytes, defaulter_dict, encode, encode_bytes, format_bytes, enum 7 | from lib.logging import log 8 | 9 | 10 | class enum: pass 11 | 12 | class WS(TCP): 13 | """ 14 | read & write WebSocket frames 15 | write same as TCP, but parse & prepare additional info to signal frames 16 | over otherwise continuous connection 17 | """ 18 | 19 | class FIN: 20 | NON_FINAL = 0x0 21 | FINAL = 0x1 22 | @staticmethod 23 | def name(final): 24 | return ( 25 | [x for x in dir(WS.FIN) if getattr(WS.FIN, x) == final] 26 | or [None])[0] 27 | 28 | class Opcode: 29 | CONTINUE = 0x0 30 | TEXT = 0x1 31 | BINARY = 0x2 32 | CLOSE = 0x8 33 | PING = 0x9 34 | PONG = 0xA 35 | @staticmethod 36 | def name(opcode): 37 | return ( 38 | [x for x in dir(WS.Opcode) if getattr(WS.Opcode, x) == opcode] 39 | or [None])[0] 40 | 41 | class Mask: 42 | OFF = 0x0 43 | ON = 0x1 44 | 45 | LOW_PAYLOAD_LEN = 125 46 | MID_PAYLOAD_LEN = 2 << 15 - 1 47 | MAX_PAYLOAD_LEN = 2 << 62 - 1 # I don't think we'll need to worry about that 48 | 49 | class ReadFrame: 50 | def __init__(self, sock: socket.socket): 51 | # expect start of WebSocket frame 52 | self.sock = sock 53 | self.data = b'' 54 | self.done = False 55 | self.final = False 56 | self.opcode = None 57 | 58 | def read(self): 59 | if self.data: # continue prev read 60 | self.data += self.sock.read(self.len - len(self.data)) 61 | else: 62 | try: bAB = self.sock.recv(2) 63 | except: bAB = None 64 | if not bAB: return # no data to read 65 | bA = bAB[0] 66 | self.final = (bA & 0b1000_0000) > 7 67 | self.opcode = bA & 0b0000_1111 68 | log.debug( 69 | 'WebSocket frame read: header', format_bytes(bAB), 70 | WS.FIN.name(self.final), WS.Opcode.name(self.opcode)) 71 | bB = bAB[1] 72 | self.mask = bB & 0b1000_0000 73 | self.len = bB & 0b0111_1111 74 | ext = 0 75 | if self.len == 126: ext = 2 76 | if self.len == 127: ext = 8 77 | if ext: self.len = decode_bytes(self.sock.recv(ext)) 78 | 79 | self.mask = self.sock.recv(4) if self.mask else 0 80 | self.data = bytearray(self.sock.read(self.len)) 81 | if len(self.data) == self.len: 82 | # read completed, unmask data 83 | if self.mask: 84 | for i in range(len(self.data)): 85 | j = i % 4 86 | self.data[i] = self.data[i] ^ self.mask[j] 87 | log.debug( 88 | 'WebSocket completed frame read:', 89 | WS.Opcode.name(self.opcode), self.data) 90 | self.done = True 91 | else: 92 | log.debug('WebSocket frame read:', len(self.data), '/', self.len, 'bytes') 93 | 94 | class ReadMessage: 95 | def __init__(self, sock): 96 | self.sock = sock 97 | self.frame: WS.ReadFrame = WS.ReadFrame(sock) 98 | self.data = b'' 99 | self.done = False 100 | self.opcode = None 101 | 102 | def read(self): 103 | self.frame.read() 104 | self.opcode = self.opcode or self.frame.opcode 105 | if self.frame.done: 106 | self.data += self.frame.data 107 | if self.frame.final: 108 | self.done = True 109 | del self.frame 110 | else: 111 | self.frame = WS.ReadFrame(self.sock) 112 | elif not self.frame.data: # initial frame empty, end read 113 | self.done = True 114 | 115 | 116 | def __init__(self, poller): 117 | super().__init__(poller) 118 | self.messages: dict[int, WS.ReadMessage] = defaulter_dict() 119 | 120 | def end(self, sock: socket.socket): 121 | del self.messages[id(sock)] 122 | super().end(sock) 123 | 124 | def read(self, sock: socket.socket) -> tuple(bytes, str or bytes): 125 | message = self.messages.get(id(sock), lambda x: WS.ReadMessage(sock)) 126 | try: message.read() 127 | except Exception as e: 128 | log.exception(e) 129 | message.opcode = WS.Opcode.CLOSE 130 | message.done = True 131 | if message.done: 132 | if message.opcode == WS.Opcode.CLOSE: self.end(sock) 133 | else: 134 | del self.messages[id(sock)] 135 | self._poller.modify(sock, select.POLLOUT) 136 | return [message.opcode, message.data] 137 | 138 | def send(self, 139 | sock: socket.socket, 140 | opcode: int, 141 | message: str or bytes=b''): 142 | 143 | # construct frame - send in single message 144 | header = bytearray() 145 | data = encode(str(message)) 146 | 147 | FIN_RSV_opcode = WS.FIN.FINAL << 7 | opcode 148 | header += bytes((FIN_RSV_opcode,)) 149 | 150 | payload_len = len(data) 151 | ext_payload_len = b'' 152 | if payload_len > WS.MID_PAYLOAD_LEN: 153 | # use next 8 bytes for length 154 | ext_payload_len = encode_bytes(payload_len, 8) 155 | payload_len = 127 156 | elif payload_len > WS.LOW_PAYLOAD_LEN: 157 | # use next 2 bytes for length 158 | ext_payload_len = encode_bytes(payload_len, 2) 159 | payload_len = 126 160 | header += bytes((WS.Mask.OFF << 7 | payload_len,)) + ext_payload_len 161 | 162 | log.debug( 163 | 'WebSocket frame send:', format_bytes(header[:2]), 164 | WS.FIN.name(header[0] > 7), WS.Opcode.name(header[0] & 1), 165 | 'length:', len(data)) 166 | 167 | super().prepare(sock, io.BytesIO(header + data)) 168 | 169 | def prepare(self, sock: socket.socket, *data: list[bytes or io.BufferedIOBase]): 170 | """prepare WebSocket frames""" 171 | 172 | message = b'' 173 | for x in data: 174 | if isinstance(x, io.BytesIO): message += x.read() 175 | else: message += x 176 | 177 | # prepend frame info for data 178 | self.send(sock, WS.Opcode.TEXT, message) 179 | 180 | def clear(self, sock: socket.socket): 181 | if id(sock) in self.messages: del self.messages[id(sock)] 182 | super().clear(sock) 183 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import pico_fi 2 | 3 | 4 | pico_fi.run(id='w-pico', password='pico1234', indicator='LED') 5 | # You should see wireless network 'w-pico' appear 6 | # Log in with the above password 7 | # The on-board LED will visualize network activity 8 | # Optional: connect an external LED (set indicator to GPIO #) 9 | 10 | 11 | 12 | """ 13 | Example with randomized network name (w-pico-#######) and custom routes 14 | """ 15 | # from machine import Pin 16 | # import pico_fi 17 | # from lib.handle.http import HTTP 18 | 19 | 20 | # led = Pin('LED', Pin.OUT) 21 | # app = pico_fi.App(id=7, password='pico1234') 22 | 23 | # @app.route('/led') 24 | # def toggle_led(req: HTTP.Request, res: HTTP.Response): 25 | # led.toggle() 26 | 27 | # @app.route('/') 28 | # def index(req: HTTP.Request, res: HTTP.Response): res.html(""" 29 | # """) 33 | 34 | # led.on() # turn on LED if app was initialized successfully 35 | # app.run() # listen for requests 36 | -------------------------------------------------------------------------------- /src/packs/basic-led/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | pico-basic-led 3 | 4 | toggle the on-board LED 5 | $ python3 build -a basic-led 6 | """ 7 | from lib.logging import log 8 | from pico_fi import App 9 | from machine import Pin 10 | 11 | def configure(app: App): 12 | # runs after app is initialized 13 | log.info('LED configure') 14 | app.indicator = None 15 | 16 | # on-board LED will be on if started successfully 17 | led = Pin('LED', Pin.OUT) 18 | @app.started 19 | def started(): led.on() 20 | 21 | # wait for the login screen to appear 22 | # once logged in, you'll see a button to toggle the LED 23 | @app.route('/led') 24 | def toggle_led(req, res): led.toggle() 25 | -------------------------------------------------------------------------------- /src/packs/basic-led/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/packs/basic-socket/README.md: -------------------------------------------------------------------------------- 1 | ## base-socket 2 | 3 | ### Setup 4 | 1. Install [pico-fi](/README.md#install) 5 | 1. Build with **base-socket** enabled 6 | ``` 7 | python3 build -a base-socket 8 | ``` 9 | 1. Design on top of this pack for socketed applications 10 | -------------------------------------------------------------------------------- /src/packs/basic-socket/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | pico-basic-socket 3 | 4 | simple socket showcase 5 | """ 6 | from lib.logging import log 7 | from pico_fi import App 8 | from lib.handle.http import HTTP 9 | from lib.handle.ws import WebSocket 10 | 11 | sockets = {} 12 | 13 | def configure(app: App): 14 | 15 | @app.event('echo-join') 16 | def join(msg: WebSocket.Message): 17 | global sockets 18 | id = msg.content 19 | if id not in sockets: 20 | for other in sockets.keys(): 21 | msg.reply('echo-join', other) 22 | sockets[id] = msg 23 | for socket in sockets.values(): 24 | socket.reply(f'echo-join {id}') 25 | 26 | @app.event('echo-echo') 27 | def echo(msg: WebSocket.Message): 28 | global sockets 29 | id, message = msg.content.split(' ', 1) 30 | if id in sockets: 31 | for socket in sockets.values(): 32 | socket.reply(f'echo-echo {id} {message}') 33 | 34 | @app.event('echo-leave') 35 | def leave(msg: WebSocket.Message): 36 | global sockets 37 | id = msg.content 38 | if id in sockets: 39 | del sockets[id] 40 | for socket in sockets.values(): 41 | socket.reply(f'echo-leave {id}') 42 | -------------------------------------------------------------------------------- /src/packs/basic-socket/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | echo 8 | 62 | 63 | 64 | 65 |
68 | 152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /src/packs/cpu-temp/README.md: -------------------------------------------------------------------------------- 1 | ## pico-cpu-temp 2 | 3 | Example with added API *and* HTML display 4 | 5 | ### Setup 6 | 1. Install [pico-fi](/README.md#install) 7 | 1. Build with **cpu-temp** enabled 8 | ``` 9 | python3 build -a cpu-temp 10 | ``` 11 | -------------------------------------------------------------------------------- /src/packs/cpu-temp/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | pico-cpu-temp 3 | 4 | simple example of API and HTML display 5 | """ 6 | 7 | from machine import ADC 8 | 9 | from pico_fi import App 10 | from lib.logging import log 11 | from lib.handle.http import HTTP 12 | from lib.handle.ws import WebSocket 13 | 14 | sensor_temp = ADC(4) 15 | def reading_to_celsius(reading): 16 | return 27 - ((reading * 3.3 / 65535) - 0.706)/0.001721 17 | def reading_to_fahrenheit(reading): 18 | return reading_to_celsius(reading) * 9/5 + 32 19 | 20 | def configure(app: App): 21 | 22 | @app.route('/cpu-temperature') 23 | def cpu_temperature(req: HTTP.Request, res: HTTP.Response): 24 | farenheit = reading_to_fahrenheit(sensor_temp.read_u16()) 25 | log.info('CPU temperature reading:', farenheit) 26 | res.text(f'{farenheit}') 27 | -------------------------------------------------------------------------------- /src/packs/cpu-temp/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | pico-cpu-temp 5 | 6 | 7 | 8 | 19 | 20 | 21 | 22 | 23 |
24 |
25 | pico-cpu-temp  CPU temperature reading 26 | [ back to menu ] 27 | 30 |
31 |
32 |
33 | last reading (°F): (no readings yet) 34 |
35 | 46 |
47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/packs/cyrus-led-6/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | pico-led-6 3 | 4 | turn on 6 LEDs 5 | """ 6 | from lib.logging import log 7 | from pico_fi import App 8 | from lib import LED 9 | 10 | """ 11 | SETTINGS 12 | """ 13 | GPIO = None 14 | # set to GP## of component (or list) to use instead of on-board LED, for example: 15 | GPIO = ['LED', 16, 12, 19, 8, 26, 4] 16 | """""" 17 | 18 | def configure(app: App): 19 | power = True 20 | app.indicator = None 21 | leds = [LED(pin=pin, brightness=1) for pin in GPIO] 22 | 23 | @app.started 24 | def started(): 25 | for led in leds: led.on() 26 | 27 | @app.route('/led-toggle') 28 | def toggle_power(req, res): 29 | nonlocal power 30 | power = not power 31 | for led in leds: 32 | if power: led.on() 33 | else: led.off() 34 | log.info('toggled LEDs:', power) 35 | res.ok() 36 | 37 | @app.route('/led-brightness') 38 | def set_brightness(req, res): 39 | brightness = float(req.query['x']) 40 | for led in leds: led.set(brightness) 41 | log.info('set LED brightness:', brightness) 42 | res.ok() 43 | -------------------------------------------------------------------------------- /src/packs/cyrus-led-6/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 14 |
18 | 19 |
20 | 21 |
22 | -------------------------------------------------------------------------------- /src/packs/cyrus-server/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | pico-cyrus-server 3 | 4 | companion to freshman.dev 5 | """ 6 | from lib.logging import log 7 | from pico_fi import App 8 | from lib import LED 9 | 10 | """ 11 | SETTINGS 12 | """ 13 | GPIO = None 14 | # set to GP## of component (or list) to use instead of on-board LED, for example: 15 | GPIO = ['LED', 16, 12, 19, 8, 26, 4] 16 | """""" 17 | 18 | def configure(app: App): 19 | power = True 20 | app.indicator = None 21 | leds = [LED(pin=pin, brightness=1) for pin in GPIO] 22 | 23 | @app.connected 24 | def connected(): 25 | for led in leds: led.on() 26 | 27 | @app.route('/online') 28 | def set_online(req, res): 29 | online = int(req.query['x']) 30 | log.info('set online LEDs:', online) 31 | for i in range(len(leds)): 32 | if i < online: leds[i].on() 33 | else: leds[i].off() 34 | res.ok() 35 | 36 | @app.route('/led-toggle') 37 | def toggle_power(req, res): 38 | nonlocal power 39 | power = not power 40 | for led in leds: 41 | if power: led.on() 42 | else: led.off() 43 | log.info('toggled LEDs:', power) 44 | res.ok() 45 | 46 | @app.route('/led-brightness') 47 | def set_brightness(req, res): 48 | brightness = float(req.query['x']) 49 | for led in leds: led.set(brightness) 50 | log.info('set LED brightness:', brightness) 51 | res.ok() 52 | -------------------------------------------------------------------------------- /src/packs/cyrus-server/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 14 |
18 | 19 |
20 | 21 |
22 | -------------------------------------------------------------------------------- /src/packs/data-view/README.md: -------------------------------------------------------------------------------- 1 | ## pico-data-view 2 | 3 | View & edit data persisted on the Pico W with pico-fi's get/set API 4 | 5 | 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 | pico-data-view 5 | 6 | 7 | 8 | 26 | 27 | 28 | 29 | 30 |
31 | pico-data-view  persisted JSON data 32 | [ back to menu ] 33 | 36 |
37 | 38 | 110 | 111 |
115 | JSON format  116 | 117 |   118 |
119 | 163 | 164 | 165 | 166 | -------------------------------------------------------------------------------- /src/packs/hello-world/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | pico-hello-world 3 | 4 | basic module configuration showcase 5 | $ python3 build -a hello-world 6 | """ 7 | from lib.logging import log 8 | from pico_fi import App 9 | from lib.handle.http import HTTP 10 | from lib.handle.ws import WebSocket 11 | 12 | log.info('HELLO WORLD import') 13 | 14 | def configure(app: App): 15 | # runs after app is initialized 16 | log.info('HELLO WORLD configure') 17 | 18 | @app.started 19 | def started(): 20 | # runs after access point has been started 21 | log.info('HELLO WORLD started') 22 | 23 | @app.connected 24 | def connected(): 25 | # runs after connected to wifi 26 | log.info('HELLO WORLD connected') 27 | 28 | @app.route('/hello') 29 | def hello(req: HTTP.Request, res: HTTP.Response): 30 | # runs when an HTTP request is made to /hello: 31 | # > fetch('/hello').then(res => res.text()).then(console.log) 32 | # < world 33 | res.text('world') 34 | 35 | @app.event('foo') 36 | def foo(msg: WebSocket.Message): 37 | # runs when a WebSocket message is sent starting with 'foo': 38 | # > let ws = new WebSocket('ws://'+location.host) 39 | # > ws.onmessage = e => console.log(e.data) 40 | # > ws.send('foo test') 41 | # < bar test 42 | msg.reply('bar', msg.content) 43 | -------------------------------------------------------------------------------- /src/packs/led-pulse/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | pico-led-pulse 3 | 4 | pulse an LED 5 | """ 6 | import uasyncio, math 7 | from pico_fi import App 8 | from lib import LED 9 | from lib.logging import log 10 | 11 | """ 12 | SETTINGS 13 | """ 14 | GPIO = None # set to GP## of component (or list) to use instead of on-board LED 15 | """""" 16 | 17 | def configure(app: App): 18 | led = LED(pin=GPIO or ['LED', 17], brightness=0) 19 | app.indicator = None 20 | 21 | @app.started 22 | def started(): 23 | log.info('start LED pulse') 24 | t = 0.0 25 | async def pulse(): 26 | nonlocal t 27 | brightness = math.sin(t / math.tau * 5) / 2 + .5 28 | led.set(brightness) 29 | t = t + .1 30 | # log.info('set LED brightness', t, brightness) 31 | await uasyncio.sleep(.1) 32 | async def inner(): 33 | while 1: 34 | uasyncio.run(pulse()) 35 | uasyncio.create_task(inner()) 36 | -------------------------------------------------------------------------------- /src/packs/led-toggle/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | pico-led-toggle 3 | 4 | toggle an LED 5 | """ 6 | from lib.logging import log 7 | from pico_fi import App 8 | from lib import LED 9 | from lib.handle.http import HTTP 10 | from lib.handle.ws import WebSocket 11 | from machine import Pin 12 | 13 | """ 14 | SETTINGS 15 | """ 16 | GPIO = None 17 | # set to GP## of component (or list) to use instead of on-board LED, for example: 18 | # GPIO = [4, 8, 12, 19, 26] 19 | """""" 20 | 21 | def configure(app: App): 22 | led = LED(pin=GPIO or ['LED', 17], brightness=.1) 23 | app.indicator = None 24 | 25 | @app.started 26 | def started(): led.on() 27 | 28 | @app.route('/led-toggle') 29 | def toggle_led(req, res): 30 | led.toggle() 31 | log.info('led toggled:', led.get()) 32 | 33 | @app.route('/led-brightness') 34 | def set_led_brightness(req, res): 35 | brightness = float(req.query['x']) 36 | led.on(brightness) 37 | log.info('led brightness set:', brightness) 38 | -------------------------------------------------------------------------------- /src/packs/led-toggle/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 14 |
18 | 19 |
20 | 28 | 33 |
34 | -------------------------------------------------------------------------------- /src/packs/lobby/README.md: -------------------------------------------------------------------------------- 1 | ## pico-lobby 2 | 3 | ### Setup 4 | 1. Install [pico-fi](/README.md#install) 5 | 1. Build with **lobby** enabled 6 | ``` 7 | python3 build -a lobby 8 | ``` 9 | 1. Design on top of this pack for lobbied games 10 | -------------------------------------------------------------------------------- /src/packs/lobby/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | pico-lobby 3 | 4 | base for lobbied games 5 | """ 6 | from lib import randloweralphanum 7 | from lib.logging import log 8 | from pico_fi import App 9 | from lib.handle.http import HTTP 10 | from lib.handle.ws import WebSocket 11 | from machine import Pin 12 | import json 13 | 14 | _sockets = {} 15 | _player_to_room = {} 16 | _rooms = {} 17 | def broadcast_state(code, others=[]): 18 | room = _rooms[code] 19 | for p_id in room['players'] + others: 20 | _player_to_room[p_id] = room['code'] 21 | socket = _sockets[p_id] 22 | state = { 'room': room } 23 | socket and socket.reply(f'lobby-state {p_id} {json.dumps(state)}') 24 | def do_leave_room(p_id, code): 25 | global _sockets, _rooms, _player_to_room, broadcast_state 26 | if code in _rooms: 27 | room = _rooms[code] 28 | if p_id in room['players']: 29 | room['players'].remove(p_id) 30 | del _player_to_room[p_id] 31 | broadcast_state(room['code'], [p_id]) 32 | 33 | 34 | def configure(app: App): 35 | 36 | @app.event('lobby-join') 37 | def join(msg: WebSocket.Message): 38 | global _sockets, _socket_to_player, _rooms, _player_to_room, broadcast_state, do_leave_room 39 | while True: 40 | p_id = randloweralphanum(8) 41 | if not p_id in _sockets: 42 | _sockets[p_id] = msg 43 | msg.reply(f'lobby-joined {p_id}') 44 | break 45 | 46 | @app.event('lobby-leave') 47 | def leave(msg: WebSocket.Message): 48 | global _sockets, _rooms, _player_to_room, broadcast_state, do_leave_room 49 | p_id = msg.content 50 | if p_id in _player_to_room: do_leave_room(p_id, _player_to_room[p_id]) 51 | _sockets[p_id] = None 52 | 53 | @app.event('lobby-room-create') 54 | def room_create(msg: WebSocket.Message): 55 | global _sockets, _rooms, _player_to_room, broadcast_state, do_leave_room 56 | p_id = msg.content 57 | print(p_id, 'new room') 58 | if p_id in _player_to_room: do_leave_room(p_id, _player_to_room[p_id]) 59 | while True: 60 | code = randloweralphanum(4) 61 | if not code in _rooms: break 62 | room = { 'code': code, 'players': [p_id], 'capacity': 4 } 63 | _rooms[code] = room 64 | _player_to_room[p_id] = code 65 | broadcast_state(code) 66 | 67 | @app.event('lobby-room-join') 68 | def room_join(msg: WebSocket.Message): 69 | global _sockets, _rooms, _player_to_room, broadcast_state, do_leave_room 70 | p_id, code = msg.content.split(' ') 71 | print(p_id, 'join', code) 72 | # leave previous room 73 | if p_id in _player_to_room: do_leave_room(p_id, _player_to_room[p_id]) 74 | if code in _rooms: 75 | room = _rooms[code] 76 | if len(room['players']) < room['capacity']: 77 | room['players'].append(p_id) 78 | _player_to_room[p_id] = code 79 | broadcast_state(code) 80 | 81 | @app.event('lobby-room-leave') 82 | def room_leave(msg: WebSocket.Message): 83 | global _sockets, _rooms, _player_to_room, broadcast_state, do_leave_room 84 | p_id, code = msg.content.split(' ') 85 | print(p_id, 'leave', code) 86 | do_leave_room(p_id, code) 87 | 88 | @app.route('/lobby-state') 89 | def state(req: HTTP.Request, res: HTTP.Response): 90 | global _sockets, _rooms, _player_to_room, broadcast_state, do_leave_room 91 | 92 | res.json({ 93 | 'rooms': _rooms, 94 | }) 95 | -------------------------------------------------------------------------------- /src/packs/lobby/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | pico-lobby 5 | 6 | 7 | 37 | 38 | 39 | 40 |
41 |
42 | 43 | 44 | 139 | 140 | -------------------------------------------------------------------------------- /src/packs/mastermind/README.md: -------------------------------------------------------------------------------- 1 | ## pico-mastermind 2 | 3 | ### Setup 4 | 1. Install [pico-fi](/README.md#install) 5 | 1. Build with **mastermind** enabled 6 | ``` 7 | python3 build -a mastermind 8 | ``` 9 | -------------------------------------------------------------------------------- /src/packs/mastermind/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | play a 2-player game of mastermind 3 | $ python3 build -a mastermind 4 | 5 | TODO allow multiple games at once 6 | """ 7 | from lib.logging import log 8 | from pico_fi import App 9 | from lib.handle.http import HTTP 10 | from lib.handle.ws import WebSocket 11 | from machine import Pin 12 | 13 | class State: 14 | INIT = 'init' 15 | PLAY = 'play' 16 | WIN = 'win' 17 | LOSE = 'lose' 18 | class Mode: 19 | SINGLE = 'single' 20 | MULTI = 'multi' 21 | class Color: 22 | RED = 'red' 23 | GREEN = 'green' 24 | YELLOW = 'yellow' 25 | BLUE = 'blue' 26 | WHITE = 'white' 27 | BLACK = 'black' 28 | 29 | _state = State.INIT 30 | _mode = Mode.MULTI 31 | _code = [] 32 | _guesses = [] 33 | _feedbacks = [] 34 | _sockets = [None, None] 35 | def broadcast_played(): 36 | for i, socket in enumerate(_sockets): socket and socket.reply(f'mastermind-played {i}') 37 | 38 | def configure(app: App): 39 | 40 | MAX_GUESSES = 10 41 | 42 | @app.event('mastermind-join') 43 | def join(msg: WebSocket.Message): 44 | global _state, _mode, _code, _guesses, _feedbacks, _sockets, broadcast_played 45 | 46 | if not _sockets[0]: 47 | _sockets[0] = msg 48 | broadcast_played() 49 | elif not _sockets[1]: 50 | _sockets[1] = msg 51 | broadcast_played() 52 | else: 53 | msg.reply('mastermind-full') 54 | 55 | @app.event('mastermind-leave') 56 | def leave(msg: WebSocket.Message): 57 | global _state, _mode, _code, _guesses, _feedbacks, _sockets, broadcast_played 58 | 59 | p_id = int(msg.content) 60 | if p_id < 2: 61 | _sockets[p_id] = None 62 | 63 | @app.event('mastermind-play') 64 | def play(msg: WebSocket.Message): 65 | global _state, _mode, _code, _guesses, _feedbacks, _sockets, broadcast_played 66 | 67 | p_id, *code = msg.content.split(' ') 68 | p_id = int(p_id) 69 | print(p_id, 'played', code) 70 | played = False 71 | if _state == State.PLAY and p_id == 1 and _mode == Mode.MULTI: 72 | _guesses.append(code) 73 | 74 | correct = 0 75 | expected_counts = {} 76 | actual_counts = {} 77 | for i in range(4): 78 | if code[i] == _code[i]: 79 | correct += 1 80 | else: 81 | if _code[i] not in expected_counts: expected_counts[_code[i]] = 0 82 | expected_counts[_code[i]] += 1 83 | if code[i] not in actual_counts: actual_counts[code[i]] = 0 84 | actual_counts[code[i]] += 1 85 | common = 0 86 | for color, actual in actual_counts.items(): 87 | expected = expected_counts[color] if color in expected_counts else 0 88 | common += min(actual, expected) 89 | _feedbacks.append([correct, common]) 90 | 91 | if code == _code: 92 | _state = State.WIN 93 | elif len(_guesses) > MAX_GUESSES: 94 | _state = State.LOSE 95 | played = True 96 | 97 | elif _state == State.INIT and p_id == 0 and _mode == Mode.MULTI: 98 | _code = code 99 | _state = State.PLAY 100 | played = True 101 | 102 | if played: broadcast_played() 103 | 104 | @app.event('mastermind-new') 105 | def new(msg: WebSocket.Message): 106 | global _state, _mode, _code, _guesses, _feedbacks, _sockets, broadcast_played 107 | 108 | p_id = int(msg.content) 109 | print(p_id, 'new game') 110 | if _state in [State.LOSE, State.WIN] and p_id == 0: 111 | _state = State.INIT 112 | _code = [] 113 | _guesses = [] 114 | _feedbacks = [] 115 | _sockets = _sockets[::-1] 116 | broadcast_played() 117 | 118 | @app.route('/mastermind-state') 119 | def state(req: HTTP.Request, res: HTTP.Response): 120 | global _state, _mode, _code, _guesses, _feedbacks, _sockets, broadcast_played 121 | 122 | res.json({ 123 | 'state': _state, 124 | 'mode': _mode, 125 | 'code': _code, 126 | 'guesses': _guesses, 127 | 'feedbacks': _feedbacks, 128 | }) 129 | -------------------------------------------------------------------------------- /src/packs/mastermind/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | pico-mastermind 7 | 33 | 34 | 35 | 36 |
37 | mastermind 38 | guess the hidden code 39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | 47 | 249 | 250 | -------------------------------------------------------------------------------- /src/packs/remote-repl/README.md: -------------------------------------------------------------------------------- 1 | ## pico-remote-repl 2 | 3 | Run MicroPython commands on your Pico W, wirelessly 4 | 5 | ![](https://freshman.dev/api/file/public-pico-remote-repl.png) 6 | 7 | ### Setup 8 | 1. Install [pico-fi](/README.md#install) 9 | 1. Build with **remote-repl** enabled 10 | ``` 11 | python3 build -a --packs remote-repl 12 | ``` 13 | 1. Once you've connected the Pico W to a network, access the remote repl through the Pico's web address 14 | 15 | ### Examples 16 | 17 | pico-fi's `app` is exposed as a global variable within the REPL: 18 | ``` 19 | # By default pico-fi uses the on-board LED as an indicator for request activity 20 | # Disable to use for something else 21 | app.indicator = None 22 | 23 | # Add a route 24 | @app.route('/led') 25 | def toggle(req, res): 26 | from machine import Pin 27 | led = Pin('LED', Pin.OUT) 28 | led.value(1 - led.value()) 29 | res.text(f'LED: {led.value()}') 30 | ``` 31 | --- 32 | `lib` includes some useful utilities: 33 | ``` 34 | # Wait for BOOTSEL press 35 | 36 | import time 37 | from lib.bootsel import pressed 38 | 39 | while True: 40 | if pressed(): break 41 | time.sleep(.1) 42 | 43 | print('BOOTSEL pressed') 44 | ``` 45 | --- 46 | ``` 47 | # Get external IP (can be accessed if internal IP is port-forwarded) 48 | 49 | app.indicator = None 50 | @app.route('/led') 51 | def toggle(req, res): 52 | from machine import Pin 53 | led = Pin('LED', Pin.OUT) 54 | led.value(1 - led.value()) 55 | 56 | import urequests 57 | print('internal:', app.sta.ifconfig()[0] + '/led') 58 | print('external:', urequests.request('GET', 'https://ident.me').text + '/led') 59 | ``` 60 | --- 61 | ``` 62 | # Random pokemon 63 | 64 | import urequests 65 | p = urequests.request( 66 | 'GET', 67 | 'https://api.pikaserve.xyz/pokemon/random' 68 | ).json() 69 | print( 70 | p['name']['english']+':', 71 | p['species']) 72 | print(p['description']) 73 | ``` 74 | -------------------------------------------------------------------------------- /src/packs/remote-repl/__init__.py: -------------------------------------------------------------------------------- 1 | import os, re, _thread, time 2 | 3 | from lib.handle.http import HTTP 4 | from lib.handle.ws import WebSocket 5 | from lib import unquote_to_bytes, randalphanum 6 | from lib.logging import str_print, atomic_print, log 7 | from pico_fi import App 8 | 9 | 10 | _interrupt_ids = set() 11 | _repl_locals = {} # maintain repl context across calls 12 | _tokens = set() # require user auth - saved to user.py 13 | _auth = False 14 | try: 15 | with open('user.py') as f: 16 | match = re.match(r'(.+) = "(.+)"', f.read()) 17 | if match: 18 | _auth = {} 19 | _auth[match.group(1)] = match.group(2) 20 | log.info('REPL auth:', _auth) 21 | except: pass 22 | 23 | 24 | def configure(app: App): 25 | 26 | # TODO re-enable interrupts after debugging background thread issues 27 | # (unable to accept additional requests even after first REPL completes) 28 | @app.event('interrupt') 29 | def interrupt(msg: WebSocket.Message): 30 | global _interrupt_ids 31 | log.info('interrupt', msg.s_id) 32 | _interrupt_ids.add(msg.s_id) 33 | 34 | @app.route('/repl') 35 | def repl(req: HTTP.Request, res: HTTP.Response): 36 | global _repl_locals, _tokens, _auth, _interrupt_ids 37 | log.info('repl') 38 | 39 | socket_id = int(req.socket_id or 0) 40 | log.info('REPL with socket', socket_id) 41 | socket_id and app.websocket.emit('repl begin', socket_id=socket_id) 42 | 43 | outputs = [] 44 | def _print(*a, log=True, **k): 45 | line = str_print(*a, **({ 'end': '\n' } | k)) 46 | log and atomic_print('>', line, end='') 47 | socket_id \ 48 | and app.websocket.emit('log', line, socket_id=socket_id) \ 49 | or outputs.append(line) 50 | if socket_id in _interrupt_ids: 51 | _interrupt_ids.remove(socket_id) 52 | app.websocket.emit('interrupted', socket_id=socket_id) 53 | raise KeyboardInterrupt('REPL interrupt') 54 | def _resolve(): 55 | added = set(app.routes.keys()) - routes 56 | if added: _print('added routes:', ' '.join(x.decode() for x in added)) 57 | res.text(''.join(outputs)) 58 | time.sleep(.1) 59 | if socket_id: app.websocket.emit('repl complete', socket_id=socket_id) 60 | 61 | try: 62 | # AUTHORIZATION 63 | # request auth: ?token 64 | # provide auth: ?user=####&passhash=#### 65 | # token = "####" returned 66 | # normal auth: ?token=#### 67 | # remove auth: ?token=####&user=#### 68 | token = req.query.get('token', None) 69 | user = req.query.get('user', None) 70 | passhash = req.query.get('passhash', None) 71 | authed = ( 72 | not _auth 73 | or token in _tokens 74 | or (user in _auth and _auth[user] == passhash)) 75 | if not authed or ( 76 | not _auth and not token is None and not token in _tokens): 77 | res.send([ 78 | HTTP.Response.Status.NOT_FOUND, 79 | b'WWW-Authenticate: Basic realm="User Visible Realm"']) 80 | if user: 81 | if passhash: 82 | _auth[user] = passhash 83 | log.info(f'token = "{randalphanum(16)}"') 84 | else: 85 | del _auth[user] 86 | if not len(_auth): _auth = False 87 | if _auth: 88 | with open('user.py', 'w') as f: f.write(f'{user} = "{passhash}"') 89 | else: os.remove('user.py') 90 | 91 | command = unquote_to_bytes(req.query.get('command', '')).decode() 92 | # app.websocket.emit('command', command) 93 | 94 | run_option = unquote_to_bytes(req.query.get('run_option', '')).decode() 95 | log.info( 96 | ('run option: '+run_option+'\n' if run_option else '') + 97 | command + '\n') 98 | 99 | if len(command.split('\n')) == 1: command = f'print({command})' 100 | if run_option == 'startup': 101 | with open('startup.py', 'w') as f: f.write(command) 102 | _repl_locals['app'] = app 103 | routes = set(app.routes.keys()) 104 | try: exec(command, globals() | { 'print': _print }, _repl_locals) 105 | except KeyboardInterrupt as e: _print(repr(e), log=False) 106 | _resolve() 107 | log.info('REPL completed') 108 | 109 | except Exception as e: 110 | log.exception(e, 'REPL outer') 111 | _print(repr(e), log=False) 112 | _resolve() 113 | 114 | # Run saved script on startup 115 | try: 116 | with open('startup.py') as f: 117 | command = f.read() 118 | if command: 119 | log.info('remote-repl: startup script') 120 | exec(command, globals(), _repl_locals | { 'app': app }) 121 | except: 122 | log.info('remote-repl: no startup script') 123 | -------------------------------------------------------------------------------- /src/packs/snake/README.md: -------------------------------------------------------------------------------- 1 | ## pico-snake 2 | 3 | snake! 4 | 5 | ### Setup 6 | 1. Install [pico-fi](/README.md#install) 7 | 1. Build with **snake** enabled 8 | ``` 9 | python3 build -a snake 10 | ``` 11 | -------------------------------------------------------------------------------- /src/packs/snake/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | pico-snake 8 | 9 | 17 | 18 | 19 | 20 | 21 |
Not intended for mobile devices
22 | 23 | 154 | 155 | -------------------------------------------------------------------------------- /src/packs/z-broken--chess/README.md: -------------------------------------------------------------------------------- 1 | ## chess 2 | 3 | THIS IS BROKEN. 4 | 5 | It works in theory, and you may be able to load a synced game on two devices, but even at the first move the Pico hits a memory allocation error when trying to sync the board state again. TODO - rewrite board sync to be less memory intensive. 6 | 7 | ### Setup 8 | 1. Install [pico-fi](/README.md#install) 9 | 1. Build with **chess** enabled 10 | ``` 11 | python3 build -a chess 12 | ``` 13 | -------------------------------------------------------------------------------- /src/packs/z-broken--chess/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic module configuration showcase 3 | $ python3 build -a hello-world 4 | """ 5 | from lib.logging import log 6 | from pico_fi import App 7 | from lib.handle.http import HTTP 8 | from lib.handle.ws import WebSocket 9 | import json 10 | import gc 11 | 12 | sockets = {} 13 | 14 | def configure(app: App): 15 | 16 | @app.event('chess-join') 17 | def join(msg: WebSocket.Message): 18 | global sockets 19 | id, room_id, *_ = json.loads(msg.content) 20 | if id not in sockets: 21 | for other in sockets.keys(): 22 | msg.reply('chess-join', other) 23 | sockets[id] = msg 24 | for socket in sockets.values(): 25 | socket.reply(f'chess-join:{room_id} {id}') 26 | gc.collect() 27 | 28 | @app.event('chess-echo') 29 | def echo(msg: WebSocket.Message): 30 | global sockets 31 | id, room_id, *message = json.loads(msg.content) 32 | if id in sockets: 33 | for socket in sockets.values(): 34 | socket.reply(f'chess-echo:{room_id} {id} {json.dumps(message)}') 35 | gc.collect() 36 | 37 | @app.event('chess-emit') 38 | def emit(msg: WebSocket.Message): 39 | global sockets 40 | id, room_id, *message = json.loads(msg.content) 41 | if id in sockets: 42 | for other, socket in sockets.items(): 43 | if other != id: 44 | socket.reply(f'chess-emit:{room_id} {id} {json.dumps(message)}') 45 | gc.collect() 46 | 47 | @app.event('chess-leave') 48 | def leave(msg: WebSocket.Message): 49 | global sockets 50 | id, room_id, *_ = json.loads(msg.content) 51 | if id in sockets: 52 | del sockets[id] 53 | for socket in sockets.values(): 54 | socket.reply(f'chess-leave:{room_id} {id}') 55 | gc.collect() 56 | -------------------------------------------------------------------------------- /src/packs/z-broken--chess/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | chess 6 | 10 | 11 | 12 | 13 |
14 |
15 | 16 | 17 | 18 |
19 |
20 | 21 |
22 | 691 | 692 | 733 | 734 | 735 | 736 | -------------------------------------------------------------------------------- /src/packs/z-broken--led-indicator/README.md: -------------------------------------------------------------------------------- 1 | ## pico-led-indicator 2 | 3 | Sync an LED (or other component) to an endpoint. Press BOOTSEL to turn off 4 | 5 | For example, as a physical notification system or daily reminder 6 | 7 | 8 | ### Setup 9 | 10 | ### Setup 11 | 1. Install [pico-fi](/README.md#install) 12 | 1. Build with **led-indicator** enabled 13 | ``` 14 | python3 build -a led-indicator 15 | ``` 16 | 1. (Optional) Connect an LED between GP17 and GND 17 | 1. Go to [switches.freshman.dev](https://switches.freshman.dev) and turn on `default/default` 18 | 1. If the LED doesn't turn on but you can see new messages in the console, trying flipping the LED 19 | 1. If the LED does turn on: 20 | - Press BOOTSEL to turn it off 21 | - Confirm `default/default` updates after a few seconds 22 | - [Edit the endpoint](./__init__.py#L25) 23 | -------------------------------------------------------------------------------- /src/packs/z-broken--led-indicator/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | pico-led-indicator 3 | 4 | BROKEN - for some reason the pico is not able to make internet requests 5 | 6 | sync an LED (or other component) to the state of an API endpoint 7 | press BOOTSEL to turn off 8 | 9 | (you can use this as a physical notification system or daily reminder) 10 | """ 11 | 12 | import time, urequests, uasyncio 13 | from pico_fi import App 14 | from lib import LED 15 | from lib.logging import log 16 | from lib.bootsel import pressed 17 | 18 | 19 | GPIO = OFF_URL = ON_URL = None 20 | 21 | 22 | """ 23 | CONFIGURATION 24 | """ 25 | GPIO = None # set to GP## of component to use instead of on-board LED 26 | 27 | # replace this with your endpoint after testing 28 | SYNC_METHOD = 'GET' 29 | SYNC_URL = 'https://freshman.dev/api/switch/default/default' 30 | def parse_switch_response(response): 31 | """Parse urequests response to determine LED truthiness""" 32 | return response.json()['item']['state'] 33 | 34 | # replace this with an endpoint to request if BOOTSEL pressed while LED on 35 | # OR comment out to disable 36 | OFF_METHOD = 'POST' 37 | OFF_URL = 'https://freshman.dev/api/switch/off/default/default' 38 | 39 | # replace this with an endpoint to request if BOOTSEL pressed while LED on 40 | # OR comment out to disable 41 | ON_METHOD = 'POST' 42 | ON_URL = 'https://freshman.dev/api/switch/on/default/default' 43 | """ 44 | END CONFIGURATION 45 | """ 46 | 47 | def configure(app: App): 48 | led = LED(pin=GPIO or ['LED', 17], brightness=.1) 49 | app.indicator = None 50 | 51 | @app.connected 52 | def connected(): 53 | if ON_URL: 54 | log.info('(ON endpoint) attempting to', ON_METHOD, ON_URL) 55 | try: 56 | response = urequests.request(ON_METHOD, ON_URL) 57 | log.info('(ON endpoint) request succeeded') 58 | response.close() 59 | except Exception as e: 60 | log.info('(ON endpoint) request failed') 61 | log.exception(e) 62 | 63 | state = None 64 | async def listen(): 65 | nonlocal state 66 | log.info('inside led-indicator listen') 67 | # listen for endpoint changes 68 | try: 69 | if state is None: log.info('(SYNC endpoint) attempting to', SYNC_METHOD, SYNC_URL) 70 | response = urequests.request(SYNC_METHOD, SYNC_URL) 71 | new_state = parse_switch_response(response) 72 | if state is None: log.info('(SYNC endpoint) request succeeded, value:', new_state) 73 | if state != new_state: 74 | led.set(new_state) 75 | log.info('new LED state:', led.get()) 76 | if led.get(): log.info('press BOOTSEL to turn off') 77 | state = new_state 78 | response.close() 79 | except Exception as e: 80 | log.info('(SYNC endpoint) request failed') 81 | log.exception(e) 82 | 83 | if led.get(): 84 | # wait for BOOTSEL press 60s 85 | for i in range(60 * 10): 86 | if pressed(): 87 | log.info('BOOTSEL pressed') 88 | led.off() 89 | log.info('new LED state:', led.get()) 90 | if OFF_URL: 91 | log.info('(OFF endpoint) attempting to', OFF_METHOD, OFF_URL) 92 | try: 93 | response = urequests.request(OFF_METHOD, OFF_URL) 94 | log.info('(OFF endpoint) request succeeded') 95 | response.close() 96 | except Exception as e: 97 | log.info('(OFF endpoint) request failed') 98 | log.exception(e) 99 | log.info('waiting for endpoint change') 100 | break 101 | await uasyncio.sleep(.1) 102 | 103 | 104 | """ 105 | Uncomment to use BOOTSEL as ON switch too 106 | This will add up to 60s of delay to changes from the API endpoint 107 | """ 108 | # else: 109 | # for i in range(60 * 10): 110 | # if pressed(): 111 | # log.info('BOOTSEL pressed') 112 | # led.on() 113 | # log.info('new LED state:', led.get()) 114 | # if ON_URL: 115 | # log.info('(ON endpoint) attempting to', OFF_METHOD, OFF_URL) 116 | # try: 117 | # response = urequests.request(OFF_METHOD, OFF_URL) 118 | # log.info('(ON endpoint) request succeeded') 119 | # response.close() 120 | # except Exception as e: 121 | # log.info('(ON endpoint) request failed') 122 | # log.exception(e) 123 | # log.info('waiting for endpoint change') 124 | # break 125 | # time.sleep(.1) 126 | 127 | await uasyncio.sleep(1) 128 | async def inner(): 129 | await uasyncio.sleep(10) 130 | while 1: 131 | uasyncio.run(listen()) 132 | uasyncio.create_task(inner()) 133 | -------------------------------------------------------------------------------- /src/pico_fi.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main orchestration class 3 | """ 4 | import binascii 5 | import gc 6 | import json 7 | import select 8 | import time 9 | 10 | import network 11 | import uasyncio 12 | import machine 13 | import socket 14 | 15 | from lib.handle.dns import DNS 16 | from lib.handle.http import HTTP 17 | from lib.handle.ws import WebSocket 18 | from lib.stream.ws import WS 19 | from lib import encode, randlower, delimit, LED, coroutine 20 | from lib.logging import comment, log 21 | from lib.server import Orchestrator, SocketPollHandler, IpSink 22 | from lib.store import Store 23 | 24 | 25 | class App: 26 | """ 27 | Pico W access point serving single offline website with persistent get/set API 28 | 29 | This serves as a communication node between clients 30 | It provides the common site, persistent state, and websockets 31 | Design the site to offload processing & temporary storage to the client 32 | """ 33 | 34 | IP = '192.168.0.1' 35 | NETWORK_JSON = 'network.json' 36 | 37 | def __init__(self, id=None, password='', indicator=None): 38 | self.running = False 39 | self.id = id 40 | self.sta_ip = None 41 | self.sta = network.WLAN(network.STA_IF) 42 | if self.sta.isconnected(): self.sta.disconnect() 43 | self.sta.active(False) 44 | self.ip_sink = IpSink(App.IP) 45 | self.ap_ip = self.ip_sink.get() 46 | self.ap = network.WLAN(network.AP_IF) 47 | if self.ap.isconnected(): self.ap.disconnect() 48 | self.ap.active(False) 49 | self.ap.config(password=password) 50 | if not password: self.ap.config(security=0) 51 | self.ap.ifconfig( 52 | # IP address, netmask, gateway, DNS 53 | (self.ap_ip, '255.255.255.0', self.ap_ip, self.ap_ip) 54 | ) 55 | self.poller = select.poll() 56 | self.orch = Orchestrator(self.poller) 57 | self.websocket = None 58 | self.servers: SocketPollHandler = [] 59 | self.routes = { 60 | b'/portal': b'/public/portal.html', 61 | b'/': b'/public/index.html', 62 | b'/favicon.ico': b'', 63 | b'/get': self.get, 64 | b'/set': self.set, 65 | b'/api': self.api, 66 | } 67 | self.events = { 68 | b'echo': lambda msg: msg.reply(msg.content), 69 | # can define opcode fallback for non-text or non-match: 70 | WS.Opcode.TEXT: None, 71 | WS.Opcode.BINARY: None, 72 | } 73 | 74 | try: 75 | with open(App.NETWORK_JSON) as f: 76 | self.networks = json.loads(f.read()) 77 | log.info('stored network logins:', self.networks) 78 | except: 79 | self.networks = { 'list': [], 'logins': {} } 80 | log.info('no stored network login') 81 | self.preferred_networks = [] 82 | 83 | if indicator and not isinstance(indicator, LED): indicator = LED(indicator, .05) 84 | self.indicator = indicator or LED.Mock() 85 | 86 | self.effects = { 'start': [], 'connect': [] } 87 | 88 | # load installed packs 89 | try: 90 | packs = getattr(__import__('packs'), 'packs') 91 | log.info('defined packs:', packs) 92 | for pack in packs: 93 | try: 94 | log.info('import', f'packs/{pack}') 95 | pack_import = __import__(f'packs/{pack}') 96 | pack_features = {} 97 | for feature in ['routes', 'events', 'configure']: 98 | if hasattr(pack_import, feature): 99 | pack_features[feature] = getattr( 100 | pack_import, feature) 101 | 102 | if len(pack_features): 103 | log.info('-', *pack_features.keys()) 104 | if 'routes' in pack_features: 105 | self.routes = self.routes | pack_features['routes'] 106 | if 'events' in pack_features: 107 | self.events = self.events | pack_features['events'] 108 | if 'configure' in pack_features: 109 | pack_features['configure'](self) 110 | except Exception as e: 111 | log.error(e) 112 | log.info('configured routes:', *self.routes.keys()) 113 | log.info('configured events:', *self.events.keys()) 114 | except Exception as e: 115 | log.exception(e) 116 | 117 | def route(self, path: str or bytes): 118 | """ 119 | decorator for HTTP requests 120 | 121 | @app.route('/foo') 122 | def bar(req, res): 123 | res.text(req.params['baz']) 124 | """ 125 | if path[0] != '/': path = '/' + path 126 | def decorator(handler): 127 | def wrapper(*args, **kwargs): 128 | handler(*args, **kwargs) 129 | self.routes[encode(path)] = wrapper 130 | return decorator 131 | 132 | def event(self, type: str or bytes or WS.Opcode): 133 | """ 134 | decorator for WebSocket events 135 | 136 | @app.event('foo') 137 | def bar(msg): 138 | msg.reply('baz') 139 | """ 140 | def decorator(handler): 141 | def wrapper(*args, **kwargs): 142 | handler(*args, **kwargs) 143 | self.events[encode(type)] = wrapper 144 | return decorator 145 | 146 | def started(self, func): 147 | """ 148 | decorator for start callbacks 149 | 150 | @app.started 151 | def start(): 152 | print('started') 153 | """ 154 | self.effects['start'].append(func) 155 | log.info('registered start func', len(self.effects['connect'])) 156 | return func 157 | 158 | def connected(self, func): 159 | """ 160 | decorator for connect callbacks 161 | 162 | @app.connected 163 | def connect(): 164 | print('connected') 165 | """ 166 | self.effects['connect'].append(func) 167 | log.info('registered connect func', len(self.effects['connect'])) 168 | return func 169 | 170 | def start(self): 171 | comment('start pico-fi') 172 | Store.load() 173 | self.websocket = WebSocket(self.orch, self.events) 174 | self.servers = [ 175 | DNS(self.orch, self.ap_ip), 176 | HTTP(self.orch, self.ip_sink, self.routes), 177 | # HTTP(self.orch, self.ip_sink, self.routes, ssl=True), 178 | self.websocket, 179 | ] 180 | 181 | # scan for other networks 182 | self.sta.active(True) 183 | networks = sorted(self.sta.scan(), key=lambda x: -x[3]) 184 | log.info('found', len(networks), 'networks') 185 | if networks: 186 | id_len = max(len(x[0]) for x in networks) 187 | for x in networks: 188 | ssid = x[0].decode() 189 | if ssid: 190 | self.preferred_networks.append(ssid) 191 | ssid_padded = ssid + ' '*(id_len - len(x[0])) 192 | bssid = delimit(binascii.hexlify(x[1]).decode(), 2, ':') 193 | log.info( 194 | f'{-x[3]} {ssid_padded} ({bssid}) chnl={x[2]} sec={x[4]} hid={x[5]}') 195 | 196 | # start access point 197 | STORE_ID_KEY = 'id' 198 | # if ID undefined, read previous or generate new 199 | if not self.id or isinstance(self.id, int): 200 | self.id = Store.get(STORE_ID_KEY, 'w-pico-'+randlower(self.id or 7)) 201 | # increment ID while already in use 202 | network_ids = [x[0].decode() for x in networks] 203 | original_id = self.id 204 | i = 0 205 | while self.id in network_ids: 206 | i += 1 207 | self.id = original_id + '-' + str(i) 208 | self.ap.config(essid=self.id) 209 | open('board.py', 'w').write(f'name = "{self.id}"') 210 | Store.write({ STORE_ID_KEY: self.id }) 211 | self.ap.active(True) 212 | self.indicator and self.indicator.on() 213 | log.info('access point:', self.ap.config('essid'), self.ap.ifconfig()) 214 | 215 | while self.effects['start']: self.effects['start'].pop(0)() 216 | 217 | # reconnect to last network if one exists 218 | uasyncio.run(coroutine(self.connect)()) 219 | 220 | def connect( 221 | self, ssid=None, key=None, wait=True, is_retry=False): 222 | """ 223 | Attempt network connection. 224 | If using stored credentials and connection fails, retry once per minute 225 | """ 226 | try: 227 | if ssid and key: 228 | self.networks['logins'][ssid] = key 229 | try: 230 | self.networks['list'].remove(ssid) 231 | except: 232 | pass 233 | self.networks['list'].insert(0, ssid) 234 | log.info('store network login:', self.networks) 235 | with open(App.NETWORK_JSON, 'w') as f: f.write(json.dumps(self.networks)) 236 | 237 | self.preferred_networks = [] 238 | networks = sorted(self.sta.scan(), key=lambda x: -x[3]) 239 | if networks: 240 | for x in networks: 241 | ssid_item = x[0].decode() 242 | if ssid_item: 243 | self.preferred_networks.append(ssid_item) 244 | 245 | if len(self.networks['list']): 246 | network_list = self.networks['list'][:] 247 | n_i = 0 248 | while n_i < len(network_list) and network_list[n_i] not in self.preferred_networks: n_i += 1 249 | if n_i < len(network_list): 250 | ssid = self.networks['list'][n_i] 251 | key = self.networks['logins'][ssid] 252 | 253 | status = None 254 | if not ssid: 255 | log.info('no matching stored network login') 256 | else: 257 | log.info(f'attempting to connect to "{ssid}"') 258 | 259 | self.sta.active(True) 260 | 261 | ifconfig = self.sta.ifconfig() 262 | log.info(f'preconnect IP address {ifconfig[0]} under {ifconfig[2]}') 263 | self.sta.connect(ssid, key) 264 | if not wait: return 265 | 266 | # wait up to 10s for connection to succeed (or 30s for retry) 267 | wait = 30 if is_retry else 10 268 | while wait > 0: 269 | wait -= 1 270 | new_status = self.sta.status() 271 | if status != new_status: 272 | status = new_status 273 | log.info(f'network connect attempt status {status}...') 274 | if 0 <= status < 3: time.sleep(1) 275 | else: break 276 | 277 | # force x.x.0.x IP address 278 | if status == 3: 279 | ifconfig = self.sta.ifconfig() 280 | ip = ifconfig[0] 281 | gateway = ifconfig[2] 282 | log.info(f'network connected with IP {ip} under {gateway}') 283 | ip_parts = ip.split('.') 284 | if (ip_parts[2] != '0'): 285 | ip_parts[2] = '0' 286 | ip = '.'.join(ip_parts) 287 | gateway_parts = gateway.split('.') 288 | gateway_parts[2] = '0' 289 | gateway = '.'.join(gateway_parts) 290 | log.info(f'forcing IP address {ip} under {gateway}') 291 | self.sta.disconnect() 292 | self.sta.ifconfig((ip, ifconfig[1], gateway, ifconfig[3])) 293 | self.sta.connect(ssid, key) 294 | wait = 30 if is_retry else 10 295 | while wait > 0: 296 | wait -= 1 297 | new_status = self.sta.status() 298 | if status != new_status: 299 | status = new_status 300 | log.info(f'network connect attempt status {status}...') 301 | if 0 <= status < 3: time.sleep(1) 302 | else: break 303 | 304 | if status == 3: 305 | self.sta_ip = self.sta.ifconfig()[0] 306 | self.ip_sink.set(False) 307 | log.info(f'OPEN http://{self.sta_ip} TO ACCESS PICO W') 308 | log.info(f'OR SCAN QR: https://freshman.dev/raw/qr/display?=http://{self.sta_ip}') 309 | 310 | # log.info('verifying connection with google hostname lookup') 311 | # log.info('connected:', self.sta.isconnected()) 312 | # log.info('ifconfig:', self.sta.ifconfig()) 313 | # log.info('local adddrinfo:', self.sta_ip, '->', socket.getaddrinfo(self.sta_ip, 80)) 314 | # log.info('google addrinfo:', socket.getaddrinfo('google.com', 80, 0, socket.SOCK_STREAM)) 315 | 316 | async def async_connect_effects(): 317 | while self.effects['connect']: self.effects['connect'].pop(0)() 318 | uasyncio.create_task(async_connect_effects()) 319 | else: 320 | log.info('network connect failed') 321 | self.sta.active(False) 322 | if not is_retry: 323 | if key: 324 | log.info(f'will retry connection to {ssid} every 5s') 325 | while self.sta.status() != 3: 326 | self.connect(ssid, key, True, True) 327 | time.sleep(5) 328 | else: 329 | log.info(f'will retry connection to wifi every 5s') 330 | while self.sta.status() != 3: 331 | self.connect(None, None, True, True) 332 | time.sleep(5) 333 | else: 334 | log.info(f'retrying in 5s') 335 | except Exception as e: 336 | log.exception(e) 337 | 338 | def stop(self): 339 | comment('stop pico-fi') 340 | self.indicator and self.indicator.off() 341 | self.ap.active(False) 342 | self.sta.active(False) 343 | for server in self.servers: server.stop() 344 | Store.save() 345 | log.flush() 346 | gc.collect() 347 | machine.reset() 348 | self.running = False 349 | 350 | 351 | def switch(self, req: HTTP.Request, res: HTTP.Response): 352 | if self.ip_sink.get(): res.redirect(b'http://{:s}/portal'.format(self.ip_sink.get())) 353 | else: res.redirect(b'http://{:s}/'.format(App.IP)) 354 | 355 | """ 356 | get and set query with single param data= 357 | """ 358 | def _parse_data_from_query(self, query): 359 | return { 'data': json.loads(query.get('data', '{}')) } 360 | 361 | def get(self, req: HTTP.Request, res: HTTP.Response): 362 | data = self._parse_data_from_query(req.query) 363 | log.info('get', data) 364 | Store.read(data) 365 | res.json(data) 366 | 367 | def set(self, req: HTTP.Request, res: HTTP.Response): 368 | data = self._parse_data_from_query(req.query) 369 | log.info('set', data) 370 | Store.write(data) 371 | res.json(data) 372 | log.debug('updated store', Store.store) 373 | 374 | def api(self, req: HTTP.Request, res: HTTP.Response): 375 | parts = req.path.split(b'/')[2:] 376 | prefix = parts[0] 377 | path = b'/'.join(parts) 378 | data = self._parse_data_from_query(req.query)['data'] 379 | log.info('api', path, data) 380 | handler = { 381 | b'networks': self.api_networks, 382 | b'network-connect': self.api_network_connect, 383 | b'network-status': self.api_network_status, 384 | b'network-switch': self.api_network_switch, 385 | b'network-disconnect': self.api_network_disconnect, 386 | }.get(prefix, None) 387 | if handler: handler(req, data, res) 388 | else: res.send(HTTP.Response.Status.NOT_FOUND) 389 | 390 | def api_networks(self, req: HTTP.Request, data, res: HTTP.Response): 391 | networks = [{ 392 | 'ssid': x[0], 393 | 'pretty_bssid': delimit(binascii.hexlify(x[1]).decode(), 2, ':'), 394 | 'bssid': binascii.hexlify(x[1]).decode(), 395 | 'channel': x[2], 396 | 'RSSI': x[3], 397 | 'security': x[4], 398 | 'hidden': x[5], 399 | } for x in sorted(self.sta.scan(), key=lambda x: -x[3])] 400 | res.json(networks) 401 | def api_network_connect(self, req: HTTP.Request, data, res: HTTP.Response): 402 | log.info('network connect', data['ssid'], data['key'], binascii.unhexlify(data['bssid'])) 403 | uasyncio.run(coroutine(self.connect)(data['ssid'], data['key'], False)) 404 | res.json({ 'status': self.sta.status() }) 405 | def api_network_status(self, req: HTTP.Request, data, res: HTTP.Response): 406 | status = self.sta.status() 407 | log.info(f'network connect status', status) 408 | 409 | # return IP if connected to wifi 410 | if status == 3: 411 | self.sta_ip = self.sta.ifconfig()[0] 412 | log.info(f'network connected with ip', self.sta_ip) 413 | res.json({ 'ip': str(self.sta_ip), 'ssid': self.sta.config('ssid') }) 414 | elif status >= 0: res.json({ 'status': status }) 415 | else: res.json({ 'error': 'connection error', 'status': status }) 416 | def api_network_switch(self, req: HTTP.Request, data, res: HTTP.Response): 417 | log.info('network switch') 418 | if (self.sta_ip): 419 | res.ok() 420 | async def _off(): 421 | time.sleep(1.5) 422 | self.ip_sink.set(False) 423 | self.ap.active(False) 424 | for callback in self.connectCallbacks: callback() 425 | self.connectCallbacks = [] 426 | uasyncio.create_task(_off()) 427 | else: res.error('not connected to the internet') 428 | def api_network_disconnect(self, req: HTTP.Request, data, res: HTTP.Response): 429 | log.info('network disconnect') 430 | self.sta.disconnect() 431 | Store.write({ 'network': None }) 432 | res.ok() 433 | 434 | 435 | def run(self): 436 | if self.running: return 437 | self.running = True 438 | self.start() 439 | 440 | last_save = time.time() 441 | async def inner(): 442 | nonlocal last_save 443 | try: 444 | # gc between socket events or once per minute 445 | gc.collect() 446 | for response in self.poller.ipoll(1): 447 | self.indicator and self.indicator.pulse() 448 | self.orch.handle(*response) 449 | # uasyncio.create_task(async_handle(response)) 450 | 451 | # write store to file at most once per minute 452 | now = time.time() 453 | if now - last_save > 60: 454 | Store.save() 455 | last_save = now 456 | 457 | uasyncio.sleep_ms(1) # yield to other tasks 458 | except Exception as e: 459 | log.exception(e) 460 | self.stop() 461 | while self.running: 462 | uasyncio.run(inner()) 463 | 464 | 465 | def run(id=None, password='', indicator=None): 466 | app = App(id, password, indicator) 467 | app.run() 468 | return app 469 | -------------------------------------------------------------------------------- /src/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cfreshman/pico-fi/bc9c3105cc5b5cf7b8f706c4c5a223fc6c1a80d6/src/public/icon.png -------------------------------------------------------------------------------- /src/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | pico-fi 5 | 6 | 7 | 8 | 16 | 28 | 29 | 30 | 31 | 32 | 33 |

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 |

50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/public/lib.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const Q = (L, q=undefined) => (q ? L : document.body).querySelector(q || L) 3 | const QQ = (L, q=undefined) => Array.from((q ? L : document.body).querySelectorAll(q || L)) 4 | 5 | const persist = (method, data={}) => 6 | fetch(`/${method}?data=${encodeURIComponent(JSON.stringify(data))}`) 7 | .then(res => res.json()) 8 | .then(({ data }) => { 9 | console.debug(method, 'result:', data) 10 | return data 11 | }) 12 | const get = data => persist('get', data) 13 | const set = data => persist('set', data) 14 | 15 | window.lib = { 16 | Q, QQ, 17 | persist, get, set, 18 | } 19 | })() -------------------------------------------------------------------------------- /src/public/portal.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | pico-fi 6 | 7 | 8 | 221 | 235 | 236 | 237 | 238 | 239 | 240 | 241 |
242 | 243 | 244 | 506 | 507 | 508 | 509 | --------------------------------------------------------------------------------