Phase 2: editor 'Save/Load to device' + bundle the editor on the drive

Add to the editor's set-list ⋯ menu:
  - 📟 Save to device — writes the active set list as programs.json (the same file the
    PM_K-1 firmware reads). Uses the File System Access API to write straight onto the
    CIRCUITPY drive (Chrome/Edge); falls back to a download to drag on. Reuses
    setupToPatch() per item -> {title, programs:[{name, prog}]}.
  - 📥 Load from device — reads a programs.json back into a new set list (patchToSetup
    per item; reuses the existing import path).
Bundle the built editor.html into pm_k1_circuitpy.zip so the drive carries its own
offline programmer. info-kit + pico-cp/README document the workflow.

Verified: editor loads with no console errors; both menu buttons + all four functions
present; zip contains editor.html. (FSA save needs a real user gesture to test on-device.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-28 22:20:35 -05:00
parent 3664b7a29e
commit d558dccbde
4 changed files with 64 additions and 5 deletions

View file

@ -41,5 +41,6 @@ import zipfile # PM_K-1 CircuitPython drive bundle (download → unzip onto CIR
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", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin", "README.md"): for f in ("code.py", "programs.json", "font_s.bin", "font_m.bin", "font_l.bin", "README.md"):
z.write("pico-cp/" + f, f) z.write("pico-cp/" + f, f)
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")
PY PY

View file

@ -347,6 +347,8 @@
<button id="exportBtn">⭳ Export all (file)</button> <button id="exportBtn">⭳ Export all (file)</button>
<button id="importBtn">⭱ Import file…</button> <button id="importBtn">⭱ Import file…</button>
<input type="file" id="importFile" accept="application/json" style="display:none"> <input type="file" id="importFile" accept="application/json" style="display:none">
<button id="saveDeviceBtn" title="Write this set list to a PM_K-1 device drive (programs.json)">📟 Save to device</button>
<button id="loadDeviceBtn" title="Load a device's programs.json into a new set list">📥 Load from device</button>
<button id="clearLogBtn">🗑 Clear log</button> <button id="clearLogBtn">🗑 Clear log</button>
<button id="resetAllBtn" style="color:#ff7b6b">♻ Reset everything…</button> <button id="resetAllBtn" style="color:#ff7b6b">♻ Reset everything…</button>
</div> </div>
@ -1069,6 +1071,50 @@ function shareSetlist() {
openShare("Share “" + (sl.title || "set list") + "”", shareLink("sl=" + setlistToCode(sl)), "Shares the whole set list (each item's settings)."); openShare("Share “" + (sl.title || "set list") + "”", shareLink("sl=" + setlistToCode(sl)), "Shares the whole set list (each item's settings).");
} }
/* Device (PM_K-1) programs.json — the same grooves the firmware reads.
Save: writes the active set list straight onto the CIRCUITPY drive (File System Access,
Chrome/Edge) or downloads it to drag on. Load: reads a programs.json into a new set list. */
function programsJSON() {
const sl = getSL(); if (!sl) return null;
return JSON.stringify({ title: sl.title || "PolyMeter", programs: sl.items.map((it) => ({ name: it.name, prog: setupToPatch(it) })) }, null, 2);
}
async function saveToDevice() {
const json = programsJSON(); if (!json) return alert("No set list selected to save.");
if (window.showSaveFilePicker) {
try {
const h = await showSaveFilePicker({ suggestedName: "programs.json",
types: [{ description: "PolyMeter programs", accept: { "application/json": [".json"] } }] });
const w = await h.createWritable(); await w.write(json); await w.close();
return alert("Saved programs.json — pick your CIRCUITPY drive and the device auto-reloads with these grooves.");
} catch (e) { if (e.name === "AbortError") return; } // cancelled or unsupported → fall through to download
}
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([json], { type: "application/json" }));
a.download = "programs.json"; a.click(); URL.revokeObjectURL(a.href);
alert("Downloaded programs.json — drag it onto the device's CIRCUITPY drive (it auto-reloads).");
}
function importPrograms(text) {
try {
const d = JSON.parse(text);
const items = (d.programs || []).map((p) => ({ name: p.name || "Item", ...patchToSetup(p.prog) }));
if (!items.length) return alert("No programs found in that file.");
setlists.push({ title: d.title || "Device", description: "", items }); activeSL = setlists.length - 1; activeItem = 0;
saveSetlists(); renderSetlists(); applySetup(items[0]);
alert("Loaded " + items.length + " grooves from the device into a new set list.");
} catch (e) { alert("Load failed: " + e.message); }
}
async function loadFromDevice() {
if (window.showOpenFilePicker) {
try {
const [h] = await showOpenFilePicker({ types: [{ description: "PolyMeter programs", accept: { "application/json": [".json"] } }] });
return importPrograms(await (await h.getFile()).text());
} catch (e) { if (e.name === "AbortError") return; }
}
const inp = document.createElement("input"); inp.type = "file"; inp.accept = ".json,application/json";
inp.onchange = () => { if (inp.files[0]) inp.files[0].text().then(importPrograms); };
inp.click();
}
// Apply a shared link on load. Returns true if it set the metronome state. // Apply a shared link on load. Returns true if it set the metronome state.
function applyHashShare() { function applyHashShare() {
const h = location.hash || ""; const h = location.hash || "";
@ -1292,6 +1338,8 @@ $("shortcutsOverlay").addEventListener("click", (e) => { if (e.target.id === "sh
$("exportBtn").addEventListener("click", () => { $("trayMenu").hidden = true; exportAll(); }); $("exportBtn").addEventListener("click", () => { $("trayMenu").hidden = true; exportAll(); });
$("importBtn").addEventListener("click", () => { $("trayMenu").hidden = true; $("importFile").click(); }); $("importBtn").addEventListener("click", () => { $("trayMenu").hidden = true; $("importFile").click(); });
$("importFile").addEventListener("change", (e) => { if (e.target.files[0]) importAll(e.target.files[0]); e.target.value = ""; }); $("importFile").addEventListener("change", (e) => { if (e.target.files[0]) importAll(e.target.files[0]); e.target.value = ""; });
$("saveDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; saveToDevice(); });
$("loadDeviceBtn").addEventListener("click", () => { $("trayMenu").hidden = true; loadFromDevice(); });
$("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); }); $("clearLogBtn").addEventListener("click", () => { $("trayMenu").hidden = true; clearLog(); });
$("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); }); $("resetAllBtn").addEventListener("click", () => { $("trayMenu").hidden = true; resetAll(); });
$("shareSettingsBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSettings(); }); $("shareSettingsBtn").addEventListener("click", () => { $("trayMenu").hidden = true; shareSettings(); });

View file

@ -133,9 +133,10 @@
<summary>CircuitPython edition — USB drive + editor (experimental)</summary> <summary>CircuitPython edition — USB drive + editor (experimental)</summary>
<div class="spec-body"> <div class="spec-body">
<p class="sub">An alternative firmware that makes the Pico mount as a <b>USB drive</b> carrying the <p class="sub">An alternative firmware that makes the Pico mount as a <b>USB drive</b> carrying the
firmware and your tracks (<code>programs.json</code>) — edit on the web and reprogram it without firmware, your tracks (<code>programs.json</code>) and a copy of this editor — design grooves on the
Thonny. Coming next: oneclick "Save to device" and USBMIDI audio out to your computer's speakers. web and write them straight to the device, no Thonny. A full lanes/pads display with antialiased
The MicroPython firmware above stays the simple, rocksolid option.</p> text drives the touchscreen. The MicroPython firmware above stays the simple, rocksolid option.
(USBMIDI audio out to your computer's speakers is the next step.)</p>
<p> <p>
<a class="dl" href="/pm_k1_circuitpy.zip" download>Download CircuitPython bundle ↓</a> <a class="dl" href="/pm_k1_circuitpy.zip" download>Download CircuitPython bundle ↓</a>
<a class="dl alt" href="https://codeberg.org/VARASYS/metronome/src/branch/main/pico-cp" target="_blank" rel="noopener">Source + README ↗</a> <a class="dl alt" href="https://codeberg.org/VARASYS/metronome/src/branch/main/pico-cp" target="_blank" rel="noopener">Source + README ↗</a>
@ -143,8 +144,11 @@
<ol class="steps"> <ol class="steps">
<li>Flash <b>CircuitPython</b> (<a href="https://circuitpython.org/board/raspberry_pi_pico/" target="_blank" rel="noopener">raspberry_pi_pico</a>) <li>Flash <b>CircuitPython</b> (<a href="https://circuitpython.org/board/raspberry_pi_pico/" target="_blank" rel="noopener">raspberry_pi_pico</a>)
via BOOTSEL → the <code>CIRCUITPY</code> drive appears.</li> via BOOTSEL → the <code>CIRCUITPY</code> drive appears.</li>
<li>Unzip the bundle onto <code>CIRCUITPY</code> (<code>code.py</code> + <code>programs.json</code>) — it's a normal drive, just drag them on.</li> <li>Unzip the bundle onto <code>CIRCUITPY</code> (it's a normal drive — just drag everything on). It runs on boot.</li>
<li>It runs on boot; editing <code>programs.json</code> autoreloads the grooves. See the bundle's README for calibration flags.</li> <li><b>Reprogram it from the web:</b> build a set list in the <a href="/editor.html">editor</a>, then the
setlist <b></b> menu → <b>📟 Save to device</b> → pick the <code>CIRCUITPY</code> drive. The Pico
autoreloads with your grooves. (In Chrome/Edge it writes straight to the drive; otherwise it
downloads <code>programs.json</code> to drag on.) <b>📥 Load from device</b> reads it back.</li>
</ol> </ol>
</div> </div>
</details> </details>

View file

@ -19,6 +19,7 @@ same programstring language as <https://metronome.varasys.io>.
- `code.py` (this firmware — runs on boot) - `code.py` (this firmware — runs on boot)
- `programs.json` (your grooves) - `programs.json` (your grooves)
- `font_s.bin`, `font_m.bin`, `font_l.bin` (the antialiased fonts — kept as files to save RAM) - `font_s.bin`, `font_m.bin`, `font_l.bin` (the antialiased fonts — kept as files to save RAM)
- `editor.html` (an offline copy of the web editor, so the drive carries its own programmer)
3. It starts immediately. Editing `programs.json` (or resaving it from the editor) makes CircuitPython 3. It starts immediately. Editing `programs.json` (or resaving it from the editor) makes CircuitPython
**autoreload** with the new tracks. **autoreload** with the new tracks.
@ -38,6 +39,11 @@ same programstring language as <https://metronome.varasys.io>.
Each `prog` is a program string from the web editor. Add/replace entries and save — the device reloads. Each `prog` is a program string from the web editor. Add/replace entries and save — the device reloads.
**Easiest way to (re)program it:** in the editor (the web app, or the `editor.html` on the drive), build a
set list, then the setlist **⋯** menu → **📟 Save to device** → pick the `CIRCUITPY` drive. In Chrome/Edge it
writes `programs.json` straight onto the drive (the Pico autoreloads); elsewhere it downloads the file to drag
on. **📥 Load from device** reads a `programs.json` back into a new set list.
## Calibration (flip flags at the top of `code.py`) ## Calibration (flip flags at the top of `code.py`)
- **Red/blue swapped:** flip `MADCTL` between `0x48` (default) and `0x40`. - **Red/blue swapped:** flip `MADCTL` between `0x48` (default) and `0x40`.