diff --git a/.claude/skills/openpocket-flash/SKILL.md b/.claude/skills/openpocket-flash/SKILL.md new file mode 100644 index 0000000..abf3d61 --- /dev/null +++ b/.claude/skills/openpocket-flash/SKILL.md @@ -0,0 +1,82 @@ +--- +name: openpocket-flash +description: Build, dry-run and flash an OpenPocket package to a connected Donner Pocket X or M-VAVE BlackBox from the command line (node tools/op.mjs flash, over USB MIDI, no browser). Use to check that a built package fits the connected pedal, or, only when the captain explicitly instructs it, to write it. Covers the safety rules and the SD-card recovery. +--- + +# Flash a pedal from the command line + +`node tools/op.mjs flash` (or `make flash ARGS="..."`) runs the same update protocol as the browser installer +(`docs/index.html`): the code is `docs/pxota.js` and `docs/pxpkg.js`, shared with the page; `tools/flash.mjs` only adds the +command line. Run everything from the repository root. The command runs on the host, not in the container: the pedal is a USB +device. + +## Safety rules (read first) + +1. **Never flash without the captain's explicit instruction** for this pedal and this package. A task that says "build" or + "test" is not that instruction. Without it, stop after the dry run and report. +2. **Always dry-run first**, and read its output: pedal, model and package must be the ones intended. +3. Never put `--confirm-flash` in a script, alias or default. It is typed for one flash, on the captain's say-so. +4. Keep the pedal plugged in and powered until the command prints `done`. Close the browser installer and any other MIDI + program first; they hold the port. +5. Only one pedal on the bus. The command refuses when none or several answer. +6. A failure prints the recovery pointer. Do not retry in a loop: report the output to the captain. + +## 1. Set up once + +``` +node tools/op.mjs doctor # Node 18+ (Docker only for building) +npm ci --prefix tools # the one dependency, @julusian/midi (prebuilt RtMidi), into tools/node_modules +``` + +On Linux the MIDI library needs ALSA (`libasound2`); the pedal's USB MIDI port must be visible to your user. + +## 2. Build the package + +``` +node tools/op.mjs package # docs/firmware/PocketX_9NN.fwsc, BlackBox_9NN.fwsc, manifest.json, build/SHA256SUMS +``` + +Details and failures: the `openpocket-toolchain` skill. To flash a package built elsewhere (for example a test image from +another branch), keep its `.fwsc` next to the `manifest.json` that was built with it and pass both. + +## 3. Dry run (writes nothing) + +``` +node tools/op.mjs flash --dry-run +node tools/op.mjs flash --dry-run --package build-x/PocketX_925.fwsc --manifest build-x/manifest.json +``` + +It checks the package against the manifest (size, SHA-256, identity, UFW and CRC structure), finds the pedal by asking each +MIDI input for its identity (a read-only query), and refuses a package for the other model. The pedal's flash is not touched. +Success ends with `dry run ok: would update PocketX_923 -> PocketX_924`. + +Refusals (exit 2, nothing sent to the pedal): no `--confirm-flash` and no `--dry-run`; package not listed in the manifest; +SHA-256, size or identity differs from the manifest; wrong model; no pedal; several pedals; pedal already in update mode +(power-cycle it). + +## 4. Flash (only on the captain's explicit instruction) + +``` +node tools/op.mjs flash --confirm-flash [--package FILE] [--manifest FILE] +``` + +Same checks as the dry run, then: the pedal stages its update loader (step 1), the loader writes the app area (step 2), the +pedal restarts, and the command reads the version it reports. `done: the pedal reports PocketX_924.` is success (exit 0); a +version that differs from the package is a failure. It takes about a minute. Report the full output to the captain. + +## 5. Recovery + +A failure (exit 1) prints `FAILED: ...` and the recovery text. In order: + +1. Unplug USB, power-cycle the pedal, run `--dry-run` again to see what it reports. A pedal in update mode (`ota-...`) is + finished by the browser installer's resume button, or by this command after a power-cycle. +2. If it stays dead, the SD-card recovery in the README (`flash/`: fm1buddy on an ESP32-S3; the image comes from your factory + backup with `make appimage DUMP=fm1-1.bin` or `makeimage.bat`). Only the captain does this. + +Never publish or commit a pedal backup (`fm1-1.bin`, `fm1-300.bin`): they hold the official firmware. + +## Tests + +`node tests/test_flash.mjs` (part of `node tools/op.mjs test`) drives the real command against the simulated pedal that runs +the pedal's own `ota.c` and loader code: update, dry run writes nothing, wrong model, damaged package, missing confirmation, +no pedal, two pedals, failure with the recovery text. Real hardware is not covered by any test. diff --git a/.claude/skills/openpocket-toolchain/SKILL.md b/.claude/skills/openpocket-toolchain/SKILL.md index 0e989f1..ea4e58b 100644 --- a/.claude/skills/openpocket-toolchain/SKILL.md +++ b/.claude/skills/openpocket-toolchain/SKILL.md @@ -26,6 +26,7 @@ Run commands from the repository root. | Host tests (engine, preset store, screen, simulated pedal, installer, editor) | `node tools/op.mjs test` | | Remove every build output (needs no Docker) | `node tools/op.mjs clean` | | A shell in the container | `node tools/op.mjs shell` | +| Dry-run or flash a built package to a connected pedal (host, USB MIDI) | `node tools/op.mjs flash`: see the `openpocket-flash` skill | Outputs: `firmware/openpocket.bin`, `docs/firmware/PocketX_9NN.fwsc`, `BlackBox_9NN.fwsc`, `manifest.json`. All of them are gitignored. Two clean builds give identical bytes; a changed checksum means the source or a pin changed. @@ -50,8 +51,9 @@ Follow `references/updating-pins.md`. Never bypass the checksum check. ## Safety rules of this repository -- **Never flash a device from an agent.** No `flash`, no WebUSB or MIDI write to a real pedal. Only the captain installs, - from the browser installer, by hand. Hardware checks are in `docs/MIDI-TEST.md` when present. +- **Never flash a device unless the captain explicitly instructs it.** `node tools/op.mjs flash --dry-run` is allowed; `--confirm-flash` + and any other write to a real pedal only on the captain's say-so, following the `openpocket-flash` skill. Builds and tests never + touch a pedal. Hardware checks are in `docs/MIDI-TEST.md` when present. - **Never publish an unreviewed package as the installer default.** A test package is a separate, labelled file and is not listed in the default `docs/firmware/manifest.json`. `PUBLISH-*.bat` is the maintainer's tool: do not run it. - **Keep the official-firmware analysis out of the repository.** Facts about the vendor's firmware (offsets, tables, diff --git a/.gitignore b/.gitignore index cd0c4ea..1ad7a19 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ fm1-1.bin fm1-300.bin *.dump.bin openpocket.bundle +node_modules/ diff --git a/Makefile b/Makefile index 591ee81..32a0b4e 100644 --- a/Makefile +++ b/Makefile @@ -8,13 +8,16 @@ # make shell a shell in the build container # make image (re)build the container image # make serve the installer and editor on http://localhost:8765/ (offline install) +# make flash ARGS="--dry-run" write a built package to a connected pedal (ARGS="--confirm-flash" to really write) # make render IN=guitar.wav [OUT=out.wav] [PRESET=1] a WAV through the engine # make appimage [DUMP=fm1-1.bin] fm1-300.bin for the SD-card recovery # `make NATIVE=1 build` skips Docker (toolchain installed with firmware/setup.sh). -.PHONY: doctor build test package clean shell image serve render appimage +.PHONY: doctor build test package clean shell image serve render appimage flash doctor build test package clean shell image serve: @node tools/op.mjs $@ render: @node tools/op.mjs render "$(IN)" "$(OUT)" "$(PRESET)" appimage: @node tools/op.mjs appimage "$(DUMP)" +flash: + @node tools/op.mjs flash $(ARGS) diff --git a/README.md b/README.md index 5e58bde..5b781cf 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Written from scratch for the pedal's own chip (JieLi AC791N, two cores), install (the note compresses on a hard attack and blooms back) - **24-bit audio** in and out, guitar read from the left input channel only, like the official firmware (the right channel only carries noise: mixing it in was the constant hiss on high-gain amps) -- **USB update**: the pedal shows up as a USB-MIDI device and takes updates from the [web installer](docs/index.html) +- **USB update**: the pedal shows up as a USB-MIDI device and takes updates from the [web installer](docs/index.html), or from the command line (`make flash`, see [docs/BUILDING.md](docs/BUILDING.md)) Full list of models, presets and controls: [docs/SOUNDS-AND-CONTROLS.md](docs/SOUNDS-AND-CONTROLS.md). Development notes (how the audio path was found, what changed in each version): [docs/AUDIO-SDK.md](docs/AUDIO-SDK.md). diff --git a/docs/BUILDING.md b/docs/BUILDING.md index de42e5f..fea7f82 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -33,6 +33,7 @@ Apple silicon and other ARM machines Docker emulates it (Rosetta or QEMU); you c | `make clean` | remove every build output (on the host; needs neither Docker nor the image) | | `make shell` | a shell in the container (as your user) | | `make serve` | the installer and editor on `http://localhost:8765/` (offline install; needs Python 3 on the host) | +| `make flash ARGS="--dry-run"` | write a built package to a connected pedal over USB MIDI, no browser (`--confirm-flash` really writes; see below) | | `make render IN=guitar.wav [OUT=..] [PRESET=1]` | a DI recording through the engine | | `make appimage DUMP=fm1-1.bin` | `fm1-300.bin` for the SD-card recovery | @@ -43,6 +44,10 @@ Python 3. The `.bat` files `makeimage.bat` and `render.bat` only check that Node is installed and call `tools/op.mjs`. `build.bat` does the same when Node and Docker are there, and otherwise builds in WSL (`build.bat docker` or `build.bat wsl` forces one way). `INSTALL-OFFLINE.bat` needs nothing installed: it serves `docs\` with PowerShell (`tools/serve_offline.ps1`), and falls back to Python (`tools/serve.py`) or WSL only if PowerShell cannot open a port. `tests/test_op.mjs` (part of `make test`) checks the Docker command lines, the friendly errors and `make clean` without Docker, using a fake `docker`. `PUBLISH-GITHUB.bat` and `PUBLISH-SOURCE.bat` (PowerShell `tools/publish_github.ps1`) are the maintainer's release tool and still need Windows; nothing else does. +## Flashing from the command line + +`make flash ARGS="--dry-run"` (or `node tools/op.mjs flash --dry-run`) finds the pedal over USB MIDI and checks the built package without writing; `--confirm-flash` instead of `--dry-run` writes it. It runs on the host (not in the container), speaks the same update protocol as the installer page (`docs/pxota.js`, shared with the page), and checks the package against `docs/firmware/manifest.json` (size, SHA-256, identity), refuses the wrong model, and verifies the version the pedal reports afterwards. `--package FILE --manifest FILE` flash a package built elsewhere. Install its one dependency once with `npm ci --prefix tools`. The rules for agents (dry run first, never flash without the captain's explicit instruction, recovery) are in `.claude/skills/openpocket-flash/SKILL.md`. On failure the command prints the SD-card recovery pointer (README, `flash/`). `tests/test_flash.mjs` runs it against the simulated pedal; no test touches real hardware. + ## What is pinned - The base image `debian:stable-slim`, by digest (`Dockerfile`). diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..5ffd980 --- /dev/null +++ b/docs/package.json @@ -0,0 +1 @@ +{ "type": "module" } diff --git a/docs/pxota.js b/docs/pxota.js index c6758ff..a83ffbe 100644 --- a/docs/pxota.js +++ b/docs/pxota.js @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-only -// OpenPocket — the M-UPGRADE update protocol over WebMIDI, for the Donner Pocket X and the M-VAVE BlackBox. +// OpenPocket — the M-UPGRADE update protocol over a Web MIDI-shaped access object (the installer page passes Web MIDI; tools/midi-node.mjs passes a Node adapter), for the Donner Pocket X and the M-VAVE BlackBox. // Adapted from Felucca's web/fm1ota.js (GPL-3.0, Leo Kuroshita / Hügelton Instruments), used by CLIPPER and SLOOP. // // Step 1: the running firmware (the official one, or OpenPocket's ota.c) reads parts of the package and stages its diff --git a/docs/pxpkg.js b/docs/pxpkg.js index 1b0fbc9..7ce7437 100644 --- a/docs/pxpkg.js +++ b/docs/pxpkg.js @@ -87,3 +87,19 @@ export async function checkOfficial(bytes) { } return { product, model: m[1], known, image, sha256: sha }; } + +// an OpenPocket package against its manifest entry ({ product, size, sha256 }), before anything is sent to a pedal: +// size, SHA-256, identity (Model_9NN) and the UFW structure. Returns { product, model, image } or throws an Error. +export async function verifyPackage(bytes, entry) { + if (bytes.length !== entry.size) throw new Error(`the package is ${bytes.length} bytes, the manifest says ${entry.size}`); + const sha = await sha256hex(bytes); + if (sha !== entry.sha256) throw new Error(`SHA-256 mismatch: the package is ${sha}, the manifest says ${entry.sha256}`); + const product = productOf(bytes); + const m = /^(PocketX|BlackBox)_9\d\d$/.exec(product); + if (!m || product !== entry.product) throw new Error(`the package identity is "${product}", the manifest says ${entry.product}`); + const image = logicalImage(bytes); + const ents = ufwEntries(image); + const fl = ents && ents.find((e) => e.type === 0), ota = ents && ents.find((e) => e.type === 100); + if (!fl || !ota || !fl.crcOk || !ota.crcOk) throw new Error("the package is damaged (UFW / CRC)"); + return { product, model: m[1], image }; +} diff --git a/tests/run_tests.sh b/tests/run_tests.sh index ff82108..11d08f5 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -6,6 +6,7 @@ # - preset store: save / reload / power cut at every step of a save (firmware/px_store.h on a simulated flash) # - packages: tools/pxpkg.py rebuilds the official PocketX_016 app area byte for byte (needs the official file) # - installer: docs/pxota.js + docs/pxpkg.js against a simulated pedal running the real update code (ota.c, ldr_core.c) +# - flasher: tools/flash.mjs (node tools/op.mjs flash) against the same simulated pedal: update, dry run, wrong model, damaged package # - page: docs/index.html in headless Chromium (needs Playwright: npm i playwright && npx playwright install chromium) set -e cd "$(dirname "$0")/.." @@ -23,6 +24,8 @@ $CC -O1 -Wall -Wno-unused-function -o build/devsim tests/devsim.c if [ -n "$1" ]; then echo "== packages"; python3 tests/test_pxpkg.py "$1"; fi echo "== installer" node tests/test_installer.mjs "$1" +echo "== flasher (tools/flash.mjs)" +node tests/test_flash.mjs echo "== build entry point (tools/op.mjs)" node tests/test_op.mjs echo "== editor" diff --git a/tests/simpedal.mjs b/tests/simpedal.mjs new file mode 100644 index 0000000..ba7535f --- /dev/null +++ b/tests/simpedal.mjs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-only +// OpenPocket — a fake WebMIDI access over one simulated pedal (tests/devsim.c), shared by the installer and flasher tests. +import { spawn } from "node:child_process"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SIM = process.env.DEVSIM || join(fileURLToPath(new URL("..", import.meta.url)), "build", "devsim"); + +// a fake WebMIDI access over one simulated pedal: its port pair is renamed when the device re-enumerates +export function simAccess(args) { + const child = spawn(SIM, args, { stdio: ["pipe", "pipe", "inherit"] }); + const log = []; + const access = { inputs: new Map(), outputs: new Map() }; + let port = null, buf = "", readyFn; + const ready = new Promise((r) => { readyFn = r; }); + const mkPorts = (name) => { + if (port) { port.input.state = "disconnected"; port.output.state = "disconnected"; } + access.inputs.clear(); access.outputs.clear(); + if (name === "none") { port = null; return; } + const input = { name, state: "connected", onmidimessage: null, open: async () => {} }; + const output = { name, state: "connected", open: async () => {}, + send: (bytes) => { + if (output.state === "disconnected") throw new Error("disconnected"); + child.stdin.write(Buffer.from(bytes).toString("hex") + "\n"); + } }; + port = { input, output }; + access.inputs.set(name, input); access.outputs.set(name, output); + }; + child.stdout.on("data", (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf("\n")) >= 0) { + const ln = buf.slice(0, i); buf = buf.slice(i + 1); + if (ln.startsWith("#port ")) { mkPorts(ln.slice(6)); readyFn(); } + else if (ln.startsWith("#log ")) log.push(ln.slice(5)); + else if (port && port.input.onmidimessage && ln) port.input.onmidimessage({ data: Uint8Array.from(Buffer.from(ln, "hex")) }); + } + }); + const done = new Promise((r) => child.on("exit", (code) => r(code))); + return { access, log, ready, end: async () => { child.stdin.end(); return done; } }; +} diff --git a/tests/test_flash.mjs b/tests/test_flash.mjs new file mode 100644 index 0000000..a402b20 --- /dev/null +++ b/tests/test_flash.mjs @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: GPL-3.0-only +// OpenPocket — the command-line flasher (tools/flash.mjs, behind `node tools/op.mjs flash`) against the simulated pedal +// (tests/devsim.c: the real ota.c and loader code), plus its Node MIDI adapter against a fake MIDI library. No hardware. +// cc -O1 -o build/devsim tests/devsim.c && node tests/test_flash.mjs +import { execFileSync, spawnSync } from "node:child_process"; +import { copyFileSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { main } from "../tools/flash.mjs"; +import { nodeMidiAccess } from "../tools/midi-node.mjs"; +import { simAccess } from "./simpedal.mjs"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const TMP = mkdtempSync(join(tmpdir(), "opflash-")); +let failed = 0; +const ok = (c, what) => { console.log(`${what.padEnd(70)} ${c ? "ok" : "FAIL"}`); if (!c) failed++; }; +const FAST = { afterUpgrade: 50, afterStep1: 50, loaderWait: 5000, step1Idle: 3000, step2Idle: 5000, afterWrite: 50, + backWait: 5000, perRequest: 0, poll: 50 }; +execFileSync("python3", [join(ROOT, "tests", "fixtures.py"), TMP], { stdio: "inherit" }); + +const man = join(TMP, "manifest.json"), px = join(TMP, "PocketX_924.fwsc"), bb = join(TMP, "BlackBox_924.fwsc"); +const old = join(TMP, "flash_old.bin"); +const appOf = ` +import sys; sys.path.insert(0, sys.argv[2]) +import pocketx_fw as fw +d = fw.flash_decrypt(open(sys.argv[1], 'rb').read()) +e = fw.flash_parse(d) +bad = [k for k, v in fw.flash_checks(d, e).items() if not v] +app = d[0x4000 + e[1]['addr']:0x4000 + e[1]['addr'] + e[1]['len']] +i = app.find(b'OP-PRODUCT:') +print(app[i + 11:i + 31].split(b'\\0')[0].decode(), 'CRC-OK' if not bad else 'CRC-BAD')`; +const appIn = (dump) => execFileSync("python3", ["-c", appOf, dump, join(ROOT, "tools")]).toString().trim(); + +// run the flasher on a simulated pedal; resolves { code, out, log, dump } +async function run(argv, { send, flash = old } = {}) { + const dump = join(TMP, `dump${Math.random().toString(36).slice(2)}.bin`); + const sim = simAccess([flash, "--dump", dump]); + await sim.ready; + if (send) for (const o of sim.access.outputs.values()) { const s = o.send; o.send = (b) => send(b, s); } + const lines = []; + const code = await main(argv, { access: sim.access, say: (l) => lines.push(l), timing: FAST }); + await sim.end(); + return { code, out: lines.join("\n"), log: sim.log, dump: readFileSync(dump) }; +} +const untouched = (r) => r.dump.equals(readFileSync(old)) && !r.log.some((l) => /step[12]/.test(l)); +const notContacted = { inputs: { values() { throw new Error("MIDI was touched"); } }, outputs: { values() { throw new Error("MIDI was touched"); } } }; +const say = (lines) => (l) => lines.push(l); + +// 1. no confirmation: refused before any MIDI +{ + const lines = []; + const code = await main(["--package", px, "--manifest", man], { access: notContacted, say: say(lines), timing: FAST }); + ok(code === 2 && /--confirm-flash/.test(lines.join("\n")), "no --confirm-flash: refused, MIDI never touched"); +} + +// 2. dry run: detects the pedal, checks the package, writes nothing +{ + const r = await run(["--dry-run", "--manifest", man]); + ok(r.code === 0 && /pedal: PocketX_923/.test(r.out) && /PocketX_924\.fwsc.*matches the manifest/.test(r.out) && /dry run ok/.test(r.out), "dry run: pedal found, its model's package checked"); + ok(untouched(r), "dry run: the pedal's flash is unchanged and no update step ran"); + const both = await run(["--dry-run", "--confirm-flash", "--package", px, "--manifest", man]); + ok(both.code === 0 && untouched(both), "--dry-run wins over --confirm-flash"); +} + +// 3. the update: 923 -> 924, version verified afterwards +{ + const r = await run(["--confirm-flash", "--package", px, "--manifest", man]); + ok(r.code === 0 && /done: the pedal reports PocketX_924/.test(r.out), `update 923 -> 924 reports the new version ${r.code ? "\n" + r.out : ""}`); + const f = join(TMP, "after.bin"); writeFileSync(f, r.dump); + ok(appIn(f) === "PocketX_924 CRC-OK", "flash now holds app PocketX_924, every CRC right"); +} + +// 4. wrong model: refused, nothing sent +{ + const r = await run(["--confirm-flash", "--package", bb, "--manifest", man]); + ok(r.code === 2 && /wrong model/.test(r.out) && untouched(r), "BlackBox package on a Pocket X: refused, flash unchanged"); +} + +// 5. damaged or unlisted package: refused before any MIDI +{ + const d2 = join(TMP, "PocketX_925.fwsc"); + const bytes = readFileSync(px); bytes[0x20000] ^= 0x55; + writeFileSync(d2, bytes); + const lines = []; + let code = await main(["--confirm-flash", "--package", d2, "--manifest", man], { access: notContacted, say: say(lines), timing: FAST }); + ok(code === 2 && /not listed/.test(lines.join("\n")), "a package the manifest does not list: refused"); + const bytes2 = readFileSync(px); bytes2[0x20000] ^= 0x55; + const damaged = join(TMP, "damaged"); execFileSync("mkdir", ["-p", damaged]); + writeFileSync(join(damaged, "PocketX_924.fwsc"), bytes2); copyFileSync(man, join(damaged, "manifest.json")); + lines.length = 0; + code = await main(["--confirm-flash", "--package", join(damaged, "PocketX_924.fwsc"), "--manifest", join(damaged, "manifest.json")], { access: notContacted, say: say(lines), timing: FAST }); + ok(code === 2 && /SHA-256 mismatch/.test(lines.join("\n")), "damaged package (one byte): SHA-256 mismatch, MIDI never touched"); +} + +// 6. pedal problems: none, several +{ + const lines = []; + const code = await main(["--dry-run", "--manifest", man], { access: { inputs: new Map(), outputs: new Map() }, say: say(lines), timing: FAST }); + ok(code === 2 && /no OpenPocket-compatible pedal found/.test(lines.join("\n")), "no pedal: a clear error"); + const a = simAccess([old]), b = simAccess([old]); + await a.ready; await b.ready; + for (const p of [...b.access.inputs.values(), ...b.access.outputs.values()]) p.name = "OpenPocket B"; + const both = { inputs: new Map([["a", [...a.access.inputs.values()][0]], ["b", [...b.access.inputs.values()][0]]]), + outputs: new Map([["a", [...a.access.outputs.values()][0]], ["b", [...b.access.outputs.values()][0]]]) }; + lines.length = 0; + const two = await main(["--confirm-flash", "--package", px, "--manifest", man], { access: both, say: say(lines), timing: FAST }); + await a.end(); await b.end(); + ok(two === 2 && /2 pedals answered/.test(lines.join("\n")) && !a.log.concat(b.log).some((l) => /step/.test(l)), "two pedals: refused, nothing written"); +} + +// 7. a failure midway prints the SD-card recovery pointer +{ + const r = await run(["--confirm-flash", "--package", px, "--manifest", man], { send: (bytes, real) => { if (bytes.length > 100) throw new Error("unplugged"); return real(bytes); } }); + ok(r.code === 1 && /FAILED/.test(r.out) && /SD-card/.test(r.out) && /README\.md/.test(r.out), "a failed update exits 1 and points to the SD-card recovery"); +} + +// 8. the Node MIDI adapter over a fake @julusian/midi: ports are listed afresh, SysEx goes both ways +{ + const names = ["Other", "OpenPocket"], sent = [], handlers = []; + const midi = { + Input: class { getPortCount() { return names.length; } getPortName(i) { return names[i]; } ignoreTypes() {} on(_, f) { this.f = f; handlers.push(f); } openPort(i) { this.i = i; } closePort() {} }, + Output: class { getPortCount() { return names.length; } getPortName(i) { return names[i]; } openPort(i) { this.i = i; } sendMessage(m) { sent.push([this.i, m]); } closePort() {} }, + }; + const acc = nodeMidiAccess(midi); + const ins = [...acc.inputs.values()], outs = [...acc.outputs.values()]; + ok(ins.map((p) => p.name).join() === "Other,OpenPocket" && ins[0].state === "connected", "adapter: ports listed by name"); + await ins[1].open(); await outs[1].open(); + let got = null; ins[1].onmidimessage = (e) => { got = e.data; }; + handlers[0](0, [0xF0, 1, 2, 0xF7]); outs[1].send(Uint8Array.from([0xF0, 0xF7])); + ok(got && got.length === 4 && sent.length === 1 && sent[0][0] === 1, "adapter: SysEx received and sent on the right port"); + names.pop(); names.push("OpenPocket Update"); + ok([...acc.inputs.values()].map((p) => p.name).join() === "Other,OpenPocket Update" && ins[1].state === "disconnected", "adapter: a re-enumerated port shows under its new name"); + acc.close(); +} + +// 9. the command line itself: no confirmation, no library needed, no MIDI +{ + const r = spawnSync(process.execPath, [join(ROOT, "tools/op.mjs"), "flash"], { encoding: "utf8" }); + ok(r.status === 2 && /--confirm-flash/.test(r.stdout), "node tools/op.mjs flash (no flag): refused with exit 2"); +} + +console.log(failed ? `\n${failed} FAILED` : "\nall flasher tests passed"); +process.exit(failed ? 1 : 0); diff --git a/tests/test_installer.mjs b/tests/test_installer.mjs index cd7840b..2a23d97 100644 --- a/tests/test_installer.mjs +++ b/tests/test_installer.mjs @@ -4,16 +4,16 @@ // (step 2), on a 1 MiB flash. No browser, no hardware. // cc -O1 -o build/devsim tests/devsim.c && node tests/test_installer.mjs [PocketX_016.fwsc] // The optional official package checks the way back's step 1 (the official loader staged by OpenPocket's ota.c). -import { spawn, execFileSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { mkdtempSync, readFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { Updater, IS_OPENPOCKET } from "../docs/pxota.js"; import { productOf, logicalImage, checkOfficial } from "../docs/pxpkg.js"; +import { simAccess } from "./simpedal.mjs"; const ROOT = fileURLToPath(new URL("..", import.meta.url)); -const SIM = process.env.DEVSIM || join(ROOT, "build", "devsim"); const TMP = mkdtempSync(join(tmpdir(), "opinst-")); let failed = 0; const ok = (c, what) => { console.log(`${what.padEnd(70)} ${c ? "ok" : "FAIL"}`); if (!c) failed++; }; @@ -22,40 +22,6 @@ const FAST = { afterUpgrade: 50, afterStep1: 50, loaderWait: 5000, step1Idle: 30 execFileSync("python3", [join(ROOT, "tests", "fixtures.py"), TMP], { stdio: "inherit" }); -// a fake WebMIDI access over one simulated pedal: its port pair is renamed when the device re-enumerates -function simAccess(args) { - const child = spawn(SIM, args, { stdio: ["pipe", "pipe", "inherit"] }); - const log = []; - const access = { inputs: new Map(), outputs: new Map() }; - let port = null, buf = "", readyFn; - const ready = new Promise((r) => { readyFn = r; }); - const mkPorts = (name) => { - if (port) { port.input.state = "disconnected"; port.output.state = "disconnected"; } - access.inputs.clear(); access.outputs.clear(); - if (name === "none") { port = null; return; } - const input = { name, state: "connected", onmidimessage: null, open: async () => {} }; - const output = { name, state: "connected", open: async () => {}, - send: (bytes) => { - if (output.state === "disconnected") throw new Error("disconnected"); - child.stdin.write(Buffer.from(bytes).toString("hex") + "\n"); - } }; - port = { input, output }; - access.inputs.set(name, input); access.outputs.set(name, output); - }; - child.stdout.on("data", (d) => { - buf += d.toString(); - let i; - while ((i = buf.indexOf("\n")) >= 0) { - const ln = buf.slice(0, i); buf = buf.slice(i + 1); - if (ln.startsWith("#port ")) { mkPorts(ln.slice(6)); readyFn(); } - else if (ln.startsWith("#log ")) log.push(ln.slice(5)); - else if (port && port.input.onmidimessage && ln) port.input.onmidimessage({ data: Uint8Array.from(Buffer.from(ln, "hex")) }); - } - }); - const done = new Promise((r) => child.on("exit", (code) => r(code))); - return { access, log, ready, end: async () => { child.stdin.end(); return done; } }; -} - const pkg = (name) => readFileSync(join(TMP, name)); const flashOf = (f) => readFileSync(f); const py = (code, ...a) => execFileSync("python3", ["-c", code, ...a]).toString().trim(); diff --git a/tools/flash.mjs b/tools/flash.mjs new file mode 100644 index 0000000..87c7612 --- /dev/null +++ b/tools/flash.mjs @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-only +// node tools/op.mjs flash: write a built OpenPocket package to a connected pedal over USB MIDI, with no browser. +// The protocol is docs/pxota.js and the package checks are docs/pxpkg.js, the very modules the installer page runs; +// this file only adds the command line: arguments, the checks that come first, and the report. +// flash [--dry-run | --confirm-flash] [--package FILE] [--manifest FILE] +import { readFileSync } from "node:fs"; +import { webcrypto } from "node:crypto"; +import { basename, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Updater, IS_OTA, MODELS } from "../docs/pxota.js"; +import { verifyPackage } from "../docs/pxpkg.js"; + +globalThis.crypto ??= webcrypto; // Node 18 has no global; the page's browser does + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const USAGE = `usage: node tools/op.mjs flash --dry-run [--package FILE] [--manifest FILE] + node tools/op.mjs flash --confirm-flash [--package FILE] [--manifest FILE] + --dry-run find the pedal and check the package; write nothing + --confirm-flash really write the package to the pedal (required: there is no default) + --package FILE the .fwsc to write (default: the manifest's package for the pedal's model) + --manifest FILE the manifest that lists the package and its SHA-256 (default: docs/firmware/manifest.json)`; +const RECOVERY = `Recovery: if the pedal does not start, power-cycle it and run this again. If it stays dead, use the SD-card +recovery (README.md, "Install": the \`flash/\` folder, fm1buddy on an ESP32-S3, \`make appimage DUMP=fm1-1.bin\` or makeimage.bat).`; +const TIMING = { afterUpgrade: 2000, afterStep1: 3000, loaderWait: 30000, step1Idle: 8000, step2Idle: 180000, afterWrite: 3000, backWait: 40000, perRequest: 10, poll: 1000 }; + +class Refusal extends Error {} // a check that came first: nothing was sent to the pedal +const refuse = (msg) => new Refusal(msg); + +function parse(argv) { + const a = { dryRun: false, confirm: false, package: null, manifest: resolve(ROOT, "docs/firmware/manifest.json") }; + for (let i = 0; i < argv.length; i++) { + const v = argv[i]; + if (v === "--dry-run") a.dryRun = true; + else if (v === "--confirm-flash") a.confirm = true; + else if (v === "--package" || v === "--manifest") { + if (i + 1 >= argv.length) throw refuse(`${v} needs a file`); + a[v.slice(2)] = resolve(argv[++i]); + } else throw refuse(`unknown argument ${v}`); + } + return a; +} + +// the manifest entry for a package file (by its name) or for a model +function manifestEntry(manifestPath, { file, model }) { + let man; + try { man = JSON.parse(readFileSync(manifestPath, "utf8")); } catch (e) { throw refuse(`cannot read the manifest ${manifestPath}: ${e.message}`); } + const entry = (man.packages || []).find((p) => (file ? p.file === file : p.model === model)); + if (!entry) throw refuse(file ? `${file} is not listed in ${manifestPath}: refusing an unlisted package` : `${manifestPath} has no package for ${model}`); + return entry; +} + +async function loadPackage(manifestPath, { file, model }) { + const entry = manifestEntry(manifestPath, { file: file && basename(file), model }); + const path = file || resolve(dirname(manifestPath), entry.file); + let bytes; + try { bytes = new Uint8Array(readFileSync(path)); } catch (e) { throw refuse(`cannot read the package ${path}: ${e.message}`); } + try { return { ...(await verifyPackage(bytes, entry)), path, sha256: entry.sha256 }; } catch (e) { throw refuse(`${basename(path)}: ${e.message}`); } +} + +// the one pedal on the bus, or an error that says why not. Each MIDI input is asked alone (read-only identity query). +async function detect(access, timing) { + const found = []; + const outputs = access.outputs; + for (const input of [...access.inputs.values()]) { + if (input.state === "disconnected") continue; + const probe = new Updater({ inputs: new Map([[input.name, input]]), outputs }, null, timing); + const d = await probe.identify(); + if (d && (MODELS.includes(d.id.model) || IS_OTA(d.id))) found.push(d); + } + const seen = new Updater(access, null, timing).seen(); + if (!found.length) throw refuse(`no OpenPocket-compatible pedal found${seen}. Plug the pedal in over USB, close the browser installer and any MIDI program, and try again.`); + if (found.length > 1) throw refuse(`${found.length} pedals answered (${found.map((d) => `${d.id.text} on "${d.name}"`).join(", ")}). Connect only one.`); + return found[0]; +} + +const WHY = { + notfound: "the pedal was not found", model: "wrong model", stopped: "the transfer stopped", noloader: "the update loader did not appear", + lost: "the pedal was disconnected", noreturn: "the pedal did not come back", mismatch: "the pedal reports another version", + badreq: "the pedal asked for data outside the package", foreign: "the pedal is in another firmware's update mode", +}; + +// main(argv, { access, say, timing }): the exit code. access defaults to the real USB MIDI ports. +export async function main(argv, { access, say = console.log, timing } = {}) { + let node = null; + try { + const args = parse(argv); + if (!args.dryRun && !args.confirm) throw refuse(`this writes to the pedal; add --confirm-flash to do it, or --dry-run to only check.\n${USAGE}`); + const mode = args.dryRun ? "dry run: nothing will be written" : "FLASH"; + say(`flash (${mode})`); + + // the package first, when named: a damaged one never reaches the pedal, not even a query + let pkg = args.package ? await loadPackage(args.manifest, { file: args.package }) : null; + if (pkg) say(`package ${basename(pkg.path)}: ${pkg.product}, SHA-256 ${pkg.sha256.slice(0, 16)}… matches the manifest`); + + if (!access) { + const midi = await import("@julusian/midi").then((m) => m.default || m, () => { throw refuse("the MIDI library is not installed: run `npm ci --prefix tools` once (docs/BUILDING.md)."); }); + node = (await import("./midi-node.mjs")).nodeMidiAccess(midi); + access = node; + } + const t = { ...TIMING, ...timing }; + const dev = await detect(access, t); + say(`pedal: ${dev.id.text} on "${dev.name}"`); + if (IS_OTA(dev.id)) throw refuse(`the pedal is in update mode (${dev.id.text}), not running firmware. Power-cycle it, then try again; the installer page can also finish an interrupted update.`); + + if (!pkg) { pkg = await loadPackage(args.manifest, { model: dev.id.model }); say(`package ${basename(pkg.path)}: ${pkg.product}, SHA-256 ${pkg.sha256.slice(0, 16)}… matches the manifest`); } + if (pkg.model !== dev.id.model) throw refuse(`wrong model: the pedal is a ${dev.id.text}, the package is ${pkg.product}. Nothing was sent.`); + + if (args.dryRun) { say(`dry run ok: would update ${dev.id.text} -> ${pkg.product}. Nothing was written.`); return 0; } + + say(`updating ${dev.id.text} -> ${pkg.product}: do not unplug the pedal`); + const step = (s, n) => { + if (s === "verify" || s === "write") { if (n % 250 === 0) say(` ${s}: ${n} requests`); } + else say(` ${s}`); + }; + const reported = await new Updater(access, null, t).install(pkg.image, pkg.product, step); + if (reported !== pkg.product) throw new Error(`the pedal reports ${reported}, expected ${pkg.product}`); + say(`done: the pedal reports ${reported}.`); + return 0; + } catch (e) { + if (e instanceof Refusal) { say(`REFUSED: ${e.message}`); return 2; } + say(`FAILED: ${WHY[e.code] || "error"}: ${e.message}${e.detail ? ` [${e.detail}]` : ""}`); + say(RECOVERY); + return 1; + } finally { + if (node) node.close(); + } +} diff --git a/tools/midi-node.mjs b/tools/midi-node.mjs new file mode 100644 index 0000000..8f0247e --- /dev/null +++ b/tools/midi-node.mjs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-only +// The Node side of the flasher's MIDI transport: a Web MIDI shaped access ({ inputs, outputs }, the shape docs/pxota.js's +// Updater takes) over @julusian/midi (RtMidi: CoreMIDI, ALSA, WinMM). Ports are listed afresh on every values() call, +// because the pedal re-enumerates under another name when it enters its update loader. + +export function nodeMidiAccess(midi) { + const probes = { in: new midi.Input(), out: new midi.Output() }; + const live = { in: new Map(), out: new Map() }; // key -> port, for the ports open now + + const names = (probe) => Array.from({ length: probe.getPortCount() }, (_, i) => probe.getPortName(i)); + + // key = name, or name#n for the n-th port that shares it + function keyed(list) { + const seen = new Map(); + return list.map((name) => { const n = seen.get(name) || 0; seen.set(name, n + 1); return { name, key: n ? `${name}#${n}` : name }; }); + } + + function makePort(kind, name, key) { + const present = () => keyed(names(probes[kind])).some((p) => p.key === key); + const index = () => keyed(names(probes[kind])).findIndex((p) => p.key === key); + let dev = null; + const port = { + name, + get state() { return present() ? "connected" : "disconnected"; }, + async open() { + if (dev) return; + const i = index(); + if (i < 0) throw new Error(`MIDI port ${name} is gone`); + if (kind === "in") { + dev = new midi.Input(); + dev.ignoreTypes(false, true, true); // SysEx on, clock and active sensing off + dev.on("message", (_dt, msg) => { if (port.onmidimessage) port.onmidimessage({ data: Uint8Array.from(msg) }); }); + } else { + dev = new midi.Output(); + } + dev.openPort(i); + }, + send: (bytes) => dev.sendMessage(Array.from(bytes)), + dispose() { if (dev) { try { dev.closePort(); } catch (_) { /* already gone */ } dev = null; } }, + }; + if (kind === "in") port.onmidimessage = null; + return port; + } + + function refresh(kind) { + const now = keyed(names(probes[kind])); + const map = live[kind]; + for (const [key, port] of map) if (!now.some((p) => p.key === key)) { port.dispose(); map.delete(key); } + for (const p of now) if (!map.has(p.key)) map.set(p.key, makePort(kind, p.name, p.key)); + return map; + } + + return { + inputs: { values: () => refresh("in").values() }, + outputs: { values: () => refresh("out").values() }, + close() { for (const kind of ["in", "out"]) for (const p of live[kind].values()) p.dispose(); }, + }; +} diff --git a/tools/op.mjs b/tools/op.mjs index 5159b78..981bb0f 100644 --- a/tools/op.mjs +++ b/tools/op.mjs @@ -3,6 +3,7 @@ // The one host-side entry point of the OpenPocket build (macOS, Linux, Windows; Node 18+). The Makefile and the // .bat files only call this. Every Docker command line lives here. // node tools/op.mjs doctor | build | test | package | clean | shell | image | serve [editor|build] +// node tools/op.mjs flash --dry-run | --confirm-flash [--package FILE] (writes to a connected pedal: docs/BUILDING.md) // node tools/op.mjs render in.wav [out.wav] [preset] | node tools/op.mjs appimage [fm1-1.bin] // NATIVE=1 runs the commands on this machine (toolchain from firmware/setup.sh) instead of in the container. import { spawnSync } from "node:child_process"; @@ -83,6 +84,9 @@ const commands = { return 0; }, shell: () => inContainer(["bash"], { tty: true }), + async flash(args) { // runs on this machine: the pedal is a USB device, not in the container + return (await import("./flash.mjs")).main(args); + }, serve([arg]) { if (arg === "build" || !existsSync(resolve(root, "docs/firmware/manifest.json"))) { const built = commands.build(); @@ -110,4 +114,4 @@ const commands = { const [name, ...rest] = process.argv.slice(2); if (!Object.hasOwn(commands, name)) fail(`usage: node tools/op.mjs ${Object.keys(commands).join(" | ")}`); -process.exit(commands[name](rest)); +process.exit(await commands[name](rest)); diff --git a/tools/package-lock.json b/tools/package-lock.json new file mode 100644 index 0000000..7298f06 --- /dev/null +++ b/tools/package-lock.json @@ -0,0 +1,54 @@ +{ + "name": "openpocket-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openpocket-tools", + "license": "GPL-3.0-only", + "dependencies": { + "@julusian/midi": "3.8.1" + } + }, + "node_modules/@julusian/midi": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@julusian/midi/-/midi-3.8.1.tgz", + "integrity": "sha512-T+Ecn2pWTFu0G81PUa64Tk7yqzS6KlW61BKIaWVCBeXKXtXQ8ARgn6NcqmH6kABOM3FA8DV7XaPcw7VDBtgxKQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^6.1.0", + "pkg-prebuilds": "^1.1.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15" + } + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, + "node_modules/pkg-prebuilds": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pkg-prebuilds/-/pkg-prebuilds-1.1.0.tgz", + "integrity": "sha512-jyai+KTQ2OwbN6iRYw88XbYOMgtpoSYJpjYebx7d9ihqz3txNi3ucsBt3va0iVWe6svSlaqpijMHFF/eJCMZzg==", + "license": "MIT", + "bin": { + "pkg-prebuilds-copy": "bin/copy.mjs", + "pkg-prebuilds-verify": "bin/verify.mjs" + }, + "engines": { + "node": ">= 14.15.0" + } + }, + "node_modules/tslib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "license": "0BSD" + } + } +} diff --git a/tools/package.json b/tools/package.json new file mode 100644 index 0000000..acf0a6a --- /dev/null +++ b/tools/package.json @@ -0,0 +1,9 @@ +{ + "name": "openpocket-tools", + "private": true, + "description": "Host dependencies of tools/op.mjs flash (the command-line flasher). Nothing else in the repository needs npm.", + "license": "GPL-3.0-only", + "dependencies": { + "@julusian/midi": "3.8.1" + } +}