Adds pico-explorer/ as a parallel CircuitPython firmware target alongside the 52Pi
Kit in pico-cp/. Same engine, same program-string grammar, same programs.json, same
live-sync protocol. Read-only on the device (no on-device beat editing); the web
editor's Live sync mirrors all edits in real time and the Explorer emits its own
play/stop/bpm/sel deltas back.
Hardware (Pimoroni Explorer PIM744):
- RP2350B + 2.8" ST7789V 320x240 LCD (8-bit parallel; CircuitPython's official
board definition pre-builds the BusDisplay so we just use board.DISPLAY).
- 6 user buttons - A/B/C on the left of the screen, X/Y/Z on the right.
- Piezo speaker on GP12 (PWM) with amp enable on GP13.
- I2C QwSTEMMA on GP20/21 - reserved, unused by the firmware.
- No touchscreen, no joystick, no RGB LED. Run state shows on a tiny on-screen dot.
Buttons:
- A = play/stop. B = tap tempo. C = menu.
- X = prev track (hold-repeat). Z = next track (hold-repeat).
- Y = tempo -1 (hold-repeat; -5 after 1.5s).
- X+Z chord = tempo +1 (mirrors Y).
- In a menu: X/Z move the row cursor, Y decrements, A cycles/increments/selects,
B = back, C = close.
Files added:
- pico-explorer/{boot.py, code.py, app.py, programs.json, README.md}.
app.py = 1444 lines (~73KB source -> 29.8KB compiled .mpy).
- info-explorer.html.
Files touched:
- pico-cp/app.py: bump to 0.0.23. Version-query (SysEx 0x02 -> 0x03) reply now
includes the device id as "K;<version>" (backward-compat: editor parses
"contains ';'?" - old firmware sent bare version, treated as K).
- editor.html + editor-beta.html: _parseDeviceReply() splits id;version, FW_PATHS
maps id to .py/.mpy URL pair, so Update firmware now pushes the right binary.
- build.sh + deploy.sh: precompile pico-explorer/app.py -> dist/explorer-app.mpy,
zip pm_x1_circuitpy.zip alongside pm_k1_circuitpy.zip, ship
pico-explorer-app.{py,mpy} next to pico-cp-app.{py,mpy}.
- docs/livesync-protocol.md: new section 7 - per-device emit/apply matrix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
85 lines
5.9 KiB
Bash
Executable file
85 lines
5.9 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="$PWD/tools/mpy-cross"; ROOT="$PWD"
|
|
[[ -x "$MPYC" ]] || { echo "error: $MPYC missing (Adafruit mpy-cross for CircuitPython 10.2.1)" >&2; exit 1; }
|
|
( cd pico-cp && "$MPYC" app.py -o "$ROOT/dist/app.mpy" ) # compile from pico-cp/ so tracebacks read "app.py"
|
|
echo "precompiled dist/app.mpy ($(stat -c%s dist/app.mpy) bytes <- $(stat -c%s pico-cp/app.py) source)"
|
|
# PM_X-1 Explorer firmware uses the same mpy-cross. Output as dist/explorer-app.mpy so the Kit + Explorer
|
|
# bundles each ship their own precompiled binary; the served URLs follow the same one-target-per-file rule.
|
|
( cd pico-explorer && "$MPYC" app.py -o "$ROOT/dist/explorer-app.mpy" )
|
|
echo "precompiled dist/explorer-app.mpy ($(stat -c%s dist/explorer-app.mpy) bytes <- $(stat -c%s pico-explorer/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","editor-beta.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","info-explorer.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")
|
|
_xsrc = pathlib.Path("pico-explorer/app.py").read_text() # PM_X-1 Explorer firmware (sibling to the Kit)
|
|
_xbad = [(i, c) for i, c in enumerate(_xsrc) if ord(c) > 0x7F]
|
|
assert not _xbad, "pico-explorer/app.py has non-ASCII at %r -- keep it ASCII (version regex + clean source)" % (_xbad[:5],)
|
|
pathlib.Path("dist/pico-explorer-app.py").write_text(_xsrc) # editor reads APP_VERSION from here
|
|
pathlib.Path("dist/pico-explorer-app.mpy").write_bytes(pathlib.Path("dist/explorer-app.mpy").read_bytes())
|
|
print("copied pico-explorer-app.py + pico-explorer-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")
|
|
# PM_X-1 Explorer drive bundle (download → unzip onto CIRCUITPY on the Pimoroni Explorer with CircuitPython for Pico 2)
|
|
with zipfile.ZipFile("dist/pm_x1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z:
|
|
for f in ("code.py", "boot.py", "programs.json", "README.md"):
|
|
z.write("pico-explorer/" + f, f)
|
|
for f in ("font_s.bin", "font_m.bin", "font_l.bin", "logo.bin", "midi.bin", "usb.bin"):
|
|
z.write("pico-cp/" + f, f) # fonts + icons are resolution-agnostic; reuse the Kit's baked blobs
|
|
z.write("dist/explorer-app.mpy", "app.mpy")
|
|
z.write("dist/editor.html", "editor.html")
|
|
print("zipped pm_x1_circuitpy.zip")
|
|
PY
|