PM_X-1 0.0.1: Pimoroni Explorer sibling firmware + Kit 0.0.23 device-id reply

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>
This commit is contained in:
Me Here 2026-05-30 20:43:38 -05:00
parent 617bb5a8b2
commit 3192f3debc
12 changed files with 1817 additions and 23 deletions

View file

@ -19,6 +19,10 @@ MPYC="$PWD/tools/mpy-cross"; ROOT="$PWD"
[[ -x "$MPYC" ]] || { echo "error: $MPYC missing (Adafruit mpy-cross for CircuitPython 10.2.1)" >&2; exit 1; } [[ -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" ( 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)" 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' python3 - <<'PY'
import os, pathlib, re import os, pathlib, re
@ -40,7 +44,7 @@ def build(name):
for name in ("index.html","editor.html","editor-beta.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html", for name in ("index.html","editor.html","editor-beta.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html",
"embed.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-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)) 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 pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is
print("copied embed.js") print("copied embed.js")
@ -55,6 +59,12 @@ pathlib.Path("dist/pico-cp-app.py").write_text(_appsrc) # served for version r
# the editor pushes the PRECOMPILED .mpy (base64); serve it next to the source # 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()) 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") 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) 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: 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", for f in ("code.py", "boot.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin",
@ -63,4 +73,13 @@ with zipfile.ZipFile("dist/pm_k1_circuitpy.zip", "w", zipfile.ZIP_DEFLATED) as z
z.write("dist/app.mpy", "app.mpy") # the precompiled firmware (NOT app.py - too big to compile on-device) 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 z.write("dist/editor.html", "editor.html") # offline copy of the editor, on the drive
print("zipped pm_k1_circuitpy.zip") 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 PY

View file

@ -42,7 +42,7 @@ fi
echo "deployed v$BUILD -> $DEST_DIR" echo "deployed v$BUILD -> $DEST_DIR"
for f in index.html editor.html editor-beta.html player.html teacher.html stage.html micro.html showcase.html kit.html \ for f in index.html editor.html editor-beta.html player.html teacher.html stage.html micro.html showcase.html kit.html \
embed.html \ embed.html \
info-editor.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html; do info-editor.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html info-explorer.html; do
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f" sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)" echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
done done
@ -51,6 +51,9 @@ cp "$DIST_DIR/pico-main.py" "$DEST_DIR/pico-main.py"; echo " pico-main.py ($(st
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/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)" # served for version reading + reference 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) 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)
cp "$DIST_DIR/pm_x1_circuitpy.zip" "$DEST_DIR/pm_x1_circuitpy.zip"; echo " pm_x1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_x1_circuitpy.zip") bytes)" # PM_X-1 Explorer CircuitPython bundle
cp "$DIST_DIR/pico-explorer-app.py" "$DEST_DIR/pico-explorer-app.py"; echo " pico-explorer-app.py ($(stat -c '%s' "$DEST_DIR/pico-explorer-app.py") bytes)" # served for version reading
cp "$DIST_DIR/pico-explorer-app.mpy" "$DEST_DIR/pico-explorer-app.mpy"; echo " pico-explorer-app.mpy ($(stat -c '%s' "$DEST_DIR/pico-explorer-app.mpy") bytes)" # PM_X-1 firmware (the editor pushes this when device id = X)
rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html rm -f "$DEST_DIR/player-asbuilt.html" # renamed to teacher.html
rm -f "$DEST_DIR/concepts.html" # Concepts is now the landing (/) 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 # info-*.html are first-class pages again: each form factor has a lean widget page

View file

@ -193,3 +193,20 @@ target.
- Streaming the device practice log (`history.json`) up to the browser. - Streaming the device practice log (`history.json`) up to the browser.
- Mirroring device `settings.json` (LED brightness, MIDI config, etc.). - Mirroring device `settings.json` (LED brightness, MIDI config, etc.).
- Multipeer / multieditor arbitration beyond lastwriterwins. - Multipeer / multieditor arbitration beyond lastwriterwins.
---
## 7. Perdevice emit/apply matrix
Both targets implement the **full apply path** for every verb. They differ in what
they **emit**, because ondevice editing differs:
| Device | Emits | Applies |
|-------------|----------------------------------------------------|---------------------------------------------|
| **PM_K1** Kit (touchscreen + joystick) | `play` / `stop` / `bpm` / `sel` / `beat` / `lane` (FULL on structural lane edits) | all of the above |
| **PM_X1** Explorer (6 buttons, readonly beats) | `play` / `stop` / `bpm` / `sel` only (no ondevice beat/lane editing) | all of the above |
Editors don't need to specialcase the source — both DELTA streams look identical on
the wire, and the **device id is only exposed on the version query** (SysEx `0x02`
`0x03` reply, `<id>;<version>`; pre0.0.23 firmware sends bare version → assume
`K`).

View file

@ -1211,34 +1211,42 @@ async function toggleDeviceAudio() {
function _queryDeviceVersion() { // ask the device its firmware version (SysEx 0x02 -> reply 0x03) 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); }); return new Promise((res) => { _verCb = res; _send([0xF0, 0x7D, 0x02, 0xF7]); setTimeout(() => { if (_verCb) { _verCb = null; res(null); } }, 1500); });
} }
// 0.0.23+ devices reply "<id>;<version>" (e.g. "K;0.0.23", "X;0.0.1"); pre-0.0.23 send bare version -> assume K.
function _parseDeviceReply(s) {
if (!s) return { id: null, version: null };
const i = s.indexOf(";");
return i >= 0 ? { id: s.slice(0, i), version: s.slice(i + 1) } : { id: "K", version: s };
}
const FW_PATHS = { K: { py: "/pico-cp-app.py", mpy: "/pico-cp-app.mpy", label: "PM_K-1 Kit" },
X: { py: "/pico-explorer-app.py", mpy: "/pico-explorer-app.mpy", label: "PM_X-1 Explorer" } };
async function updateFirmware() { // A/B firmware update over USB-MIDI: push the precompiled .mpy async function updateFirmware() { // A/B firmware update over USB-MIDI: push the precompiled .mpy
console.log("[fw] update start"); console.log("[fw] update start");
if (!(await _ensureMidi()) || !_midiOutputs().length) { if (!(await _ensureMidi()) || !_midiOutputs().length) {
console.log("[fw] no MIDI output (outputs:", _midiOutputs().length, ")"); console.log("[fw] no MIDI output (outputs:", _midiOutputs().length, ")");
return alert("Connect the PM_K-1 (Chrome/Edge/Firefox), then try again."); return alert("Connect the device (Chrome/Edge/Firefox), then try again.");
} }
console.log("[fw] MIDI ok; outputs:", _midiOutputs().map((o) => o.name)); console.log("[fw] MIDI ok; outputs:", _midiOutputs().map((o) => o.name));
const dev = await _queryDeviceVersion(); const reply = await _queryDeviceVersion();
console.log("[fw] device version reply:", dev); const { id: devId, version: dev } = _parseDeviceReply(reply);
const paths = FW_PATHS[devId] || FW_PATHS.K; // unknown id -> assume Kit (pre-0.0.23 firmware)
console.log("[fw] device reply:", reply, "-> id:", devId || "(none)", "version:", dev, "paths:", paths.label);
let latest = null, b64 = null; 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"]) { for (const base of ["", "https://metronome.varasys.io"]) {
try { const t = await (await fetch(base + "/pico-cp-app.py", { cache: "no-store" })).text(); try { const t = await (await fetch(base + paths.py, { cache: "no-store" })).text();
const m = t.match(/APP_VERSION\s*=\s*["']([^"']+)["']/); if (m) latest = m[1]; } catch (_) {} 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" }); try { const r = await fetch(base + paths.mpy, { cache: "no-store" });
if (r.ok) { b64 = _b64(new Uint8Array(await r.arrayBuffer())); break; } } catch (_) {} if (r.ok) { b64 = _b64(new Uint8Array(await r.arrayBuffer())); break; } } catch (_) {}
} }
if (!b64) { // offline: let the user pick app.mpy 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" + 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."); "metronome.varasys.io" + paths.mpy + ", or use the online editor at metronome.varasys.io/editor.html.");
const u8 = await _pickBinary(); if (!u8) return; const u8 = await _pickBinary(); if (!u8) return;
b64 = _b64(u8); if (!latest) latest = "(picked .mpy)"; b64 = _b64(u8); if (!latest) latest = "(picked .mpy)";
} }
if (!latest) latest = "?"; if (!latest) latest = "?";
console.log("[fw] latest:", latest, "| .mpy base64 length:", b64 && b64.length); console.log("[fw] latest:", latest, "| .mpy base64 length:", b64 && b64.length);
const upToDate = dev && dev === latest; const upToDate = dev && dev === latest;
if (!confirm("Device firmware: " + (dev || "unknown") + "\nNew build: " + latest + if (!confirm(paths.label + " firmware: " + (dev || "unknown") + "\nNew build: " + latest +
(upToDate ? "\n\nSame version. Re-install anyway?" (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."))) { : "\n\nUpdate now? The device reboots, runs the new build, and auto-rolls-back if it fails to start."))) {
console.log("[fw] confirm returned false (cancelled, OR the browser is suppressing dialogs -> reload the page)"); console.log("[fw] confirm returned false (cancelled, OR the browser is suppressing dialogs -> reload the page)");

View file

@ -1204,34 +1204,42 @@ async function toggleDeviceAudio() {
function _queryDeviceVersion() { // ask the device its firmware version (SysEx 0x02 -> reply 0x03) 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); }); return new Promise((res) => { _verCb = res; _send([0xF0, 0x7D, 0x02, 0xF7]); setTimeout(() => { if (_verCb) { _verCb = null; res(null); } }, 1500); });
} }
// 0.0.23+ devices reply "<id>;<version>" (e.g. "K;0.0.23", "X;0.0.1"); pre-0.0.23 send bare version -> assume K.
function _parseDeviceReply(s) {
if (!s) return { id: null, version: null };
const i = s.indexOf(";");
return i >= 0 ? { id: s.slice(0, i), version: s.slice(i + 1) } : { id: "K", version: s };
}
const FW_PATHS = { K: { py: "/pico-cp-app.py", mpy: "/pico-cp-app.mpy", label: "PM_K-1 Kit" },
X: { py: "/pico-explorer-app.py", mpy: "/pico-explorer-app.mpy", label: "PM_X-1 Explorer" } };
async function updateFirmware() { // A/B firmware update over USB-MIDI: push the precompiled .mpy async function updateFirmware() { // A/B firmware update over USB-MIDI: push the precompiled .mpy
console.log("[fw] update start"); console.log("[fw] update start");
if (!(await _ensureMidi()) || !_midiOutputs().length) { if (!(await _ensureMidi()) || !_midiOutputs().length) {
console.log("[fw] no MIDI output (outputs:", _midiOutputs().length, ")"); console.log("[fw] no MIDI output (outputs:", _midiOutputs().length, ")");
return alert("Connect the PM_K-1 (Chrome/Edge/Firefox), then try again."); return alert("Connect the device (Chrome/Edge/Firefox), then try again.");
} }
console.log("[fw] MIDI ok; outputs:", _midiOutputs().map((o) => o.name)); console.log("[fw] MIDI ok; outputs:", _midiOutputs().map((o) => o.name));
const dev = await _queryDeviceVersion(); const reply = await _queryDeviceVersion();
console.log("[fw] device version reply:", dev); const { id: devId, version: dev } = _parseDeviceReply(reply);
const paths = FW_PATHS[devId] || FW_PATHS.K; // unknown id -> assume Kit (pre-0.0.23 firmware)
console.log("[fw] device reply:", reply, "-> id:", devId || "(none)", "version:", dev, "paths:", paths.label);
let latest = null, b64 = null; 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"]) { for (const base of ["", "https://metronome.varasys.io"]) {
try { const t = await (await fetch(base + "/pico-cp-app.py", { cache: "no-store" })).text(); try { const t = await (await fetch(base + paths.py, { cache: "no-store" })).text();
const m = t.match(/APP_VERSION\s*=\s*["']([^"']+)["']/); if (m) latest = m[1]; } catch (_) {} 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" }); try { const r = await fetch(base + paths.mpy, { cache: "no-store" });
if (r.ok) { b64 = _b64(new Uint8Array(await r.arrayBuffer())); break; } } catch (_) {} if (r.ok) { b64 = _b64(new Uint8Array(await r.arrayBuffer())); break; } } catch (_) {}
} }
if (!b64) { // offline: let the user pick app.mpy 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" + 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."); "metronome.varasys.io" + paths.mpy + ", or use the online editor at metronome.varasys.io/editor.html.");
const u8 = await _pickBinary(); if (!u8) return; const u8 = await _pickBinary(); if (!u8) return;
b64 = _b64(u8); if (!latest) latest = "(picked .mpy)"; b64 = _b64(u8); if (!latest) latest = "(picked .mpy)";
} }
if (!latest) latest = "?"; if (!latest) latest = "?";
console.log("[fw] latest:", latest, "| .mpy base64 length:", b64 && b64.length); console.log("[fw] latest:", latest, "| .mpy base64 length:", b64 && b64.length);
const upToDate = dev && dev === latest; const upToDate = dev && dev === latest;
if (!confirm("Device firmware: " + (dev || "unknown") + "\nNew build: " + latest + if (!confirm(paths.label + " firmware: " + (dev || "unknown") + "\nNew build: " + latest +
(upToDate ? "\n\nSame version. Re-install anyway?" (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."))) { : "\n\nUpdate now? The device reboots, runs the new build, and auto-rolls-back if it fails to start."))) {
console.log("[fw] confirm returned false (cancelled, OR the browser is suppressing dialogs -> reload the page)"); console.log("[fw] confirm returned false (cancelled, OR the browser is suppressing dialogs -> reload the page)");

169
info-explorer.html Normal file
View file

@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VARASYS PM_X-1 Explorer - wiring, parts &amp; firmware (Pimoroni Explorer / RP2350)</title>
<meta name="description" content="PM_X-1 Explorer - the Pimoroni Explorer (PIM744, RP2350) as a 6-button polymeter metronome with live-sync to the web editor. Pinout, parts list, and the precompiled CircuitPython firmware bundle." />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@">
<script>
(function(){ try{ var p = localStorage.getItem("metronome.theme");
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
</script>
<style>
/*@BUILD:include:src/base.css@*/
:root{ --bg1:#12151c; --bg2:#05070a; --txt:#c7d0db; --muted:#7f8b9a; --link:#6cb6ff;
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#2a313c; --silk:#aab2bc; }
:root[data-theme="light"]{ --bg1:#f5f8fc; --bg2:#dde4ec; --txt:#1e2630; --muted:#5c6776; --link:#1769c4;
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#d2dae4; }
body{ margin:0; min-height:100vh; padding:22px 16px 56px; color:var(--txt);
background:radial-gradient(circle at 50% -8%, var(--bg1), var(--bg2)); }
a{ color:var(--link); }
main{ width:100%; max-width:980px; margin:0 auto; }
.info-hero{ text-align:center; padding:16px 8px 2px; }
.info-hero h1{ font-size:clamp(24px,5vw,36px); margin:0; letter-spacing:-.01em; }
.info-hero .sub{ margin:9px auto 0; max-width:64ch; font-size:14.5px; }
.steps{ width:100%; max-width:760px; margin:8px auto 0; color:var(--muted); font-size:14px; line-height:1.6; }
.steps li{ margin:5px 0; }
.steps code, .about code, .sub code { background:var(--field-bg); border:1px solid var(--field-bd); border-radius:5px; padding:1px 5px; font-size:12.5px; }
.dl{ display:inline-flex; align-items:center; gap:7px; margin:4px 10px 4px 0; padding:9px 14px; border-radius:10px;
background:linear-gradient(180deg,#34c6ff,var(--cyan)); color:#04121b; font-weight:700; text-decoration:none; font-size:13.5px; }
.dl.alt{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); font-weight:600; }
</style>
</head>
<body>
/*@BUILD:include:src/header.html@*/
<main>
<section class="info-hero">
<h1>PM_X-1 Explorer</h1>
<p class="sub">The off-the-shelf <b>Pimoroni Explorer Kit</b> (RP2350, 2.8&quot; LCD, 6 buttons, piezo) as a polymeter metronome - sibling to the PM_K-1 Kit, sharing the engine, program-string grammar, and live-sync protocol with the web editor.</p>
</section>
<section class="about">
<h2>What it is</h2>
<div class="ff-tags"><span class="hw">Buildable now</span><span>RP2350 (Pico 2 class)</span><span>Pimoroni Explorer PIM744</span><span>~$60</span></div>
<p>The <a href="https://shop.pimoroni.com/products/explorer" target="_blank" rel="noopener">Pimoroni Explorer Kit (PIM744)</a> is a finished
development board: <b>RP2350B</b> built in, <b>2.8&quot; ST7789V 320x240 IPS LCD</b>, <b>6 user buttons</b> (A/B/C
on the left of the screen, X/Y/Z on the right), a <b>piezo speaker</b>, USB-C, a JST-PH battery
connector, and a mini breadboard with 6 GPIOs / 3 ADCs broken out for sensor projects. No soldering;
you flash CircuitPython and drop the firmware on. <b>No touchscreen, no joystick, no RGB LED</b> -
everything is driven from the 6 buttons.</p>
<p>It runs the same <b>polymeter engine</b> and the same <b>program strings</b> as the web editor.
Beat editing is done in the browser; <b>Live sync</b> mirrors edits to the device in real time
(HELLO/FULL/DELTA over USB-MIDI), and the device mirrors play/stop/tempo/track changes back. The
piezo clicks; a tiny on-screen dot shows run state.</p>
</section>
<details class="spec" open>
<summary>Wiring - the Pimoroni Explorer fixed pinout (no breadboarding required)</summary>
<div class="spec-body">
<p class="sub">Everything is wired on the board; this is what the firmware reads. The display is driven by an 8-bit parallel bus initialised by CircuitPython's official board definition - we use <code>board.DISPLAY</code> directly.</p>
<table class="bom">
<thead><tr><th>Component</th><th>RP2350 pins</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="2">Display - 2.8&quot; ST7789V, 320x240 (8-bit parallel 8080)</td></tr>
<tr><td class="part">BL / CS / DC / WR / RD / D0-D7</td><td>GP26 / GP27 / GP28 / GP30 / GP31 / GP32-GP39 (board.c)</td></tr>
<tr class="grp"><td colspan="2">Buttons (digital, pull-up)</td></tr>
<tr><td class="part">A (play/stop) / B (tap tempo) / C (menu)</td><td>GP16 / GP15 / GP14 (left side, top to bottom)</td></tr>
<tr><td class="part">X (prev track) / Y (-bpm) / Z (next track)</td><td>GP17 / GP18 / GP19 (right side, top to bottom)</td></tr>
<tr class="grp"><td colspan="2">Audio</td></tr>
<tr><td class="part">Piezo PWM</td><td>GP12</td></tr>
<tr><td class="part">Amp enable</td><td>GP13</td></tr>
<tr class="grp"><td colspan="2">I2C (QwSTEMMA - unused by the firmware, free for sensors)</td></tr>
<tr><td class="part">SDA / SCL</td><td>GP20 / GP21</td></tr>
</tbody>
</table>
</div>
</details>
<details class="spec" open>
<summary>Controls</summary>
<div class="spec-body">
<table class="bom">
<thead><tr><th>Button</th><th>Action</th></tr></thead>
<tbody>
<tr><td class="part">A</td><td>play / stop</td></tr>
<tr><td class="part">B</td><td>tap tempo</td></tr>
<tr><td class="part">C</td><td>menu (Settings / Practice log / Help / About)</td></tr>
<tr><td class="part">X</td><td>prev track (hold to repeat)</td></tr>
<tr><td class="part">Z</td><td>next track (hold to repeat)</td></tr>
<tr><td class="part">Y</td><td>tempo -1 (hold = -5 after 1.5 s)</td></tr>
<tr><td class="part">X + Z (chord)</td><td>tempo +1 (same hold rule as Y)</td></tr>
<tr class="grp"><td colspan="2">In a menu</td></tr>
<tr><td class="part">X / Z</td><td>move cursor up / down (Help: prev / next page)</td></tr>
<tr><td class="part">Y</td><td>decrement the focused value</td></tr>
<tr><td class="part">A</td><td>cycle / increment / select</td></tr>
<tr><td class="part">B</td><td>back (cancel)</td></tr>
<tr><td class="part">C</td><td>close the menu</td></tr>
</tbody>
</table>
</div>
</details>
<details class="spec" open>
<summary>Parts</summary>
<div class="spec-body">
<p class="sub">A finished development board, not a custom build - ballpark one-off price (USD).</p>
<table class="bom">
<thead><tr><th>Part</th><th class="q">Qty</th><th class="c">~$</th></tr></thead>
<tbody>
<tr><td class="part">Pimoroni Explorer Kit (PIM744) <span class="spec">- RP2350B, 2.8&quot; ST7789V, 6 buttons, piezo + amp, USB-C</span></td><td class="q">1</td><td class="c">60</td></tr>
<tr><td class="part">USB-C cable <span class="spec">- power + flashing</span></td><td class="q">1</td><td class="c">2</td></tr>
<tr class="total"><td>Total (one-off)</td><td class="q"></td><td class="c">&asymp; $62</td></tr>
</tbody>
</table>
<p class="sub" style="margin-top:10px">Reference: <a href="https://shop.pimoroni.com/products/explorer" target="_blank" rel="noopener">Pimoroni Explorer product page</a>
&middot; <a href="https://github.com/pimoroni/explorer" target="_blank" rel="noopener">vendor code</a>
&middot; <a href="https://circuitpython.org/board/pimoroni_explorer2350/" target="_blank" rel="noopener">CircuitPython for Pimoroni Explorer (RP2350)</a>.</p>
</div>
</details>
<details class="spec" open>
<summary>Firmware - self-contained appliance (USB drive &middot; web-driven editing via Live sync &middot; MIDI audio &middot; practice log)</summary>
<div class="spec-body">
<p class="sub">The firmware turns the Explorer into a self-contained appliance: it mounts as a
<b>USB drive</b> carrying the (precompiled) firmware, your tracks and an offline copy of this editor;
drives a lanes/pads display with <b>web-driven editing</b> via <b>Live sync</b>; <b>logs your practice</b> to
<code>history.json</code>; takes new set lists <b>pushed from the editor over USB-MIDI</b>; and plays
out your <b>computer's speakers over USB-MIDI</b>. By default the firmware owns the drive (read-only to
the computer - so it can log and can't be accidentally erased); hold <b>button A</b> at power-on for
editor mode (drive writable).</p>
<p>
<a class="dl" href="/pm_x1_circuitpy.zip" download>Download CircuitPython bundle &darr;</a>
<a class="dl alt" href="https://codeberg.org/VARASYS/metronome/src/branch/main/pico-explorer" target="_blank" rel="noopener">Source + README &nearr;</a>
</p>
<ol class="steps">
<li>Flash <b>CircuitPython for Pimoroni Explorer (RP2350)</b>
(<a href="https://circuitpython.org/board/pimoroni_explorer2350/" target="_blank" rel="noopener">download</a>)
via BOOTSEL, unzip the bundle onto <code>CIRCUITPY</code>, and power-cycle. It boots into appliance mode.</li>
<li><b>Edit on the web:</b> open the <a href="/editor-beta.html">editor (beta)</a> in Chrome / Edge / Firefox,
click <b>&#x1f517; Live sync</b>, and the Explorer mirrors your edits live (beats, tempo, track changes).</li>
<li><b>Save a set list to the device</b> for offline use: set-list <b>&middot;&middot;&middot;</b> menu &rarr;
<b>&#x1f4DF; Save to device</b>. It's pushed over USB-MIDI; the device persists it to
<code>/programs.json</code>.</li>
<li><b>Play through your computer:</b> click <b>&#x1f3b9; Device audio</b>, then press <b>A</b> on the device -
the full groove sounds through your speakers over USB-MIDI, in sync. A <b>MIDI</b> badge appears in the
header and the piezo auto-mutes.</li>
<li><b>Practice log:</b> press <b>C</b> &rarr; <b>Practice log</b>. Plays over 5 s appear (time &middot; BPM &middot; duration &middot; bars).</li>
<li><b>Firmware updates:</b> &middot;&middot;&middot; menu &rarr; <b>&#x2B06; Update firmware</b> - the editor reads
the device id (X = Explorer), fetches the matching <code>pico-explorer-app.mpy</code>, and pushes it
over USB-MIDI. The device A/B-updates with automatic rollback if a build won't boot.</li>
</ol>
</div>
</details>
<p class="sub" style="max-width:760px;margin:14px auto 0">Pairs with the touch-driven <a href="/info-kit.html">PM_K-1 Kit</a> - same engine, same programs.json, same web editor.</p>
</main>
/*@BUILD:include:src/footer.html@*/
<script>
const APP_VERSION = "v0.0.1-dev";
/*@BUILD:include:src/chrome.js@*/
</script>
</body>
</html>

View file

@ -18,7 +18,8 @@
import board, busio, digitalio, analogio, pwmio, displayio, vectorio, time, json, gc, os, supervisor 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 supervisor.runtime.autoreload = False # we write our own files (log + pushed programs); never self-restart
APP_VERSION = "0.0.22" # firmware version (the A/B updater pushes/compares this) APP_VERSION = "0.0.23" # firmware version (the A/B updater pushes/compares this)
DEVICE_ID = "K" # 'K' = 52Pi kit, 'X' = Pimoroni Explorer (per docs/livesync-protocol.md and the version reply)
try: try:
import rtc # set from the editor's clock SysEx so the log has real timestamps import rtc # set from the editor's clock SysEx so the log has real timestamps
except ImportError: except ImportError:
@ -1541,8 +1542,10 @@ class App:
if cmd == 0x01 and len(sx) >= 8 and rtc is not None: # set clock: yr-2000, mo, dd, hh, mm, ss if cmd == 0x01 and len(sx) >= 8 and rtc is not None: # set clock: yr-2000, mo, dd, hh, mm, ss
try: rtc.RTC().datetime = time.struct_time((2000 + sx[2], sx[3], sx[4], sx[5], sx[6], sx[7], 0, -1, -1)) try: rtc.RTC().datetime = time.struct_time((2000 + sx[2], sx[3], sx[4], sx[5], sx[6], sx[7], 0, -1, -1))
except Exception: pass except Exception: pass
elif cmd == 0x02: # version query -> reply 0x03 + APP_VERSION elif cmd == 0x02: # version query -> reply 0x03 + "<device_id>;<APP_VERSION>"
if self.midi: self.midi.write(bytes([0xF0, 0x7D, 0x03]) + APP_VERSION.encode() + bytes([0xF7])) if self.midi: # old firmware sent bare APP_VERSION; editor parses "contains ';'?" for back-compat
payload = DEVICE_ID + ";" + APP_VERSION
self.midi.write(bytes([0xF0, 0x7D, 0x03]) + payload.encode() + bytes([0xF7]))
elif cmd == 0x40 or cmd == 0x41 or cmd == 0x42 or cmd == 0x43: # Live sync (see src/livesync.js) elif cmd == 0x40 or cmd == 0x41 or cmd == 0x42 or cmd == 0x43: # Live sync (see src/livesync.js)
try: text = "".join(chr(b) if 0x20 <= b < 0x7F else "" for b in sx[2:]) try: text = "".join(chr(b) if 0x20 <= b < 0x7F else "" for b in sx[2:])
except Exception: return except Exception: return

74
pico-explorer/README.md Normal file
View file

@ -0,0 +1,74 @@
# PM_X-1 "Explorer" — CircuitPython edition (Pimoroni Explorer · RP2350)
The **CircuitPython** firmware for the [Pimoroni Explorer Kit (PIM744)](https://shop.pimoroni.com/products/explorer),
set up as a self-contained appliance. Sibling to the PM_K-1 build in `../pico-cp/` (the 52Pi EP-0172
kit) — same engine, same program strings, same `programs.json`, same web editor.
This board is a **2.8″ ST7789V 320×240 LCD + 6 user buttons (A/B/C on the left, X/Y/Z on the right)
+ piezo speaker** built around an RP2350B (Pico 2 class chip). **No touchscreen, no joystick, no
RGB LED.** Editing is done in the web editor with **Live sync** on; the device mirrors changes in
real time and emits its own play/stop/bpm/sel deltas back.
## Controls
| Button | Action |
| ------ | --------------------------------------------------------------------- |
| **A** | play / stop |
| **B** | tap tempo |
| **C** | menu (Settings / Help / About / Practice log) |
| **X** | prev track (hold to repeat) |
| **Y** | tempo 1 (hold to repeat; after ~1.5 s the step grows to 5) |
| **Z** | next track (hold to repeat) |
| **X + Z** | tempo +1 (chord; same hold-repeat as Y) |
In a menu: **X / Z** move the cursor up / down, **Y** decrements the focused value, **A** commits or
cycles, **B** = back, **C** = close.
## Install
1. **Flash CircuitPython for Pico 2 / RP2350.** Hold **BOOTSEL** on the Explorer, plug it in over
USB-C, drop the [Pimoroni Explorer (RP2350) CircuitPython `.uf2`](https://circuitpython.org/board/pimoroni_explorer2350/)
onto the `RP2350` drive. A `CIRCUITPY` drive appears.
2. **Copy the bundle onto `CIRCUITPY`**`boot.py`, `code.py`, **`app.mpy`**, `programs.json`,
`font_s.bin` / `font_m.bin` / `font_l.bin`, `logo.bin` / `midi.bin` / `usb.bin`, `editor.html`
(offline editor). If an old `app.py` is on the drive, delete it.
3. **Power-cycle.** It boots into appliance mode and runs.
## Program it from the web
Open <https://metronome.varasys.io> in Chrome / Edge / Firefox. The set-list **⋯** menu →
**📟 Save to device** pushes a `programs.json` over USB-MIDI; the device persists it and reloads.
Click **🔗 Live sync** to mirror edits in real time.
## Pin reference
The display, buttons, and audio are wired into the board — no jumpers required. CircuitPython's
official board definition for `pimoroni_explorer2350` exposes `board.DISPLAY` pre-initialized, so
the firmware just uses it.
| Function | GPIO |
| ----------------- | -------- |
| Button A | GP16 |
| Button B | GP15 |
| Button C | GP14 |
| Button X | GP17 |
| Button Y | GP18 |
| Button Z | GP19 |
| Piezo audio (PWM) | GP12 |
| Piezo amp enable | GP13 |
| I²C SDA (QwSTEMMA) | GP20 |
| I²C SCL (QwSTEMMA) | GP21 |
| Display | `board.DISPLAY` (8080 parallel bus on GP26..GP39, initialized by board.c) |
## Calibration (flags at the top of `app.py`)
- **Speaker too loud / quiet:** the piezo + amp gain is fixed in hardware. `MUTE_SPEAKER`
silences the click; `SPEAKER_AUTO_MUTE` auto-mutes when a MIDI host is listening.
- **Buttons feel inverted:** the polarity is hard-coded to active-low (pull-up). If a button
fires on release instead of press, check the `BTN_*` pin map at the top of `app.py`.
- **Display orientation:** the board's CircuitPython init mounts the panel landscape
(320 × 240). If your screen looks rotated, that's a board.c-level thing — file a CircuitPython
bug, don't patch the firmware.
If `app.py` ever errors, CircuitPython prints the traceback **on the screen and over USB serial**
send me that.

1444
pico-explorer/app.py Normal file

File diff suppressed because it is too large Load diff

22
pico-explorer/boot.py Normal file
View file

@ -0,0 +1,22 @@
# boot.py - runs once at power-on (before USB connects); decides who owns the filesystem.
#
# DEFAULT = appliance mode: the FIRMWARE owns the drive, so it can save your practice log to
# /history.json and write /programs.json that the editor pushes over USB-MIDI. The drive is then
# READ-ONLY to the computer - which also protects the firmware from accidental deletion.
#
# HOLD BUTTON A (GP16 on the Pimoroni Explorer) WHILE PLUGGING IN = editor mode: the drive is
# writable by the computer, so you can drag programs.json / code.py on from any OS or browser
# (the universal fallback). Reset afterwards to return to appliance mode.
#
# Also frees a USB endpoint (disables unused HID) and makes sure USB-MIDI is available.
import board, digitalio, storage, usb_hid, usb_midi
try: usb_hid.disable()
except Exception: pass
usb_midi.enable()
a = digitalio.DigitalInOut(board.GP16)
a.switch_to_input(pull=digitalio.Pull.UP)
appliance = a.value # value True (pull-up, not pressed) -> appliance mode
a.deinit()
if appliance:
try: storage.remount("/", readonly=False) # writable by code, read-only to the computer
except Exception: pass

24
pico-explorer/code.py Normal file
View file

@ -0,0 +1,24 @@
# code.py - PM_X-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

View file

@ -0,0 +1,3 @@
{
"setlists": []
}