Fix firmware-update brick: app.py must be ASCII for the 7-bit MIDI push (+ guards)

Root cause: a non-ASCII em-dash in an app.py comment. The A/B updater pushes app.py
as 7-bit SysEx (charCode & 0x7F), which turned the em-dash's bytes into a NUL byte ->
corrupt source -> the pushed build crashed on boot (black screen, onboard LED blinking
CircuitPython's error/safe-mode pattern). A dragged copy was fine (valid UTF-8); only
the over-MIDI path mangled it.

- Replace the em-dash with ASCII; app.py is now pure ASCII.
- build.sh now ASSERTS pico-cp/app.py is pure ASCII (fails the build otherwise) so this
  class of bug can never ship again.
- Device 0x20 handler VALIDATES the pushed app.py before installing (reject if it
  contains a NUL byte, or is missing App().run()/APP_VERSION) and now catches ALL
  exceptions (not just OSError) -> a corrupt/truncated/oversized push NAKs and keeps the
  working build instead of bricking. Longer pre-reload sleep so the ACK flushes.

APP_VERSION -> 0.0.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-29 10:14:13 -05:00
parent 2b113a18cc
commit 711a02fcc1
3 changed files with 17 additions and 7 deletions

View file

@ -37,8 +37,13 @@ pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text())
print("copied embed.js")
pathlib.Path("dist/pico-main.py").write_text(pathlib.Path("pico/main.py").read_text()) # PM_K-1 firmware, downloadable
print("copied pico-main.py")
pathlib.Path("dist/pico-cp-app.py").write_text(pathlib.Path("pico-cp/app.py").read_text()) # served for the editor's A/B firmware updater
print("copied pico-cp-app.py")
_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)")
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",

View file

@ -18,7 +18,7 @@
import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor
supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart
APP_VERSION = "0.0.4" # firmware version (the A/B updater pushes/compares this)
APP_VERSION = "0.0.5" # firmware version (the A/B updater pushes/compares this)
try:
import rtc # set from the editor's clock SysEx so the log has real timestamps
except ImportError:
@ -165,7 +165,7 @@ def make_text(s, font, fg, bg):
pen += adv
return displayio.TileGrid(bmp, pixel_shader=pal), w, h
# ---- single-image alpha assets (logo, status icons) blit like a one-off glyph; see gen_assets.py ----
# ---- single-image alpha assets (logo, status icons) - blit like a one-off glyph; see gen_assets.py ----
def load_alpha(path):
try:
with open(path, "rb") as f: blob = f.read()
@ -704,15 +704,20 @@ class App:
elif cmd == 0x20: # A/B firmware update: install new app.py to the trial slot
try:
data = bytes(sx[2:])
# sanity-check the transfer before touching the working build: a corrupt/truncated push
# (e.g. a dropped MIDI byte, or a 7-bit-mangled non-ASCII char -> NUL) must NOT be installed.
if (0 in data) or (b"App().run()" not in data) or (b"APP_VERSION" not in data):
if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7E, 0xF7])) # NAK: rejected, kept current build
return
try: os.remove("/app.bak")
except OSError: pass
os.rename("/app.py", "/app.bak") # keep the current build as the rollback
with open("/app.py", "wb") as f: f.write(data)
open("/trial", "w").close() # arm the trial; the loader reverts if it won't boot
if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7F, 0xF7])) # ACK -> rebooting
time.sleep(0.3); supervisor.reload()
except OSError:
if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7E, 0xF7])) # NAK: read-only (editor mode)
time.sleep(0.4); supervisor.reload()
except Exception: # catch ALL (OSError read-only, MemoryError, ...) -> never brick
if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x7E, 0xF7])) # NAK
def run(self):
if self.touch.addr is None: