From 7481f919359f483ba2819a59114c928b0e8d6404 Mon Sep 17 00:00:00 2001 From: Me Here Date: Fri, 29 May 2026 14:01:57 -0500 Subject: [PATCH] PM_K-1 0.0.10: ship precompiled app.mpy (fixes boot OOM) + push .mpy over the air 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) --- .gitignore | 1 + build.sh | 20 +++++-- deploy.sh | 3 +- editor.html | 69 ++++++++++++------------ pico-cp/README.md | 29 +++++----- pico-cp/__pycache__/app.cpython-312.pyc | Bin 79562 -> 79459 bytes pico-cp/app.py | 30 ++++++----- pico-cp/code.py | 13 ++--- 8 files changed, 94 insertions(+), 71 deletions(-) diff --git a/.gitignore b/.gitignore index 7385083..af067a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ # Build output — assembled from index.html + assets/ by build.sh dist/ +tools/ diff --git a/build.sh b/build.sh index b4886ea..bd01dc9 100755 --- a/build.sh +++ b/build.sh @@ -11,6 +11,15 @@ set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")" mkdir -p dist + +# Precompile the PM_K-1 CircuitPython firmware to .mpy. CircuitPython compiles a ~56KB .py at boot, +# which fragments the heap and OOMs on the RP2040; a precompiled .mpy loads without compiling. Needs +# Adafruit's mpy-cross matching the device's CircuitPython (10.2.1) -> emits CircuitPython mpy v6. +MPYC="tools/mpy-cross" +[[ -x "$MPYC" ]] || { echo "error: $MPYC missing (Adafruit mpy-cross for CircuitPython 10.2.1)" >&2; exit 1; } +"$MPYC" pico-cp/app.py -o dist/app.mpy +echo "precompiled dist/app.mpy ($(stat -c%s dist/app.mpy) bytes <- $(stat -c%s pico-cp/app.py) source)" + python3 - <<'PY' import os, pathlib, re A = pathlib.Path("assets") @@ -41,14 +50,17 @@ _appsrc = pathlib.Path("pico-cp/app.py").read_text() # The A/B updater pushes app.py over USB-MIDI as 7-bit data, so it MUST be pure ASCII -- a stray # non-ASCII char (e.g. an em-dash in a comment) gets mangled to a NUL byte and bricks the device. _bad = [(i, c) for i, c in enumerate(_appsrc) if ord(c) > 0x7F] -assert not _bad, "pico-cp/app.py has non-ASCII at %r -- the MIDI updater needs pure ASCII" % (_bad[:5],) -pathlib.Path("dist/pico-cp-app.py").write_text(_appsrc) # served for the editor's A/B firmware updater -print("copied pico-cp-app.py (ascii-checked)") +assert not _bad, "pico-cp/app.py has non-ASCII at %r -- keep it ASCII (version regex + clean source)" % (_bad[:5],) +pathlib.Path("dist/pico-cp-app.py").write_text(_appsrc) # served for version reading + as readable reference +# the editor pushes the PRECOMPILED .mpy (base64); serve it next to the source +pathlib.Path("dist/pico-cp-app.mpy").write_bytes(pathlib.Path("dist/app.mpy").read_bytes()) +print("copied pico-cp-app.py + pico-cp-app.mpy") import zipfile # PM_K-1 CircuitPython drive bundle (download → unzip onto CIRCUITPY) with zipfile.ZipFile("dist/pm_k1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z: - for f in ("code.py", "app.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin", + for f in ("code.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin", "logo.bin", "midi.bin", "usb.bin", "README.md", "protect-firmware.sh"): z.write("pico-cp/" + f, f) + z.write("dist/app.mpy", "app.mpy") # the precompiled firmware (NOT app.py - too big to compile on-device) z.write("dist/editor.html", "editor.html") # offline copy of the editor, on the drive print("zipped pm_k1_circuitpy.zip") PY diff --git a/deploy.sh b/deploy.sh index bc05d79..eeade18 100755 --- a/deploy.sh +++ b/deploy.sh @@ -49,7 +49,8 @@ done cp "$DIST_DIR/embed.js" "$DEST_DIR/embed.js"; echo " embed.js ($(stat -c '%s' "$DEST_DIR/embed.js") bytes)" cp "$DIST_DIR/pico-main.py" "$DEST_DIR/pico-main.py"; echo " pico-main.py ($(stat -c '%s' "$DEST_DIR/pico-main.py") bytes)" # PM_K-1 firmware download cp "$DIST_DIR/pm_k1_circuitpy.zip" "$DEST_DIR/pm_k1_circuitpy.zip"; echo " pm_k1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_k1_circuitpy.zip") bytes)" # PM_K-1 CircuitPython bundle -cp "$DIST_DIR/pico-cp-app.py" "$DEST_DIR/pico-cp-app.py"; echo " pico-cp-app.py ($(stat -c '%s' "$DEST_DIR/pico-cp-app.py") bytes)" # PM_K-1 firmware for the editor's A/B updater +cp "$DIST_DIR/pico-cp-app.py" "$DEST_DIR/pico-cp-app.py"; echo " pico-cp-app.py ($(stat -c '%s' "$DEST_DIR/pico-cp-app.py") bytes)" # served for version reading + reference +cp "$DIST_DIR/pico-cp-app.mpy" "$DEST_DIR/pico-cp-app.mpy"; echo " pico-cp-app.mpy ($(stat -c '%s' "$DEST_DIR/pico-cp-app.mpy") bytes)" # precompiled firmware the editor pushes (base64) rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html rm -f "$DEST_DIR/concepts.html" # Concepts is now the landing (/) # info-*.html are first-class pages again: each form factor has a lean widget page diff --git a/editor.html b/editor.html index 95f32a6..bd21466 100644 --- a/editor.html +++ b/editor.html @@ -1200,63 +1200,66 @@ async function toggleDeviceAudio() { function _queryDeviceVersion() { // ask the device its firmware version (SysEx 0x02 -> reply 0x03) return new Promise((res) => { _verCb = res; _send([0xF0, 0x7D, 0x02, 0xF7]); setTimeout(() => { if (_verCb) { _verCb = null; res(null); } }, 1500); }); } -async function updateFirmware() { // A/B firmware update over USB-MIDI, with a version check +async function updateFirmware() { // A/B firmware update over USB-MIDI: push the precompiled .mpy if (!(await _ensureMidi()) || !_midiOutputs().length) return alert("Connect the PM_K-1 (Chrome/Edge/Firefox), then try again."); const dev = await _queryDeviceVersion(); - const src = await _firmwareSource(); - if (src == null) return; // offline + user cancelled the picker - const m = src.match(/APP_VERSION\s*=\s*["']([^"']+)["']/); const latest = m && m[1]; - if (!latest) return alert("That file isn't PM_K-1 firmware (no APP_VERSION line)."); + let latest = null, b64 = null; + // version comes from the (text) source; the payload is the precompiled .mpy bytecode — CircuitPython + // compiles a big .py at boot, which OOMs the RP2040, so we ship + push compiled bytecode instead. + for (const base of ["", "https://metronome.varasys.io"]) { + try { const t = await (await fetch(base + "/pico-cp-app.py", { cache: "no-store" })).text(); + const m = t.match(/APP_VERSION\s*=\s*["']([^"']+)["']/); if (m) latest = m[1]; } catch (_) {} + try { const r = await fetch(base + "/pico-cp-app.mpy", { cache: "no-store" }); + if (r.ok) { b64 = _b64(new Uint8Array(await r.arrayBuffer())); break; } } catch (_) {} + } + if (!b64) { // offline: let the user pick app.mpy + alert("Can't reach the site.\n\nPick the firmware file (app.mpy) to flash — download it from\n" + + "metronome.varasys.io/pico-cp-app.mpy, or use the online editor at metronome.varasys.io/editor.html."); + const u8 = await _pickBinary(); if (!u8) return; + b64 = _b64(u8); if (!latest) latest = "(picked .mpy)"; + } + if (!latest) latest = "?"; const upToDate = dev && dev === latest; if (!confirm("Device firmware: " + (dev || "unknown") + "\nNew build: " + latest + (upToDate ? "\n\nSame version. Re-install anyway?" : "\n\nUpdate now? The device reboots, runs the new build, and auto-rolls-back if it fails to start."))) return; - const err = await _pushFirmware(src); + const err = await _pushFirmware(b64); if (err) return alert("Update didn't complete (" + err + ").\n\nThe device kept its working firmware — nothing was installed. " + - "Make sure it's plugged in and NOT in editor mode (don't hold A), on firmware 0.0.6+, then retry. " + - "(If the device is older than 0.0.6, drag app.py onto the drive once in editor mode to get the chunked updater.)"); + "Make sure it's plugged in and NOT in editor mode (don't hold A), then retry."); alert("Update sent ✓ — the device verified v" + latest + " and is rebooting into it. It auto-confirms after a few seconds, or rolls back if it won't start."); } // One ACK/NAK await (the device replies 0x7F=ok / 0x7E=rejected after each SysEx); resolves true/false/null(timeout). function _ack(timeout) { return new Promise((res) => { _saveCb = res; setTimeout(() => { if (_saveCb) { _saveCb = null; res(null); } }, timeout); }); } -// Push app.py in small, flow-controlled chunks: a giant single SysEx overruns the device's MIDI input -// buffer and arrives corrupt. begin(0x21,len) -> data(0x22)* -> commit(0x23); wait for each ACK. -async function _pushFirmware(src) { - const data = []; for (let i = 0; i < src.length; i++) data.push(src.charCodeAt(i) & 0x7F); // 7-bit ASCII - const n = data.length; - _send([0xF0, 0x7D, 0x21, n & 0x7F, (n >> 7) & 0x7F, (n >> 14) & 0x7F, (n >> 21) & 0x7F, 0xF7]); +function _b64(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); } +// Push the base64-encoded .mpy in flow-controlled chunks (512 base64 chars = a multiple of 4, so each +// decodes cleanly on the device): begin(0x21) -> data(0x22)* -> commit(0x23), waiting for each ACK. The +// device base64-decodes each chunk to /app.new, verifies the .mpy header, then A/B-installs + reboots. +async function _pushFirmware(b64) { + _send([0xF0, 0x7D, 0x21, 0xF7]); if (await _ack(3000) !== true) return "handshake"; const CH = 512; - for (let o = 0; o < n; o += CH) { - _send([0xF0, 0x7D, 0x22].concat(data.slice(o, o + CH)).concat([0xF7])); + for (let o = 0; o < b64.length; o += CH) { + const part = b64.slice(o, o + CH); const msg = [0xF0, 0x7D, 0x22]; + for (let i = 0; i < part.length; i++) msg.push(part.charCodeAt(i)); // base64 is ASCII + msg.push(0xF7); _send(msg); const a = await _ack(4000); - if (a !== true) return "transfer at " + o + "/" + n + (a === false ? " (rejected)" : " (timeout)"); + if (a !== true) return "transfer at " + o + "/" + b64.length + (a === false ? " (rejected)" : " (timeout)"); } - _send([0xF0, 0x7D, 0x23, 0xF7]); // commit: device verifies the whole file, then reboots + _send([0xF0, 0x7D, 0x23, 0xF7]); return (await _ack(6000)) === true ? null : "verify"; } -// Where the new app.py comes from: the site when online (the https editor, same-origin), else let the user -// pick the file — so the OFFLINE editor that ships ON the device can update too (file:// pages can't fetch). -async function _firmwareSource() { - for (const url of ["/pico-cp-app.py", "https://metronome.varasys.io/pico-cp-app.py"]) { - try { const r = await fetch(url, { cache: "no-store" }); if (r.ok) return await r.text(); } catch (_) {} - } - alert("Can't reach the site from this offline copy.\n\nPick the firmware file (app.py) to flash — download it from\n" + - "metronome.varasys.io/pico-cp-app.py, or just open the online editor at\nmetronome.varasys.io/editor.html (it updates automatically)."); - return await _pickFile(".py,text/x-python,text/plain", "PM_K-1 firmware (app.py)", { "text/x-python": [".py"] }); -} -function _pickFile(accept, desc, fsTypes) { // resolve to the chosen file's text, or null if cancelled +function _pickBinary() { // offline fallback: choose an app.mpy -> Uint8Array (or null if cancelled) return new Promise(async (res) => { if (window.showOpenFilePicker) { - try { const [h] = await showOpenFilePicker({ types: [{ description: desc, accept: fsTypes }] }); - return res(await (await h.getFile()).text()); } + try { const [h] = await showOpenFilePicker({ types: [{ description: "PM_K-1 firmware (app.mpy)", accept: { "application/octet-stream": [".mpy"] } }] }); + return res(new Uint8Array(await (await h.getFile()).arrayBuffer())); } catch (e) { if (e.name === "AbortError") return res(null); } } - const inp = document.createElement("input"); inp.type = "file"; inp.accept = accept; - inp.onchange = () => { inp.files[0] ? inp.files[0].text().then(res) : res(null); }; + const inp = document.createElement("input"); inp.type = "file"; inp.accept = ".mpy,application/octet-stream"; + inp.onchange = async () => { inp.files[0] ? res(new Uint8Array(await inp.files[0].arrayBuffer())) : res(null); }; inp.oncancel = () => res(null); inp.click(); }); diff --git a/pico-cp/README.md b/pico-cp/README.md index 1c473d4..a532f3c 100644 --- a/pico-cp/README.md +++ b/pico-cp/README.md @@ -23,12 +23,14 @@ from the web editor over USB‑MIDI**, and plays through your **computer's speak 1. **Flash CircuitPython:** hold **BOOTSEL**, plug in, drop the CircuitPython `.uf2` onto `RPI‑RP2` ( — Pico 2 / W builds also fine). A `CIRCUITPY` drive appears. -2. **Copy the whole bundle** onto `CIRCUITPY`: `boot.py`, `code.py` (loader) + `app.py` (the application), - `programs.json`, `font_s.bin` / `font_m.bin` / `font_l.bin`, `logo.bin` / `midi.bin` / `usb.bin` (the - on-screen logo + MIDI/USB status icons), `editor.html` (offline editor), and the helper scripts. - (`code.py` is a tiny stable loader; `app.py` is what firmware updates replace. The `.bin` assets — like - the fonts — ride in the bundle, since the one-click updater only pushes `app.py`; if a `.bin` is missing - the firmware just falls back to text and never fails to boot.) +2. **Copy the whole bundle** onto `CIRCUITPY`: `boot.py`, `code.py` (loader) + **`app.mpy`** (the + application, **precompiled**), `programs.json`, `font_s.bin` / `font_m.bin` / `font_l.bin`, + `logo.bin` / `midi.bin` / `usb.bin` (logo + MIDI/USB status icons), `editor.html` (offline editor), + and the helper scripts. **If an old `app.py` is on the drive, delete it** — the firmware ships as + precompiled `app.mpy`. (Why: CircuitPython compiling the ~57 KB source at boot fragments the heap and + runs the RP2040 out of memory; a `.mpy` loads without compiling. `code.py` is a tiny stable loader; the + one-click updater pushes a new `app.mpy`. The `.bin` assets ride in the bundle — if one is missing the + firmware just falls back to text and never fails to boot.) 3. **Power‑cycle** (so `boot.py` takes effect). It boots into appliance mode and runs. ## Program it from the web (push over USB‑MIDI) @@ -42,13 +44,14 @@ device answers — boot the Pico in **editor mode** (hold A) and drag the file o ## Firmware updates (one‑click, A/B with auto‑rollback) -`code.py` is a small stable **loader**; the application is `app.py` (it carries `APP_VERSION`). To update: -the editor's ⋯ menu → **⬆ Update firmware…** queries the device's version, fetches the latest from the site, -shows *device vs latest*, and on confirm **pushes the new `app.py` over USB‑MIDI**. The device installs it to -a **trial slot** (keeping the old build as `app.bak`) and reboots; if the new build **doesn't boot, the loader -automatically rolls back** to `app.bak`. A build that runs cleanly for ~5 s is confirmed. No BOOTSEL, no -dragging. (Updating CircuitPython *itself* still uses BOOTSEL + a `.uf2`, but that's rare. And the Pico is -unbrickable as the ultimate backstop.) +`code.py` is a small stable **loader**; the application is the precompiled **`app.mpy`** (it carries +`APP_VERSION`). To update: the editor's ⋯ menu → **⬆ Update firmware…** queries the device's version, fetches +the latest `app.mpy` from the site, shows *device vs latest*, and on confirm **pushes it over USB‑MIDI** +(base64, in flow‑controlled chunks; the device decodes each chunk to disk and verifies the `.mpy` header +before installing). It goes to a **trial slot** (old build kept as `app.bak`) and reboots; if the new build +**doesn't boot, the loader automatically rolls back** to `app.bak`. A build that runs cleanly for ~5 s is +confirmed. No BOOTSEL, no dragging. (Updating CircuitPython *itself* still uses BOOTSEL + a `.uf2`, but that's +rare. And the Pico is unbrickable as the ultimate backstop.) ## Play through the computer's speakers diff --git a/pico-cp/__pycache__/app.cpython-312.pyc b/pico-cp/__pycache__/app.cpython-312.pyc index 35efd266c807a2fb4dd93c1a6f17f18e67e6d3c7..511ca0588d521ed1e0d7a143b7c993185456807d 100644 GIT binary patch delta 13626 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 delta 13623 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
    ^1=ZGvgMIeU`$cz=_X(i{Z-2sfmHMf2K3T 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

    (tWPq5Tk#K3(Zu(g7 zM%au_x7_8l&*Bq7k)Wx@8|%L`o{eKke(eM{>csd7tV)~K63()&X$US#&@kN{`g_8+ zgx?kU<2P;oJ(Jn;>Kk74DJS*1%4wRD5mAc_nv==8MVXqD>AFQ@HLp)IF4AhxNDQGN eeo>nCOe#Z|X 0) else None self._mbuf = bytearray(64); self.midi_host = False; self.last_midi_in = 0.0 self._sx = bytearray(); self._sxon = False # USB-MIDI SysEx assembler (clock + pushed programs) - self._fw = None; self._fw_len = 0 # chunked firmware transfer: staging file + expected size + self._fw = None # chunked firmware transfer: staging file handle self.led = RGB(P_RGB) self.buz = pwmio.PWMOut(P_BUZ, frequency=1600, variable_frequency=True, duty_cycle=0) self.buz_off = 0 @@ -941,37 +945,35 @@ class App: self._ack(False) # read-only (editor mode) etc. # A/B firmware update, sent as small flow-controlled chunks (a single huge SysEx overruns the # USB-MIDI input buffer and arrives corrupt). begin(0x21,len) -> data(0x22)* -> commit(0x23). - elif cmd == 0x21: # BEGIN: open the staging file; sx[2:6] = expected length (7-bit) - self._fw_len = (sx[2] | (sx[3] << 7) | (sx[4] << 14) | (sx[5] << 21)) if len(sx) >= 6 else 0 + elif cmd == 0x21: # BEGIN firmware transfer: open the .mpy staging file try: try: self._fw.close() except Exception: pass self._fw = open("/app.new", "wb"); self._ack(True) except Exception: # read-only (editor mode) / no space self._fw = None; self._ack(False) - elif cmd == 0x22: # DATA: append a chunk to the staging file + elif cmd == 0x22: # DATA: a base64 chunk (multiple of 4) -> decode -> append try: - if self._fw is None: raise OSError() - self._fw.write(bytes(sx[2:])); self._fw.flush(); self._ack(True) + if self._fw is None or a2b_base64 is None: raise OSError() + self._fw.write(a2b_base64(bytes(sx[2:]))); self._ack(True) except Exception: try: self._fw.close() except Exception: pass self._fw = None; self._ack(False) - elif cmd == 0x23: # COMMIT: verify the whole file, then A/B install + reboot + elif cmd == 0x23: # COMMIT: verify it's a CircuitPython .mpy, then A/B install try: try: self._fw.close() except Exception: pass self._fw = None; gc.collect() - with open("/app.new", "rb") as f: data = f.read() - if (self._fw_len and len(data) != self._fw_len) or (0 in data) \ - or (b"App().run()" not in data) or (b"APP_VERSION" not in data): - try: os.remove("/app.new") # corrupt/truncated -> reject, keep the working build + with open("/app.new", "rb") as f: head = f.read(2) + if os.stat("/app.new")[6] < 4000 or len(head) < 2 or head[0] != 0x43 or head[1] != 0x06: + try: os.remove("/app.new") # not a CircuitPython mpy v6 -> reject, keep the working build except OSError: pass self._ack(False); return try: os.remove("/app.bak") except OSError: pass - os.rename("/app.py", "/app.bak") # current build becomes the rollback - os.rename("/app.new", "/app.py") + os.rename("/app.mpy", "/app.bak") # current build becomes the rollback + os.rename("/app.new", "/app.mpy") open("/trial", "w").close() # arm the trial; the loader reverts if it won't boot self._ack(True); time.sleep(0.4); supervisor.reload() except Exception: # catch ALL (read-only, MemoryError, ...) -> never brick diff --git a/pico-cp/code.py b/pico-cp/code.py index 3ea730c..62b06dd 100644 --- a/pico-cp/code.py +++ b/pico-cp/code.py @@ -1,9 +1,10 @@ # code.py - PM_K-1 A/B firmware loader (stable; rarely changes). # -# The real application lives in app.py; app.bak holds the previous known-good build. The web editor -# pushes a new app.py to a "trial" slot over USB-MIDI; this loader runs it, and if the new build -# fails to boot it AUTOMATICALLY ROLLS BACK to app.bak. (The Pico is also unbrickable: BOOTSEL -> -# drag a CircuitPython .uf2.) app.py clears the /trial marker once it has run healthily for ~5s. +# 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 @@ -12,11 +13,11 @@ def _trial(): except OSError: return False try: - import app # runs the application (app.py ends with App().run()) + 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.py"); os.rename("/app.bak", "/app.py"); os.remove("/trial") + 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: