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:
parent
617bb5a8b2
commit
3192f3debc
12 changed files with 1817 additions and 23 deletions
21
build.sh
21
build.sh
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.).
|
||||||
- Multi‑peer / multi‑editor arbitration beyond last‑writer‑wins.
|
- Multi‑peer / multi‑editor arbitration beyond last‑writer‑wins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Per‑device emit/apply matrix
|
||||||
|
|
||||||
|
Both targets implement the **full apply path** for every verb. They differ in what
|
||||||
|
they **emit**, because on‑device editing differs:
|
||||||
|
|
||||||
|
| Device | Emits | Applies |
|
||||||
|
|-------------|----------------------------------------------------|---------------------------------------------|
|
||||||
|
| **PM_K‑1** Kit (touchscreen + joystick) | `play` / `stop` / `bpm` / `sel` / `beat` / `lane` (FULL on structural lane edits) | all of the above |
|
||||||
|
| **PM_X‑1** Explorer (6 buttons, read‑only beats) | `play` / `stop` / `bpm` / `sel` only (no on‑device beat/lane editing) | all of the above |
|
||||||
|
|
||||||
|
Editors don't need to special‑case 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>`; pre‑0.0.23 firmware sends bare version → assume
|
||||||
|
`K`).
|
||||||
|
|
|
||||||
|
|
@ -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)");
|
||||||
|
|
|
||||||
26
editor.html
26
editor.html
|
|
@ -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
169
info-explorer.html
Normal 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 & 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" 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" 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" 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" 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">≈ $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>
|
||||||
|
· <a href="https://github.com/pimoroni/explorer" target="_blank" rel="noopener">vendor code</a>
|
||||||
|
· <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 · web-driven editing via Live sync · MIDI audio · 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 ↓</a>
|
||||||
|
<a class="dl alt" href="https://codeberg.org/VARASYS/metronome/src/branch/main/pico-explorer" target="_blank" rel="noopener">Source + README ↗</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>🔗 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>···</b> menu →
|
||||||
|
<b>📟 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>🎹 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> → <b>Practice log</b>. Plays over 5 s appear (time · BPM · duration · bars).</li>
|
||||||
|
<li><b>Firmware updates:</b> ··· menu → <b>⬆ 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>
|
||||||
|
|
@ -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
74
pico-explorer/README.md
Normal 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
1444
pico-explorer/app.py
Normal file
File diff suppressed because it is too large
Load diff
22
pico-explorer/boot.py
Normal file
22
pico-explorer/boot.py
Normal 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
24
pico-explorer/code.py
Normal 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
|
||||||
3
pico-explorer/programs.json
Normal file
3
pico-explorer/programs.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"setlists": []
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue