Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .claude/skills/openpocket-flash/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions .claude/skills/openpocket-toolchain/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ fm1-1.bin
fm1-300.bin
*.dump.bin
openpocket.bundle
node_modules/
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
5 changes: 5 additions & 0 deletions docs/BUILDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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`).
Expand Down
1 change: 1 addition & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "type": "module" }
2 changes: 1 addition & 1 deletion docs/pxota.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 16 additions & 0 deletions docs/pxpkg.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
3 changes: 3 additions & 0 deletions tests/run_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")/.."
Expand All @@ -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"
Expand Down
41 changes: 41 additions & 0 deletions tests/simpedal.mjs
Original file line number Diff line number Diff line change
@@ -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; } };
}
Loading
Loading