metronome/build.sh
Me Here 7481f91935 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) <noreply@anthropic.com>
2026-05-29 14:01:57 -05:00

66 lines
4.1 KiB
Bash
Executable file

#!/usr/bin/env bash
# Assemble the deployed single-file pages from source + shared partials + assets/.
#
# Every page (the Concepts landing, the editor app, and the device/form-factor
# pages) is a source that shares code via markers:
# /*@BUILD:include:src/<file>@*/ inlines a shared partial (engine, seed lists, base CSS, header/footer/chrome)
# @BUILD:favicon@ / @BUILD:logo-*@ inline base64 assets (voices are all synthesized — no samples)
# This resolves them so each built page in dist/ is one self-contained file
# (zero deps, works fully offline). deploy.sh runs this first. dist/ is generated —
# don't edit or commit it.
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")
def build(name):
src = pathlib.Path(name).read_text()
# 1) inline shared partials (function-replacement: no backslash/group interpretation)
src = re.sub(r"/\*@BUILD:include:([^@]+)@\*/",
lambda m: pathlib.Path(m.group(1)).read_text().rstrip("\n"), src)
# 2) inline base64 assets (voices are all synthesized now — no samples)
src = src.replace("@BUILD:favicon@", (A / "favicon.b64").read_text().strip())
src = src.replace("@BUILD:logo-dark@", (A / "logo-dark.b64").read_text().strip())
src = src.replace("@BUILD:logo-light@", (A / "logo-light.b64").read_text().strip())
assert "@BUILD:" not in src, f"unresolved build marker(s) remain in {name}"
out = pathlib.Path("dist") / name
out.write_text(src)
return out.stat().st_size
for name in ("index.html","editor.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html",
"embed.html",
"info-editor.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html"):
print("built %s (%dKB)" % (name, build(name) // 1024))
pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is
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")
_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 -- 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", "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