The single-file app grew to ~57KB; CircuitPython compiling it at boot fragments the RP2040 heap so badly that the fonts can't get a contiguous block (161KB free, yet a ~16KB alloc fails). Fix: precompile to app.mpy (Adafruit mpy-cross for CP 10.2.1, emits CircuitPython mpy v6) so the device loads bytecode without compiling -> no fragmentation. - build.sh precompiles pico-cp/app.py -> dist/app.mpy via tools/mpy-cross (gitignored binary); the bundle ships app.mpy (NOT app.py); serves pico-cp-app.mpy + pico-cp-app.py (the .py only for the editor's version regex + as readable reference). - Loader (code.py) imports app.mpy and rolls back app.bak as .mpy. - One-click updater now pushes the .mpy: editor base64-encodes it and sends it over the existing flow-controlled chunked transport (512-char = mult-of-4 chunks); the device base64-decodes each chunk to /app.new and verifies the CircuitPython .mpy header (magic 'C', v6, >=4KB) before the A/B install. Version still read from the served .py. Verified: mpy-cross emits magic 'C'/v6; build produces a 21.8KB app.mpy; editing-logic harness + scene render still pass; and a simulated push (base64 -> 57 chunks -> a2b_base64) reassembles the .mpy byte-exact and passes the device's header check. One-time recovery: delete app.py from the drive, copy app.mpy + code.py from the new zip. After that, updates are one-click again (and can't brick: header check + A/B rollback). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
24 lines
1.3 KiB
Python
24 lines
1.3 KiB
Python
# code.py - PM_K-1 A/B firmware loader (stable; rarely changes).
|
|
#
|
|
# The real application is the PRECOMPILED app.mpy (CircuitPython compiles a big .py at boot, which
|
|
# fragments the heap and OOMs; a .mpy loads without compiling). app.bak holds the previous known-good
|
|
# build. The web editor pushes a new app.mpy to a "trial" slot over USB-MIDI; this loader runs it, and
|
|
# if it fails to boot it AUTOMATICALLY ROLLS BACK to app.bak. (Unbrickable: BOOTSEL -> drag a .uf2.)
|
|
# app.mpy clears the /trial marker once it has run healthily for ~5s.
|
|
import supervisor, os
|
|
supervisor.runtime.autoreload = False # updates reboot explicitly; never auto-restart on our own writes
|
|
|
|
def _trial():
|
|
try: os.stat("/trial"); return True
|
|
except OSError: return False
|
|
|
|
try:
|
|
import app # runs the application (app.mpy; ends with App().run())
|
|
except Exception:
|
|
if _trial(): # a freshly-pushed build crashed on startup -> roll back
|
|
try:
|
|
os.remove("/app.mpy"); os.rename("/app.bak", "/app.mpy"); os.remove("/trial")
|
|
except Exception: pass
|
|
supervisor.reload() # reboot into the restored known-good build
|
|
else:
|
|
raise # the active build failed unexpectedly (rare) -> on-screen traceback
|