diff --git a/.claude/skills/openpocket-flash/SKILL.md b/.claude/skills/openpocket-flash/SKILL.md new file mode 100644 index 0000000..16a8dc4 --- /dev/null +++ b/.claude/skills/openpocket-flash/SKILL.md @@ -0,0 +1,100 @@ +--- +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. + +## 4b. A pedal that restarts in a loop (only on the captain's explicit instruction) + +``` +node tools/op.mjs flash --rescue --confirm-flash [--package FILE] [--manifest FILE] [--timeout SECONDS] +make flash-rescue ARGS="--confirm-flash" +``` + +The installer page's rescue mode, on the command line. Start it first, then plug the pedal in or let it restart. It polls every +MIDI port every 100 ms; the moment the pedal answers its identity query it sends the upgrade command (which stops the sound +processing, so the restarts stop) and installs the package of the pedal's model. `--package` pins one package: a pedal of +the other model is refused before anything is sent. Without `--package` the manifest's package for the pedal's model is used. +It waits `--timeout` seconds (default 180); an install already running is never cut short. A pedal that vanishes before the +update finished has had nothing written: it is waited for again. `--rescue --dry-run` waits for the pedal and checks the model, +writing nothing. Reached states: the running firmware's MIDI port, and an OpenPocket update loader left half-done (finished only with `--package`: a loader does not report its model; a foreign loader is refused). Not reached +(same as the page): a firmware that crashes before it answers, and the chip's mask-ROM loader `WL80UBOOT` (USB 4C4A:8057, no +MIDI); both need the SD-card recovery. Timeout is exit 1 with the recovery text. One pedal on the bus: the first to answer is used. + +## 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, and `--rescue` against a pedal that restarts in a loop (answers on the +Nth restart, never answers, vanishes mid-transfer, wrong model, missing confirmation). Real hardware is not covered by any test. diff --git a/.claude/skills/openpocket-simulator/SKILL.md b/.claude/skills/openpocket-simulator/SKILL.md new file mode 100644 index 0000000..d4f7b2c --- /dev/null +++ b/.claude/skills/openpocket-simulator/SKILL.md @@ -0,0 +1,74 @@ +--- +name: openpocket-simulator +description: Start and drive the hosted OpenPocket simulated pedal (node tools/op.mjs sim, make sim): the firmware's real screen, editor protocol and preset store running natively behind a local HTTP API, with a code-drawn front panel of the pedal as its page. Use to press or hold footswitches and buttons, turn the knobs, read the screen as a PNG, read the pedal state, send SysEx, dump or reset the flash, or to use the installer and editor pages against a simulated pedal in a browser. Use for verifying UI, editor-protocol or preset-store changes without a pedal, and for trying a preset or a screen. Use to hear a preset through bundled guitar loops, a file or a computer input. Not for USB or Bluetooth (not simulated). +--- + +# OpenPocket simulator + +The pedal's own `ui.h`, `px_editor.h` and `px_store.h` run natively (`tests/edsim.c`) behind HTTP. Full reference: +`docs/SIMULATOR.md` (every endpoint, the state fields, what is not simulated). Run from the repository root. Needs Node 18+ +and a C compiler (`node tools/op.mjs doctor` says whether `cc` is there); no Docker. + +## Start + +``` +node tools/op.mjs sim --port 0 --reset & # prints: OpenPocket simulator (pedal) listening on http://127.0.0.1:PORT/ +``` + +`--port 0` picks a free port: read the URL from the first line of output. Without `--port` it is 8766. The flash lives in +`build/sim/flash.bin` (`--flash FILE`); without `--reset` a session continues where it stopped. The first start compiles +the simulator (a few seconds). Stop it with Ctrl-C or by killing the process **you** started. + +## Drive (with `U=http://127.0.0.1:PORT`) + +| Do | Request | +| --- | --- | +| A footswitch or button | `curl -X POST $U/api/press/NAME`: `fs_up` `fs_dn` `fx` `home` `push` `mod` `amp` `cab` `dly` `rev`; `push_long` (SAVE), `dpush` (MOVE), `home_long` (tuner) | +| Hold a button | `curl -X POST $U/api/down/NAME`, later `$U/api/up/NAME`: the same ten names, timed as the firmware does (HOME 0.7 s, PUSH 0.8 s, double click 350 ms) | +| Turn the knob | `curl -X POST $U/api/knob/3` (negative: other way; `?fast` for steps of 5) | +| Volume, battery, tuner pitch | `POST $U/api/volume/55`, `/api/battery/3640[/USB_MV]`, `/api/tuner/110` | +| Bypass, load in the status text | `POST $U/api/bypass/1`, `/api/load/17` | +| Read the state | `curl -s $U/api/state` (slot, name, mode, focused block, the whole sound, `frame`, `held`, `knob`) | +| Read the screen | `curl -s "$U/api/screen.png?scale=2" -o screen.png`, then look at it; `screen.rgb565` is the raw pixels | +| SysEx | `curl -s -X POST $U/api/sysex -d 'f0 7d 4f 50 01 f7'` returns `{"reply":"hex"}` (`docs/EDITOR-PROTOCOL.md`) | +| Dump / reset the flash | `curl -s $U/api/flash -o flash.bin`; `curl -X POST $U/api/reset` | + +Every request is applied before it is answered. The pedal's pages: `chain` (PLAY) -> `block` -> `edit`; `push_long` opens +`save` (knob picks the slot, `push` saves); `home_long` opens the tuner, any control leaves it. A worked path to change a +setting: `press/amp`, `knob/2`, `press/push`, `knob/8`, then `state` shows `preset.p[5][0]` up by 8 and `dirty: 1`. +Prove a screen claim by reading `screen.png`, not only the state. + +## Browser + +`$U/` is the pedal's front panel (`tools/sim/pedal.html`) with the live screen in its LCD: press and hold the buttons and +footswitches, drag or wheel the knobs, click PARA for PUSH, or use the keys listed on the page. Scripts on the page have +`window.pedal` (`down`, `up`, `tap`, `turn`, `master`, `state`); a control is `[data-control=NAME]`, `#master` or `#para`. +`$U/plain` is the plain page with the screen, every control as a button, the battery, the tuner pitch and the state. +`$U/site/index.html` (installer) and `$U/site/editor.html` +(editor) are `docs/` with a virtual Web MIDI injected, so Connect finds the simulated pedal in any browser; drive them with +`chrome-devtools-axi` or Playwright. For the installer use the update flow instead of the pedal: +`node tools/op.mjs sim --update FLASH.bin` (an image such as `flash_old.bin` from `python3 tests/fixtures.py DIR`). + +## Sound + +The skin's Sound section runs the real engine (`engine/px_dsp.c`, built to `tools/sim/px_engine.wasm`) in an AudioWorklet: +pick a bundled loop (`assets/loops/`), a dropped file or a device, press Play (the first click starts the audio; a device +starts muted), and the preset on the screen is what you hear. `window.pedal.audio()` reports it (`running`, `heard`, +`muted`, ...), and `#in-meter`, `#out-meter` carry `data-db`. Without a browser, `node tools/sim/render.mjs OUTDIR [preset]` +renders the loops to WAV. After any change to `engine/`, run `node tools/op.mjs wasm` (Docker) and commit the new binary: +`tests/test_audio.mjs` fails otherwise. `docs/SIMULATOR.md` ("The sound") has the reasons and limits. + +## Limits (say so when verifying) + +No LEDs or bypass footswitch (the firmware has none: the skin's backlights are +its own reading of the state), no USB or Bluetooth. `press/NAME` has no timing, `down` and `up` do. The panel's TUNER line +is the stock firmware's: in OpenPocket both footswitches are two presses, and the tuner is a long press of HOME. +`press()` and the `button_*` functions in `tests/edsim.c` mirror the control dispatch of `firmware/openpocket.c`: if that +loop changes, change them too. The proof is the firmware's real code on a PC, not the pedal. + +## Tests + +`node tests/test_sim.mjs` (also in `node tools/op.mjs test`) covers the API, the flash file and the shim. +`node tests/test_pedal_input.mjs` covers the skin's input mapping, and `node tests/test_pedal_page.mjs OUTDIR` drives the skin +in Chromium and saves screenshots (needs Playwright). `node tests/test_audio.mjs` covers the engine and the loops, and +`node tests/test_audio_page.mjs OUTDIR` the Sound section in Chromium with a fake audio input (needs Playwright). diff --git a/.claude/skills/openpocket-toolchain/SKILL.md b/.claude/skills/openpocket-toolchain/SKILL.md index 0e989f1..9c708ac 100644 --- a/.claude/skills/openpocket-toolchain/SKILL.md +++ b/.claude/skills/openpocket-toolchain/SKILL.md @@ -24,8 +24,10 @@ Run commands from the repository root. | Build the firmware, the update loader and the installer packages | `node tools/op.mjs build` | | Build, then write `build/SHA256SUMS` | `node tools/op.mjs package` | | Host tests (engine, preset store, screen, simulated pedal, installer, editor) | `node tools/op.mjs test` | +| The simulated pedal on a local port (host `cc`, no Docker; skill `openpocket-simulator`) | `node tools/op.mjs sim` | | 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 +52,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/.claude/skills/verify-pedal/SKILL.md b/.claude/skills/verify-pedal/SKILL.md new file mode 100644 index 0000000..b109aa3 --- /dev/null +++ b/.claude/skills/verify-pedal/SKILL.md @@ -0,0 +1,73 @@ +--- +name: verify-pedal +description: Verify an OpenPocket build on the real, USB-connected pedal without a person reading its screen: flash the Bluetooth test image, wait for boot, read boot state, last-boot report and radio counters over USB MIDI (node tools/op.mjs log), run the Mac Bluetooth scan, and report pass or fail with the reason in words. Use after a firmware/ble or boot change when a pedal is attached. For checks without a pedal, use the verify skill. +--- + +# Verify on the real pedal + +`node tools/op.mjs log` asks the pedal for the text its screen shows (the `DIAG` request, read only) and judges it. Only the +Bluetooth test image answers `DIAG`; the release firmware does not. The pedal is one shared device: one driver at a time. + +## Launch + +The pedal must run the build under test. Follow the `openpocket-flash` skill: its rules on authority apply, and +`--confirm-flash` never goes in a script. One flash per attempt, no sudo. + +``` +npm ci --prefix tools # once +node tools/op.mjs ble # build/ble/*.fwsc, manifest.json +node tools/op.mjs flash --dry-run --manifest build/ble/manifest.json +node tools/op.mjs flash --confirm-flash --manifest build/ble/manifest.json # ends with "done: the pedal reports ..." +``` + +Ready means the log says stage `OK`, about 7 s after start. `--wait` below waits for that. A pedal that restarts again +and again is a crash loop: stop, do not flash again, and report (see `features/boot-failure.md`). + +## Doctor + +`node tools/op.mjs log --count 1` is read only and says how far the pedal answers. Exit 2 means the MIDI library is missing. +Check first that no other agent is flashing: `ps ax | grep -E 'op.mjs (flash|log)'`. + +## Drive + +``` +node tools/op.mjs log --wait 40 --scan 30 # the whole check: two readings, the scan, a third reading, the verdict +node tools/op.mjs log --wait 40 # state only, no scan +node tools/op.mjs log --follow --interval 2 # watch it; add --json for one object per reading +``` + +| Verdict | Exit | Meaning | +| --- | --- | --- | +| `PASS` | 0 | the Mac saw the advertisement | +| `RUNNING` | 0 | radio events rise, no scan was run: not proof of packets on the air | +| `FAIL` | 1 | the reason says which step, or that nothing was seen | +| `PENDING`, `UNKNOWN` | 3 | not decided: still starting, scanner unusable, or less could be read (below) | + +Report the verdict line as it stands. Field meanings: `docs/BLE-TEST.md`, and printed by the command. + +## What it cannot read + +| Pedal state | What the log gives | Fallback | +| --- | --- | --- | +| red `CRASH` screen, or the 12 s `LAST BOOT FAILED` report | no port, or a silent port, while it shows | read again after it: the next boot gives the same report as `last boot`; a screen photo meanwhile | +| main loop stuck, firmware failed before USB service | port there, nothing answers | screen photo; power cycle or the UBOOT rescue | +| boot loader, update mode | identity only, or no port | finish the update; `openpocket-flash` skill | +| release firmware, or a test image without `DIAG` | version and DSP load, says the rest is missing | screen photo | +| stock firmware | identity only | nothing to read | + +The log never shows sound, footswitches or the drawing on the screen. + +## Evidence + +Save the output with `tee` to a folder outside the repository, with the package SHA-256 from the flash log. Proof standards: +the flash log, the `log --wait 40 --scan 30` output and the verdict, from the real pedal. The scan listens only; it never +connects or pairs. `RUNNING` is not `PASS`. `r` stays 0 with a Mac scan, so it says nothing here. A boot-failure claim needs +the `last boot` line from the real pedal, not the simulator. + +## Cleanup + +Nothing stays running; stop `--follow` with Ctrl-C. Restore the release firmware only when the task says so: +`node tools/op.mjs flash --confirm-flash` after `node tools/op.mjs package`. `build/ble-scan` is rebuilt when missing. +Evidence stays in the folder you chose. + +Features: `features/README.md`. Keep them true with `/maintain-verification-skill`. diff --git a/.claude/skills/verify-pedal/features/README.md b/.claude/skills/verify-pedal/features/README.md new file mode 100644 index 0000000..cc5af42 --- /dev/null +++ b/.claude/skills/verify-pedal/features/README.md @@ -0,0 +1,8 @@ +# Pedal feature map + +| Feature | File | Proof | +| --- | --- | --- | +| Log channel | `log-channel.md` | parsed fields from the real pedal, rising counters | +| Flash and boot | `flash-and-boot.md` | `done: the pedal reports ...`, then stage `OK` | +| Bluetooth advertising | `ble-advertising.md` | verdict `PASS` | +| Boot failure report | `boot-failure.md` | `last boot FAILED` with the stage and the reset or crash | diff --git a/.claude/skills/verify-pedal/features/ble-advertising.md b/.claude/skills/verify-pedal/features/ble-advertising.md new file mode 100644 index 0000000..13ad69c --- /dev/null +++ b/.claude/skills/verify-pedal/features/ble-advertising.md @@ -0,0 +1,25 @@ +# Bluetooth advertising + +The test image sends `OpenPocket-BLE-TEST` with the BLE-MIDI service UUID, at 100 ms, not connectable. + +## Sub-features + +- Controller start-up: `STARTING n`, `FAILED s c`, `ADV ...`. +- Radio counters: `i` about 10 a second is the advertising rate; `t`, `s`, and `V` (stored radio records) are reported as notes. +- The Mac scan: `tools/ble-scan.swift`, built into `build/ble-scan` on first use. + +## How to get to it (user POV) + +Pedal running the test image, within a few metres of the Mac, Bluetooth on. + +## Driving it with tools/op.mjs + +`node tools/op.mjs log --wait 40 --scan 30`: 30 s filtered on `03B80E5A-EDE8-4B33-A751-6CE34EC4C700`, then 30 s unfiltered, +matching the name or the UUID. Proof: `verdict PASS`, with the name and RSSI. + +## Gotchas + +- `others` above 0 is what shows the scanner works when the target is not seen; a scanner with no authorization gives `UNKNOWN`. +- The first run may ask macOS for Bluetooth permission for the terminal. +- The name is in the scan response, the UUID in the advertising data: a sighting may carry only one. +- `ADV` only means the controller accepted the command. diff --git a/.claude/skills/verify-pedal/features/boot-failure.md b/.claude/skills/verify-pedal/features/boot-failure.md new file mode 100644 index 0000000..a9408db --- /dev/null +++ b/.claude/skills/verify-pedal/features/boot-failure.md @@ -0,0 +1,22 @@ +# Boot failure report + +The `LAST BOOT FAILED` screen, readable from the computer on the next boot. + +## Sub-features + +- `last boot` line: stage, reset cause, audio counters; after a crash, PC, RETS, vector, uptime. +- Safe mode: after a failed boot Bluetooth stays off for one boot (`this boot ... SAFE MODE`). + +## How to get to it (user POV) + +Nothing to do: it is there on the first boot that gets USB up after the failure. + +## Driving it with tools/op.mjs + +`node tools/op.mjs log --wait 60`. Look the addresses up in `firmware/build-ble/symbols.txt` of the same build. + +## Gotchas + +- Not yet seen on hardware: only the simulator has produced this line (`tests/test_log.mjs`). Do not break a pedal to test it. +- While the report or the red crash screen is up, USB is down: `--wait` rides that out; the crash itself cannot be streamed. +- Only one previous boot is kept. If the safe-mode boot fails as well, the line describes that boot. diff --git a/.claude/skills/verify-pedal/features/flash-and-boot.md b/.claude/skills/verify-pedal/features/flash-and-boot.md new file mode 100644 index 0000000..8292665 --- /dev/null +++ b/.claude/skills/verify-pedal/features/flash-and-boot.md @@ -0,0 +1,23 @@ +# Flash and boot + +Write the test image, and see it start. + +## Sub-features + +- Flash over USB MIDI (`openpocket-flash` skill). +- Boot stages and the boot guard: `this boot` line (`STAGE`, `GUARD`, reset cause). + +## How to get to it (user POV) + +Pedal running OpenPocket, plugged in, no installer page open. + +## Driving it with tools/op.mjs + +Dry run, flash, then `node tools/op.mjs log --wait 40`. Proof: `done: the pedal reports BlackBox_936`, then +`this boot pedal clock N s (wraps every 179 s), stage OK, normal`. + +## Gotchas + +- The version stays `v36` between test builds: only the package SHA-256 tells them apart. +- The boot after a flash shows reset by `POWER-ON`. +- The stage is `FIRST 6 S` until 6 s after start; the boot guard clears then. diff --git a/.claude/skills/verify-pedal/features/log-channel.md b/.claude/skills/verify-pedal/features/log-channel.md new file mode 100644 index 0000000..2c86334 --- /dev/null +++ b/.claude/skills/verify-pedal/features/log-channel.md @@ -0,0 +1,24 @@ +# Log channel + +The pedal's state as text over USB MIDI: this boot, the last boot, the status line the screen scrolls. + +## Sub-features + +- `DIAG`, command 19 (`docs/EDITOR-PROTOCOL.md`), read only; `firmware/px_editor.h`, `firmware/ble/ble_diag.h`. +- `tools/log.mjs`: parsing, meanings, verdict. It falls back to `INFO` and `PING`, then to the identity query. + +## How to get to it (user POV) + +Plug the pedal in over USB and run the command. No screen, no browser. + +## Driving it with tools/op.mjs + +`node tools/op.mjs log --count 2 --interval 2`: `i` rises between the readings. +`--json` gives the same as objects, with `raw` holding each line as the pedal sent it. + +## Gotchas + +- The pedal clock in `this boot` wraps every 179 s, so it is not the uptime after that; the event rate uses this computer's clock. +- Do not run it during a flash: both talk to the same port. +- The line is the screen's own text (`ble_test_line`), so a new field shows in `raw` before the parser knows it. +- `tests/test_log.mjs` runs this against the real editor code; it says nothing about USB on hardware. diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index 80c449c..49527d6 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -1,17 +1,19 @@ --- name: verify -description: Verify OpenPocket (guitar multi-effects firmware for the Donner Pocket X / M-VAVE BlackBox) without a pedal: build the firmware and installer packages in the pinned container, run the host tests and simulators, and drive the web installer (docs/index.html) and editor (docs/editor.html) in headless Chromium against simulated pedals. Use after any change to firmware/, engine/, loader/, tools/, tests/ or docs/*.html|js, and before claiming a change works. Only a person with a real pedal can verify sound and USB behaviour on hardware. +description: Verify OpenPocket (guitar multi-effects firmware for the Donner Pocket X / M-VAVE BlackBox) without a pedal: build the firmware and installer packages in the pinned container, run the host tests and simulators, and drive the web installer (docs/index.html) and editor (docs/editor.html) in headless Chromium against simulated pedals. Use after any change to firmware/, engine/, loader/, tools/, tests/ or docs/*.html|js, and before claiming a change works. For a connected pedal, use the verify-pedal skill; only a person can verify sound. --- # Verify OpenPocket No user session exists to drive: the product is firmware that runs on a pedal, plus two static web pages. "Driving it as a user" means (1) building what a user installs, (2) running the pedal's own code on the PC in simulators, and (3) clicking -through the installer and editor pages with a mocked Web MIDI device. Never flash or contact a real device from here. +through the installer and editor pages with a mocked Web MIDI device. Never flash or contact a real device from here: that +is the `verify-pedal` skill. ## Launch -Nothing stays running except short-lived test servers, which the scripts start and stop themselves. +Nothing stays running except short-lived test servers, which the scripts start and stop themselves, and the hosted +simulator when you start it (`node tools/op.mjs sim`, skill `openpocket-simulator`): stop the one you started. ``` node tools/op.mjs doctor # Node, Docker, image (toolchain skill: .claude/skills/openpocket-toolchain) @@ -37,6 +39,10 @@ Run it first whenever anything looks off. | Installer page, update flow | `pages.sh` (runs `tests/test_page.mjs`) | `build/page/page_1..4*.png`, last line `page test passed` | | Editor page | `pages.sh` (runs `tests/test_editor_page.mjs`) | `build/editor/editor_1..6*.png`, last line `editor page test passed` | | Screen layout | `cc ... tests/uisim.c` (see toolchain skill), `build/uisim build/ui` | PPM pages, `never drawn` absent from the log | +| The pedal as a user plays it: footswitches, knob, pages, save, tuner, the live screen | `node tools/op.mjs sim`, then the HTTP API (skill `openpocket-simulator`, `docs/SIMULATOR.md`) | `GET /api/state` and `/api/screen.png` after each press; the flash file `build/sim/flash.bin` survives a restart | +| The pedal skin: every control by mouse, touch and keyboard, the backlights, the live LCD | `pages.sh` (runs `tests/test_pedal_page.mjs`) | `build/pedal/pedal-skin*.png`, last line `pedal page: ok` | +| The sound in the simulator: the engine on known signals, against its native build; the input picker, meters, bypass, the preset heard | `node tests/test_audio.mjs`, `pages.sh` (runs `tests/test_audio_page.mjs`) | every line `ok`; `build/audio/sound.png`; `node tools/sim/render.mjs DIR` renders WAV files to listen to | +| Editor and installer pages by hand or with `chrome-devtools-axi` | `sim` serves them at `/site/editor.html` and `/site/index.html` with a virtual Web MIDI | Connect finds "OpenPocket"; an edit shows in `/api/state` | | MIDI musical messages | see `features/midi-usb.md` | host simulator tests when the USB-MIDI feature is in; hardware: `docs/MIDI-TEST.md` | Features: `features/README.md` and one file per feature there. A proof that drives only one entry point is incomplete when @@ -45,9 +51,9 @@ the map lists more. ## Evidence Screenshots and logs go to `build/` (gitignored); copy what you cite to a folder outside the repo. Proof standards: run the -real code (the simulators compile the firmware's own `px_editor.h`, `px_store.h`, `ota.c`), not a re-implementation; +real code (the simulators compile the firmware's own `px_editor.h`, `px_store.h`, `ui.h`, `ota.c`), not a re-implementation; a mocked boundary is only Web MIDI/USB (already isolated by the tests); state what was exercised and what was not. -**Not verifiable here:** audio, DSP load and glitches, USB enumeration on a real host, Bluetooth, flashing, the screen on +**Not verifiable here:** audio on the pedal, its DSP load and glitches, USB enumeration on a real host, Bluetooth, flashing, the screen on the real LCD. Say so, and point to `docs/MIDI-TEST.md` (hardware checklist) when it exists. ## Cleanup diff --git a/.claude/skills/verify/features/README.md b/.claude/skills/verify/features/README.md index 4f153b8..642cb96 100644 --- a/.claude/skills/verify/features/README.md +++ b/.claude/skills/verify/features/README.md @@ -3,7 +3,7 @@ | File | Feature | Driver | | --- | --- | --- | | [build-and-packages.md](build-and-packages.md) | Firmware build, update loader, `.fwsc` packages, `manifest.json` | `op.mjs package` | -| [installer-page.md](installer-page.md) | Web installer `docs/index.html`: connect, detect, install, rescue | `pages.sh` | -| [editor-page.md](editor-page.md) | Web editor `docs/editor.html`: presets, blocks, import/export | `pages.sh` | -| [screen-ui.md](screen-ui.md) | Pedal screen pages (play, edit, save, tuner) | `uisim` via `op.mjs test` | +| [installer-page.md](installer-page.md) | Web installer `docs/index.html`: connect, detect, install, rescue | `pages.sh`; by hand: `op.mjs sim --update` | +| [editor-page.md](editor-page.md) | Web editor `docs/editor.html`: presets, blocks, import/export | `pages.sh`; by hand: `op.mjs sim` | +| [screen-ui.md](screen-ui.md) | Pedal screen pages (play, edit, save, tuner) | `uisim` via `op.mjs test`; live: `op.mjs sim` | | [midi-usb.md](midi-usb.md) | USB-MIDI: update/editor SysEx today; musical MIDI with the MIDI feature | simulators; hardware by the captain | diff --git a/.claude/skills/verify/features/editor-page.md b/.claude/skills/verify/features/editor-page.md index acf6aa5..6c643a1 100644 --- a/.claude/skills/verify/features/editor-page.md +++ b/.claude/skills/verify/features/editor-page.md @@ -9,6 +9,9 @@ Open the editor from the installer page, Connect, edit. ## Driving it with Playwright `sh .claude/skills/verify/scripts/pages.sh`; `tests/test_editor_page.mjs` talks to `build/edsim` (the real `px_editor.h` and `px_store.h`) through a fake Web MIDI. End state: every line `ok`, `editor page test passed`, `build/editor/editor_1..6*.png`. Protocol only (no browser): `node tools/op.mjs test` runs `tests/test_editor.mjs`. +## Driving it against the hosted simulator +`node tools/op.mjs sim --port 0` serves the page at `/site/editor.html` with a virtual Web MIDI: open it with `chrome-devtools-axi`, press Connect (the port is "OpenPocket"), edit, and compare with `GET /api/state` and `GET /api/screen.png` (the pedal's own screen follows an edit; a footswitch pressed over `POST /api/press/fs_up` moves the editor's selection through WATCH). Skill: `openpocket-simulator`. The real editor code over the same shim runs in `tests/test_sim.mjs`. + ## Gotchas - Protocol is in `docs/EDITOR-PROTOCOL.md`; a change to a command needs both `px_editor.h` and `docs/pxedit.js`. - Simulated flash only; no pedal. diff --git a/.claude/skills/verify/features/installer-page.md b/.claude/skills/verify/features/installer-page.md index 6c5aa79..329ab7c 100644 --- a/.claude/skills/verify/features/installer-page.md +++ b/.claude/skills/verify/features/installer-page.md @@ -9,6 +9,9 @@ Open the page in Chrome/Edge, press Connect, plug the pedal in, press Install. ## Driving it with Playwright `sh .claude/skills/verify/scripts/pages.sh`; `tests/test_page.mjs` serves `docs/` plus fixture packages, bridges a fake Web MIDI to `build/devsim` (the pedal's real `ota.c` and `ldr_core.c`) and clicks Connect, Install, rescue. End state: "Done: the pedal now runs PocketX_924", the simulated flash holds the new app, screenshots `build/page/*.png`. +## Driving it against the hosted simulator +`node tools/op.mjs sim --update FLASH.bin --port 0` serves `/site/index.html` with a virtual Web MIDI over `tests/devsim.c` on that image (make one and fake packages with `python3 tests/fixtures.py DIR`; the page also needs its packages in `docs/firmware/`). `GET /api/info` shows the steps the pedal took in `log`. The image is never written; the result goes to `build/sim/update-dump.bin`. Skill: `openpocket-simulator`. + ## Gotchas - Fixtures are version 924 packages made by `tests/fixtures.py`, not the real build. - Nothing proves a real pedal: only the captain installs, by hand. diff --git a/.claude/skills/verify/features/midi-usb.md b/.claude/skills/verify/features/midi-usb.md index 084755c..0f3e904 100644 --- a/.claude/skills/verify/features/midi-usb.md +++ b/.claude/skills/verify/features/midi-usb.md @@ -9,6 +9,8 @@ Plug the pedal's USB-C into a computer; a DAW or the browser sees a MIDI port. ## Driving it without hardware `node tools/op.mjs test` (devsim and edsim run the SysEx paths); once the MIDI feature is in, its simulator test (`tests/`) covers the parser and CC map. The pages scripts cover the SysEx clients. +Any SysEx by hand: `curl -s -X POST $U/api/sysex -d 'f0 7d 4f 50 01 f7'` against `node tools/op.mjs sim` (the editor protocol, answered by the real `px_editor.h`); `POST /api/midi` plus `GET /api/events` for unanswered or pushed messages (WATCH). + ## Only a person with a pedal can verify Enumeration on a real host, a DAW sending PC/CC, the pedal answering, audio while MIDI flows, the update still working. Use `docs/MIDI-TEST.md` when it exists; do not claim these from simulators. diff --git a/.claude/skills/verify/features/screen-ui.md b/.claude/skills/verify/features/screen-ui.md index ab4be94..a8f4a74 100644 --- a/.claude/skills/verify/features/screen-ui.md +++ b/.claude/skills/verify/features/screen-ui.md @@ -9,6 +9,12 @@ Knob, PUSH, HOME, footswitches and block buttons on the pedal. ## Driving it with uisim `node tools/op.mjs test` builds and runs `tests/uisim.c` (the real `ui.h`), writing every page to `build/ui/*.ppm` and a log in which `never drawn` must not appear. View a PPM by converting it (`sips -s format png file.ppm --out file.png` on macOS). +## Driving it live with the hosted simulator +`node tools/op.mjs sim --port 0 --reset` runs the same `ui.h` on a real pedal state (`tests/edsim.c`). Press buttons (`POST /api/press/NAME`, `/api/knob/N`), then `GET /api/screen.png?scale=2` and look at it; `GET /api/state` gives the page (`mode`), the focused block and the sound. This reaches what `uisim`'s script does not: any sequence a user can play, the save flow into a slot and the flash file after it, the tuner with `POST /api/tuner/HZ`, the battery and volume. Skill: `openpocket-simulator`. + +## Driving it on the pedal skin +`$U/` shows the same screen in the drawn front panel (`tools/sim/pedal.html`), with the firmware's hold timings. `node tests/test_pedal_page.mjs OUTDIR` drives every control in Chromium and saves screenshots; it needs Playwright. Backlights are the skin's reading of the state, not firmware behaviour. + ## Gotchas - Colours and fonts are checked as drawn pixels, not on the real LCD. - New screens must be added to `tests/uisim.c` or they are never checked. diff --git a/.claude/skills/verify/scripts/pages.sh b/.claude/skills/verify/scripts/pages.sh index ce645e8..cad7447 100755 --- a/.claude/skills/verify/scripts/pages.sh +++ b/.claude/skills/verify/scripts/pages.sh @@ -1,6 +1,7 @@ #!/bin/sh -# Drives docs/index.html (installer) and docs/editor.html (editor) in headless Chromium against the simulated pedals -# (devsim.c / edsim.c) with a mocked Web MIDI device. Never touches a real pedal. Evidence: build/page/*.png, build/editor/*.png. +# Drives docs/index.html (installer), docs/editor.html (editor) and the pedal skin in headless Chromium against the simulated +# pedals (devsim.c / edsim.c) with a mocked Web MIDI device. Never touches a real pedal. Evidence: build/page/*.png, +# build/editor/*.png, build/pedal/*.png. # PW_DIR=/path/with/node_modules sh .claude/skills/verify/scripts/pages.sh (default PW_DIR: ~/.cache/openpocket-pw) set -e cd "$(dirname "$0")/../../../.." @@ -15,3 +16,5 @@ cc -O1 -Wall -Wno-unused-function -o build/edsim tests/edsim.c engine/px_dsp.c f export NODE_PATH="$PW_DIR/node_modules" node tests/test_page.mjs build/page node tests/test_editor_page.mjs build/editor +node tests/test_pedal_page.mjs build/pedal +node tests/test_audio_page.mjs build/audio diff --git a/.dockerignore b/.dockerignore index e241596..bb28666 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ .git build firmware/build +firmware/build-ble docs/firmware/*.fwsc node_modules fm1-1.bin diff --git a/.gitignore b/.gitignore index cd0c4ea..e09c4ed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ build/ firmware/build/ firmware/openpocket.bin +firmware/build-ble/ +firmware/openpocket-ble.bin docs/firmware/*.fwsc docs/firmware/manifest.json *.o @@ -10,3 +12,4 @@ fm1-1.bin fm1-300.bin *.dump.bin openpocket.bundle +node_modules/ diff --git a/Dockerfile b/Dockerfile index 05f2449..71b0a73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,5 +7,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* COPY firmware/setup.sh /usr/local/bin/op-setup-toolchain RUN JIELI_DEST=/opt/jieli sh /usr/local/bin/op-setup-toolchain +# the engine as WebAssembly for the simulated pedal's sound (engine/wasm.sh) +RUN apt-get update && apt-get install -y --no-install-recommends clang lld \ + && rm -rf /var/lib/apt/lists/* ENV JIELI_TOOLCHAIN=/opt/jieli/toolchain OP_IN_CONTAINER=1 WORKDIR /src diff --git a/Makefile b/Makefile index 591ee81..6b37e35 100644 --- a/Makefile +++ b/Makefile @@ -2,19 +2,34 @@ # Docker command lines; Windows calls it from the .bat files. See docs/BUILDING.md. # make doctor check the prerequisites (Node, Docker, the image) # make build firmware/openpocket.bin, the update loader and docs/firmware/*.fwsc + manifest.json +# make ble the advertise-only Bluetooth test image: firmware/openpocket-ble.bin, build/ble/*.fwsc (docs/BLE-TEST.md) # make test the host tests (tests/run_tests.sh) # make package build + build/SHA256SUMS of the outputs # make clean remove every build output (needs no Docker) # 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 serve-ble the same, with the Bluetooth test packages (build/ble) to install; builds them first if missing +# make flash ARGS="--dry-run" write a built package to a connected pedal (ARGS="--confirm-flash" to really write) +# make flash-rescue ARGS="--confirm-flash" wait for a pedal that restarts in a loop and install (docs/BUILDING.md) +# make log ARGS="--follow" read a connected pedal's state over USB MIDI, with the fields explained +# make sim [ARGS="--port 9000 --reset"] the hosted simulated pedal on http://127.0.0.1:8766/ (docs/SIMULATOR.md) +# make wasm the engine as WebAssembly for the simulator's sound (tools/sim/px_engine.wasm, committed) # 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 -doctor build test package clean shell image serve: +.PHONY: doctor build ble test package clean shell image serve serve-ble wasm render appimage flash flash-rescue log sim +doctor build ble test package clean shell image serve wasm: @node tools/op.mjs $@ +serve-ble: + @node tools/op.mjs serve ble render: @node tools/op.mjs render "$(IN)" "$(OUT)" "$(PRESET)" appimage: @node tools/op.mjs appimage "$(DUMP)" +flash log: + @node tools/op.mjs $@ $(ARGS) +flash-rescue: + @node tools/op.mjs flash --rescue $(ARGS) +sim: + @node tools/op.mjs sim $(ARGS) diff --git a/NOTICE.md b/NOTICE.md index c9a68b7..e39dc77 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -8,7 +8,10 @@ OpenPocket is GPL-3.0-only (`LICENSE`), except the parts below, which keep their | `loader/` (update loader) | felucca-bsp `loader/`, changed for the Pocket X flash layout | MPL-2.0 | | `docs/pxota.js`, `docs/pxpkg.js`, `tools/pxpkg.py`, `tools/mkloader.py`, `tools/lz4blk.py` | adapted from [Felucca](https://github.com/hugelton/Felucca)'s web installer and build tools (as used by CLIPPER and SLOOP) | GPL-3.0 | | `firmware/sdk/` (I2S and DAC drivers as LLVM bitcode, SPL, cfg files) | JieLi AC79 SDK V1.2.0 (fw-AC79_AIoT_SDK) | Apache-2.0 (`LICENSES/Apache-2.0.txt`) | +| `third_party/jieli-ble/` (JieLi's Bluetooth LE controller, 33 objects of `btctrler.a` and 3 of `cpu.a`, as published LLVM bitcode, used only by the Bluetooth test image `make ble`) | JieLi AC79 SDK V1.2.0 (fw-AC79_AIoT_SDK, commit `0617f4c`), `cpu/wl82/liba/`; see `third_party/jieli-ble/README.md` | Apache-2.0 (`third_party/jieli-ble/LICENSE-JieLi-Apache-2.0`, `LICENSES/Apache-2.0.txt`) | +| `firmware/ble/ble_cfg.c` | values and names from the same SDK's `apps/common/config/log_config/lib_btctrler_config.c` | Apache-2.0 | | `firmware/fonts.h` (bitmaps generated from Inter) | [Inter](https://rsms.me/inter/), Rasmus Andersson | SIL OFL 1.1 (`LICENSES/OFL-1.1-Inter.txt`) | +| `assets/loops/*.wav` (guitar loops for the simulator) | cut from [Guitar-TECHS](https://zenodo.org/records/14963133), credits in `assets/loops/ATTRIBUTION.md` | CC BY 4.0 | Sound models: the names refer to the gear that inspired each voicing (circuit analyses published by ElectroSmash, Electric Druid, R.G. Keen / GEOFEX, Rob Robinette, Duncan's Tone Stack Calculator; speaker data sheets; manufacturers' diff --git a/README.md b/README.md index 5e58bde..7421869 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). @@ -111,6 +111,9 @@ replaces the files with this folder's. on a simulated flash: every command, pushes both ways, a power cycle; then the editor page in Chromium (sliders, drag and drop, save, move, export / import). +To play with the simulated pedal yourself (screen, footswitches, knob, the installer and editor pages, and its sound, +with bundled guitar loops, a file or an input of your computer), run `make sim` and open `http://127.0.0.1:8766/`; see [`docs/SIMULATOR.md`](docs/SIMULATOR.md). + ## Layout | | | @@ -122,6 +125,7 @@ replaces the files with this folder's. | `tools/` | package builder, loader wrapper, font converter, flash image tools | | `tests/` | host tests and the simulated pedal | | `flash/` | SD-card recovery with fm1buddy (ESP32-S3) | +| `firmware/ble/`, `third_party/jieli-ble/` | the optional advertise-only Bluetooth test image (`make ble`, [docs/BLE-TEST.md](docs/BLE-TEST.md)) and JieLi's Bluetooth controller it links | ## Licence diff --git a/assets/loops/ATTRIBUTION.md b/assets/loops/ATTRIBUTION.md new file mode 100644 index 0000000..1c39ce1 --- /dev/null +++ b/assets/loops/ATTRIBUTION.md @@ -0,0 +1,13 @@ +# The bundled guitar loops + +These recordings are not under this repository's GPL-3.0. They are licensed under the [Creative Commons Attribution 4.0 International licence](https://creativecommons.org/licenses/by/4.0/), as the dataset's Zenodo record states ([https://zenodo.org/records/14963133](https://zenodo.org/records/14963133), read on 2026-11-05). All are the dry direct-input channel of the guitar, with no amp. + +The dataset's authors ask users to cite their paper: Guitar-TECHS, ICASSP 2025, doi:10.1109/ICASSP49660.2025.10887996. + +`prepare.py` records how each file was made. BPM values come from the dataset's MIDI files. + +- `chug.wav`: "Low riff", cut from Guitar-TECHS (directinput_02.wav of P3_music) by Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman, https://zenodo.org/records/14963133, CC BY 4.0. Changed: cut to a loop, crossfaded, resampled to 44.1 kHz, converted to mono 16-bit, normalised. +- `riff.wav`: "Clean riff", cut from Guitar-TECHS (directinput_08.wav of P3_music) by Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman, https://zenodo.org/records/14963133, CC BY 4.0. Changed: cut to a loop, crossfaded, resampled to 44.1 kHz, converted to mono 16-bit, normalised. +- `arpeggio.wav`: "Arpeggios", cut from Guitar-TECHS (directinput_09.wav of P3_music) by Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman, https://zenodo.org/records/14963133, CC BY 4.0. Changed: cut to a loop, crossfaded, resampled to 44.1 kHz, converted to mono 16-bit, normalised. +- `chords.wav`: "Chords", cut from Guitar-TECHS (directinput_11.wav of P3_music) by Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman, https://zenodo.org/records/14963133, CC BY 4.0. Changed: cut to a loop, crossfaded, resampled to 44.1 kHz, converted to mono 16-bit, normalised. +- `lead.wav`: "Lead phrase", cut from Guitar-TECHS (directinput_12.wav of P3_music) by Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman, https://zenodo.org/records/14963133, CC BY 4.0. Changed: cut to a loop, crossfaded, resampled to 44.1 kHz, converted to mono 16-bit, normalised. diff --git a/assets/loops/arpeggio.wav b/assets/loops/arpeggio.wav new file mode 100644 index 0000000..39c5c1d Binary files /dev/null and b/assets/loops/arpeggio.wav differ diff --git a/assets/loops/chords.wav b/assets/loops/chords.wav new file mode 100644 index 0000000..dfa5537 Binary files /dev/null and b/assets/loops/chords.wav differ diff --git a/assets/loops/chug.wav b/assets/loops/chug.wav new file mode 100644 index 0000000..635348f Binary files /dev/null and b/assets/loops/chug.wav differ diff --git a/assets/loops/lead.wav b/assets/loops/lead.wav new file mode 100644 index 0000000..1e624f0 Binary files /dev/null and b/assets/loops/lead.wav differ diff --git a/assets/loops/manifest.json b/assets/loops/manifest.json new file mode 100644 index 0000000..6c366a1 --- /dev/null +++ b/assets/loops/manifest.json @@ -0,0 +1,94 @@ +{ + "loops": [ + { + "file": "chug.wav", + "title": "Low riff", + "kind": "chugging rhythm", + "signal": "dry", + "author": "Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman", + "source": "https://zenodo.org/records/14963133", + "source_file": "P3_music.zip: audio/directinput/directinput_02.wav, 14.66 s to 20.054 s", + "license": "CC-BY-4.0", + "license_url": "https://creativecommons.org/licenses/by/4.0/", + "attribution": "\"Low riff\", cut from Guitar-TECHS (directinput_02.wav of P3_music) by Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman, https://zenodo.org/records/14963133, CC BY 4.0. Changed: cut to a loop, crossfaded, resampled to 44.1 kHz, converted to mono 16-bit, normalised.", + "sha256": "dc070d2ddad9daa3c0f19cda2e184bcd08c620e769bcead8d719dc45a92a255b", + "seconds": 5.395, + "bpm": 90, + "bars": 2, + "sample_rate": 44100, + "channels": 1 + }, + { + "file": "riff.wav", + "title": "Clean riff", + "kind": "clean riff", + "signal": "dry", + "author": "Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman", + "source": "https://zenodo.org/records/14963133", + "source_file": "P3_music.zip: audio/directinput/directinput_08.wav, 15.57 s to 19.987 s", + "license": "CC-BY-4.0", + "license_url": "https://creativecommons.org/licenses/by/4.0/", + "attribution": "\"Clean riff\", cut from Guitar-TECHS (directinput_08.wav of P3_music) by Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman, https://zenodo.org/records/14963133, CC BY 4.0. Changed: cut to a loop, crossfaded, resampled to 44.1 kHz, converted to mono 16-bit, normalised.", + "sha256": "ac61ab74cb344782086bba9d10d020745190361dd66bd2b8a8b47b18f94f9bad", + "seconds": 4.423, + "bpm": 110, + "bars": 2, + "sample_rate": 44100, + "channels": 1 + }, + { + "file": "arpeggio.wav", + "title": "Arpeggios", + "kind": "arpeggios", + "signal": "dry", + "author": "Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman", + "source": "https://zenodo.org/records/14963133", + "source_file": "P3_music.zip: audio/directinput/directinput_09.wav, 18.078 s to 23.546 s", + "license": "CC-BY-4.0", + "license_url": "https://creativecommons.org/licenses/by/4.0/", + "attribution": "\"Arpeggios\", cut from Guitar-TECHS (directinput_09.wav of P3_music) by Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman, https://zenodo.org/records/14963133, CC BY 4.0. Changed: cut to a loop, crossfaded, resampled to 44.1 kHz, converted to mono 16-bit, normalised.", + "sha256": "1545446fb05a3af5bc369d4d5933b1da4b7af4e5c21e2588877d9a886e9af5bd", + "seconds": 5.465, + "bpm": 89, + "bars": 2, + "sample_rate": 44100, + "channels": 1 + }, + { + "file": "chords.wav", + "title": "Chords", + "kind": "chords", + "signal": "dry", + "author": "Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman", + "source": "https://zenodo.org/records/14963133", + "source_file": "P3_music.zip: audio/directinput/directinput_11.wav, 2.377 s to 7.487 s", + "license": "CC-BY-4.0", + "license_url": "https://creativecommons.org/licenses/by/4.0/", + "attribution": "\"Chords\", cut from Guitar-TECHS (directinput_11.wav of P3_music) by Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman, https://zenodo.org/records/14963133, CC BY 4.0. Changed: cut to a loop, crossfaded, resampled to 44.1 kHz, converted to mono 16-bit, normalised.", + "sha256": "60629bb17273731f292e0991e0da7d096f1e69b2d45faffaaf17884c0b804bff", + "seconds": 5.113, + "bpm": 93, + "bars": 2, + "sample_rate": 44100, + "channels": 1 + }, + { + "file": "lead.wav", + "title": "Lead phrase", + "kind": "lead phrase", + "signal": "dry", + "author": "Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman", + "source": "https://zenodo.org/records/14963133", + "source_file": "P3_music.zip: audio/directinput/directinput_12.wav, 3.275 s to 13.566 s", + "license": "CC-BY-4.0", + "license_url": "https://creativecommons.org/licenses/by/4.0/", + "attribution": "\"Lead phrase\", cut from Guitar-TECHS (directinput_12.wav of P3_music) by Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman, https://zenodo.org/records/14963133, CC BY 4.0. Changed: cut to a loop, crossfaded, resampled to 44.1 kHz, converted to mono 16-bit, normalised.", + "sha256": "7b8125718beff2f432dc56aadc3b33d48197ab5a2e0162de72d30745793fc13f", + "seconds": 10.287, + "bpm": 140, + "bars": 6, + "sample_rate": 44100, + "channels": 1 + } + ] +} diff --git a/assets/loops/prepare.py b/assets/loops/prepare.py new file mode 100644 index 0000000..3e6eb04 --- /dev/null +++ b/assets/loops/prepare.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-only +# Makes the bundled loops from the Guitar-TECHS dataset's direct-input takes, and their manifest.json and ATTRIBUTION.md. +# The WAV files are committed, so nothing needs this script to run: it is the record of how they were made. +# curl -LO https://zenodo.org/records/14963133/files/P3_music.zip && unzip P3_music.zip +# python3 assets/loops/prepare.py P3_music (needs numpy and scipy) +# Each loop runs from a note to the same note some bars later, found by comparing the spectra around both. The end point +# is moved to where the two waveforms line up, then the end's first 20 ms are crossfaded into the start. +import hashlib, json, os, sys, wave +import numpy as np +import scipy.io.wavfile +import scipy.signal + +HERE = os.path.dirname(os.path.abspath(__file__)) +RATE = 44100 +PEAK = 10 ** (-3 / 20) +FADE = int(0.020 * RATE) +ALIGN = int(0.008 * RATE) +AUTHOR = "Hegel Emmanuel Pedroza Villalobos, Termeh Taheri, Wallace Abreu, Ryan Corey and Iran R. Roman" +SOURCE = "https://zenodo.org/records/14963133" +LICENSE, LICENSE_URL = "CC-BY-4.0", "https://creativecommons.org/licenses/by/4.0/" + +# take, seconds from, to, BPM of the take's MIDI file, bars +LOOPS = [ + ("chug", "Low riff", "chugging rhythm", 2, (14.66, 20.054, 90, 2)), + ("riff", "Clean riff", "clean riff", 8, (15.57, 19.987, 110, 2)), + ("arpeggio", "Arpeggios", "arpeggios", 9, (18.078, 23.546, 89, 2)), + ("chords", "Chords", "chords", 11, (2.377, 7.487, 93, 2)), + ("lead", "Lead phrase", "lead phrase", 12, (3.275, 13.566, 140, 6)), +] + + +def take(root, number): + _, x = scipy.io.wavfile.read(os.path.join(root, "audio", "directinput", f"directinput_{number:02d}.wav")) + x = x[:, 0] if x.ndim > 1 else x + return scipy.signal.resample_poly(x / float(2 ** 31), 147, 160) + + +def loop(x, start, end): + s, e = round(start * RATE), round(end * RATE) + window = x[s : s + 2048] + lags = range(-ALIGN, ALIGN + 1) + e += max(lags, key=lambda lag: float(np.dot(window, x[e + lag : e + lag + 2048]))) + y = x[s:e].copy() + fade = 0.5 - 0.5 * np.cos(np.pi * np.arange(FADE) / FADE) + y[:FADE] = y[:FADE] * fade + x[e : e + FADE] * (1 - fade) + return y * (PEAK / np.abs(y).max()) + + +def write(path, y): + with wave.open(path, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(RATE) + w.writeframes(np.round(y * 32767).astype(" **Status: built and linked. The first flash on a pedal crash-looped, the second stalled at `FAILED 5 3` and the third +> froze in the first `ble_poll`; those faults are fixed. The fourth flash reached `ADVERTISING`, but neither a Mac nor a phone scanner saw the pedal. This build adds proof counters and a valid controller address; it has not been run on a pedal yet, and the scanner check is still pending.** There is no emulator of the radio, so everything below about what the pedal does is the plan, not an +> observation. Read [What is not verified](#what-is-not-verified) before you flash it. + +**First hardware run (fixed).** The first image crashed on every boot, before any advertisement: the hardware stack-limit +guard (`fm1_guard.h`, EMU bit 3) faults any stack pointer outside `[_ustack_lo, _sstack_top]`, and the controller's task +stacks (`ble_os.c`) are in ordinary RAM below that window. The test image is now built with that one guard off +(`OP_GUARD_STACK=0` in `firmware/build.sh`); the write, bus and code-range guards stay on. The normal firmware keeps all four. +`firmware/app.ld` now refuses to link the image if the stack guard is on while the task stacks lie outside the window, so the +mistake cannot come back unnoticed (`OP_BLE=1 OP_GUARD_STACK=1 sh firmware/build.sh` shows the refusal). + +## Build + +``` +make ble # firmware/openpocket-ble.bin and build/ble/PocketX_936.fwsc, BlackBox_936.fwsc, manifest.json +``` + +(`node tools/op.mjs ble` is the same; `OP_BLE=1 sh firmware/build.sh` inside the container.) It prints the SHA-256 of the image and the two packages. Build outputs +are `firmware/build-ble/` and `build/ble/`; `make clean` removes them. + +## Flash it + +This is the pedal's normal update path, with the test packages instead of the release ones. Your pedal must already run +OpenPocket (the stock-firmware scan was skipped on purpose). + +1. `make serve-ble` builds the test packages if they are missing and opens the offline installer on + `http://localhost:8765/` with `build/ble/` as its package folder. (Chrome or Edge, USB cable to the pedal.) +2. **Connect**, pick the pedal, **Install**, as for any update. The pedal restarts into the test image; its start-up + screen reads `v36 BLE TEST`. +3. To go back to the normal firmware: run `make serve` (the release packages) and **Install** again, or use the + **Back to the official firmware** step. The SD-card recovery (`make appimage`) works as before. + +The status text at the bottom right of the screen shows the Bluetooth state: `STARTING` with the stage and the pedal's +usual `C1 42%`, then `ADVERTISING` (the proof counters, scrolling) or `FAILED` (the failure, scrolling, with its short +code first so that it is always on screen). See [Reading the status text](#reading-the-status-text). + +## What you should see + +Open a scanner (nRF Connect, LightBlue, or macOS *Bluetooth Explorer* / *Audio MIDI Setup* are all fine; use active +scanning, which phones do by default): + +| In the scanner | Value | +| --- | --- | +| Name | `OpenPocket-BLE-TEST` (in the scan response) | +| Service UUID | BLE-MIDI `03B80E5A-EDE8-4B33-A751-6CE34EC4C700` (in the advertising data) | +| Type | scannable, not connectable (`ADV_SCAN_IND`), 100 ms interval, channels 37, 38, 39 | +| Address | `02:4F:50:42:4C:54`, a fixed locally administered address set by this image (not the stock firmware's) | + +The name appearing in a scanner is the whole success test. Connecting to it will fail by design. + +If the screen reads `FAILED`, or nothing appears within a few seconds, please report the screen text, the pedal model, +what the scanner shows and whether the pedal still works as a normal OpenPocket (audio, screen, USB MIDI). + +## Reading the status text + +| Text | Meaning | +| --- | --- | +| `STARTING n` | the last stage reached: 1 task created, 2 controller init called, 3 radio front end replay entered, 4 waiting for the controller task, 5 setting the advertisement, 6 enabling it, 7 the replay returned, 8 the baseband asked for its interrupts. The markers are set by two tasks, so 3 and 7 can appear after 4; the number shown is the last one set, not the highest | +| `FAILED s c` | step `s` failed with return value `c`. The status area is only 11 characters wide, so the whole line scrolls like a marquee (2 characters a second, resting 2 s on the start, then looping): `FAILED 2 -7 step btctrler_task_init stage init`. The short code is first, so it can be read before the scroll starts. The usual `C1 42%` is not shown while it has failed. | +| `ADVERTISING` | the controller accepted the command to advertise; it does not mean a packet was sent. The status shows the proof counters instead, as a marquee (below) | + +Failing steps: `1` `task_create` failed (`c` is -1); `2` `btctrler_task_init` returned `c` (not 0); `3` +`btctrler_task_ready` returned `c` (0: the controller task did not answer); `4` `ll_hci_adv_enable` returned `c` (not +0); `5` still `STARTING` after 5 s, and `c` is the stage it stalled in (the list above). At a timeout the line ends with `wait XXXXXXXX`: the address of the semaphore or mutex the controller task is blocked on (look it up in `firmware/build-ble/symbols.txt`; a value below 0x10 is a task state instead). Example: `FAILED 5 3` only means the last marker passed was the entry of the radio replay; it does not say the replay hung. (In the second hardware run it was a deadlock after the radio start-up: see "Found on hardware" below.) The codes and the text come from `firmware/ble/ble_status.h`, checked on the PC by +`tests/test_ble_status.c`. Please report the whole `FAILED s c` text. + +### The advertising line (proof counters) + +The fourth flash showed `ADVERTISING` but neither a Mac nor a phone scanner saw the pedal, so while advertising the status area scrolls a line of counters and registers, with the numbers first: `ADV i12 e5 r0 t340 s0 CON 2001 IEN 101 IPND 0 STAT 80 IC45 3 IC29 3 BEN 1`. + +| Field | Meaning | What it tells | +| --- | --- | --- | +| `i` | baseband event interrupts (IRQ 45), counted by `ble_isr.S` before the library's own handler runs | above 0 and rising: the radio runs and events happen. 0: no event ever reaches the CPU | +| `r` | receive interrupts (IRQ 29) | above 0: a scanner's request was heard, so the antenna works both ways | +| `e` | HCI packets the controller returned (`hci_packet_handler`) | any controller answer | +| `t` | library software timers that fired | the controller's tick source runs | +| `s` | calls of the slot-timer stand-ins (`slot_timer_*`) | above 0: the link scheduler asked for a timer this image does not have (see below) | +| `CON IEN IPND STAT` | baseband registers `0x28000`, `0x28028`, `0x28030`, `0x28038` in hex | enable, interrupt enables, pending, busy flags | +| `IC45 IC29` | the interrupt configuration of the two lines (bit 0 enabled, bits 3:1 priority) | 0: the line is off | +| `BEN` | BLE block enable `0x2FC80` | bit 0 clear: the block is off | + +Reading the next flash: `i` and `t` rising means the radio runs and the cause is on the air side (power, trims, address). `i` 0 with `IC45` 0 means the interrupt was never enabled; `i` 0 with `IC45` 3 and `IEN` 0 means the baseband never enabled its event; `t` 0 means the library's timers never fire. + +**Checked against the Core Specification** (`firmware/ble/ble_adv.h`, tested on the PC): the advertising data is two whole AD structures (flags 0x06, the complete 128-bit UUID list, little endian), 21 of 31 bytes; the scan response is the complete local name, 21 bytes; the PDU is ADV_SCAN_IND at 100 ms on channels 37, 38 and 39 with filter policy 0. One fault was found and fixed: the controller's public address was never set (the library reads it from a stored item this image does not have), so it was all zeros, which is not a valid device address. It is now `02:4F:50:42:4C:54` (locally administered, unicast; not an IEEE-assigned address). The library's own-address type is fixed at public in its HCI layer, so a random static address cannot be used from here. + +**Open suspicion, not a fix:** the link scheduler (`link_task.c`) arms its timer through `slot_timer_set` and its siblings, which are among the empty stand-ins in `ble_stubs.c` (the library's slot timer is not part of the linked archive). Whether a plain advertising event depends on them is not known. `s` counts the calls: if it is above 0 the next step is a real slot timer. + +## Reading the pedal from a computer + +`node tools/op.mjs log` reads all of this over USB MIDI, so nobody has to read the scrolling text: it prints each field with its meaning and a verdict. It needs `npm ci --prefix tools` once. + +``` +node tools/op.mjs log --wait 40 --scan 30 # after a flash: wait for the boot, read, scan from this Mac, read, judge +node tools/op.mjs log --follow --json # keep reading, one JSON object per reading +``` + +The request is `DIAG` ([EDITOR-PROTOCOL.md](EDITOR-PROTOCOL.md)), which only this image answers, so the normal firmware is byte for byte what it was. It reads memory and the same registers as the status text, and writes nothing. Its three lines are: + +| Line | Example | Meaning | +| --- | --- | --- | +| this boot | `UP 137120 SAFE 0 STAGE 8 RST 1 GUARD 0` | the pedal's clock in ms, which wraps every 179 s, so it is the uptime only in the first 179 s; safe mode; boot stage (8 = OK); reset source bits (`POWER-ON`, `VDDIO LOW`, `WATCHDOG`, `VCM`, `LONG PRESS`, `1.2V`, `SOFT`, from bit 0); failed boots counted | +| last boot | `LAST OK`, or `LAST FAILED STAGE 7 BLOCKS 9 LOAD 42 PEAK 61 NAN 0 CPU1 1 LOOPS 0 CRASH VEC 3F PC 2000DC8 RETS 2001234 DBG 0 EMU 0 UP 4100` | the fields of the `LAST BOOT FAILED` screen; `CRASH ...` only after a crash | +| status | `ADV i12 e5 ...`, `FAILED 5 3 step timeout ...`, `STARTING 4`, `OFF` | the text the status area scrolls, from the same function | + +It cannot read a pedal that is not running its main loop. While the red crash screen or the 12 s `LAST BOOT FAILED` report is up, USB is down; the next boot gives the same report through this channel. A pedal stuck before USB service stays silent: only the screen shows what it is doing. With firmware that has no `DIAG`, the command gives the version and the DSP load and says what is missing. Exit codes: 0 pass or running, 1 fail, 3 undecided. `RUNNING` means the radio events rise; only a sighting by the scan is `PASS`. + +## Found on hardware + +The second flash showed `FAILED 5 3`. The cause, found by reading the vendor code against the linked image: the controller's link-layer start-up calls `thread_create`, which calls `crc_get_32bit`, which waits for ever on a lock that the vendor function `__crc16_mutex_init()` creates and the SDK's own start-up calls. This image never called it, so the lock stayed "taken" and the controller task slept for ever. `ble_test_start` now calls it. Two more defects were fixed in the same change, because the first one would have exposed them: `ll_hci_adv_set_params` reads six bytes from its direct-address argument (it was NULL, which the bus guard is documented to fault on; now a zero array), and the context switch saved `r4`-`r9` while the compiler preserves `r4`-`r15` (now all of them, the list the compiler's own prologues use). None of the three has run on a pedal yet. + +The third flash froze: the screen was drawn but the controls were dead, and the pedal reset by watchdog after about 8 s (`LAST BOOT FAILED`, `RESET BY: WATCHDOG`, `LOOPS 0`). The scheduler is cooperative, so a vendor call that never returns holds the whole main loop. The scout report (data/openpocket-ble-hang-diagnosis/report.md) found two such places on the advertising path: a vendor assertion that fails because the empty stand-in for `low_power_get` returned a null handle (a failed assertion parks in `P33_SYSTEM_RESET`, an endless loop), and, earlier, an unbounded wait on baseband status bit 1 (`0x28038`) that the repository's own baseband header says can stay set. `low_power_get` now returns a non-null handle. The second wait is untested; if it is the one, the crash screen below names it. + +**Stall watch.** The 10 kHz timer interrupt counts while a `ble_poll()` runs. After 4 s it makes the next tick take the fatal path, so the pedal shows the normal crash screen with `VEC 3F` and the `PC` and `RETS` of the code that did not return (look them up in `firmware/build-ble/symbols.txt`; `PC 02000DC8` is a vendor assertion and `RETS` names it; a `PC` inside `ble_hw_disable` is the status-bit wait). It cannot fire while interrupts are off, and it watches only `ble_poll()`; the radio replay is excluded because it is long and its waits are bounded. + +**After a failed boot Bluetooth stays off.** The next boot shows `LAST BOOT FAILED` and runs the normal firmware (status `OFF SAFE nn%`), so the boot guard never counts two failures in a row and the pedal does not fall back to the loader. The flasher works in that state. The boot after that one tries Bluetooth again. + +## How it works + +``` +openpocket.c (OP_BLE_TEST) ble_test_start() after the screen is up, ble_test_poll() in the main loop +firmware/ble/ble_test.c a task that starts the controller, sets the advertising data and enables advertising +firmware/ble/ble_os.c the operating-system services the library calls (below) +firmware/ble/ble_rf.c the Wi-Fi front-end half of the radio start-up (below) +firmware/ble/ble_cfg.c controller configuration: LE only, advertising + one slave link +firmware/ble/ble_stubs.c empty stand-ins for the parts that are not built +third_party/jieli-ble/ JieLi's controller, as published (Apache-2.0): see its README.md +``` + +* **The controller is JieLi's**, from the Apache-2.0 SDK libraries (`btctrler.a`, 33 of its objects plus 3 helpers from + `cpu.a`): HCI front end, link layer, advertising, the LE radio driver. Only the objects the image reaches are linked. + The host stack (`btstack.a`) is not used; `ble_test.c` calls the controller's own `ll_hci_adv_*` functions. + The OpenPocket code around it is small and separate from the vendor objects (`firmware/ble/`), which are kept + unmodified under `third_party/jieli-ble/` with the licence text and the source URL. +* **No RTOS.** The library expects tasks, queues, semaphores and timers. `ble_os.c` provides a cooperative scheduler: + the controller's one task runs from `ble_poll()` in the main loop, switches only where it waits (queue, semaphore, + delay), and is woken by the two BLE interrupts (IRQ 45 and 29, priority 2: the numbers and priority the stock firmware + uses). The heap is a 24 KB static pool; each task has a 4 KB stack. Against the normal image it needs about 44 KB more RAM (of which the heap + and stacks are 36 KB) and 131 KB more flash; the link fits with about 15 KB of the 96 KB RAM region free. +* **Radio start-up.** The library's `bt_rf_init` calls the Wi-Fi library's `wf_rf_common_init` first. Linking that would + pull in the Wi-Fi MAC/SDIO stack, so `ble_rf.c` runs the already committed capture of the stock start-up instead + (`firmware/bsp/chip/ble_rf_tables_v15.h`, run by `fm1_ble_rf.h`), with this unit's trims measured by `fm1_rf_cal.h`. + The BLE baseband itself is initialised by the library (`le_hw_init`), which makes the same register changes as the stock + firmware's baseband start-up in the analysis (bits 13 and 14 set, bit 4 cleared, IRQs 45 and 29 at priority 2). +* **Nothing is saved.** The library asks for stored items (`syscfg_read`): none are returned. It tries to store calibration + results (`syscfg_write`): the call is accepted and ignored. The VM (`0xA5000`) and BTIF (`0xBE000`) areas are not read + and not written by this image. + +## What is not verified + +All of it was built, linked and size-checked; none of it ran on hardware. The things most likely to go wrong, in the +order I would suspect them: + +1. **The radio start-up.** It replays a capture taken from another firmware, with the stored crystal trim and the other + saved radio records **not applied** (the VM is not read, so those fields stay at the capture's defaults). The carrier + may therefore be off by some kHz. A scanner usually still sees it; a wrong value that stops the PLL would hang the + start-up and the screen would stay on `STARTING`. + The brief asked for the stock TX trims to be left as found. This image does **not** meet that exactly: it neither + reads nor writes them, and the transmit level is whatever the library's power table gives with no saved trim. + Reading them needs the VM item format, which is not documented in this repository; that would be the next task. +2. **Interrupt return.** The library's two interrupt functions end in `rti` without the `csync` the stock ones have + (our compiler does not add it to bitcode; `fm1_isr.S` explains it for our own interrupts). The risk is an interrupt + taken twice. Suspect this if the pedal resets or hangs a moment after `ADVERTISING` appears. +3. **The scheduler.** The context switch (`ble_ctx.S`) is eight instructions assembled from the compiler's own prologue + and epilogue forms, and was not run. The queue, semaphore and timer code is plain C but also unrun. +4. **Flash writes while Bluetooth runs.** The library's interrupt code runs from flash (execute in place). Saving a + preset or an update while advertising could, in principle, fetch code while the flash is busy. Do not save presets + while testing. +5. **Real-time load.** Starting the radio blocks the main loop for the length of the start-up (screen and knobs freeze, + audio keeps running from its interrupt). Afterwards the cost is the two radio interrupts every 100 ms. +6. **Other guards, checked on paper only.** After the stack-limit fault, the library's start-up path was read for the other + guards (`fm1_guard.h`). *Write limits* cover only the two 256-byte stack bands and `0x01C7FD50` upward (boot info, + mailbox, vectors); the controller's data, heap and task stacks are in `.bss`/`.data` between `0x01C08000` and about + `0x01C1C000`, so none of its writes should land there. *Code range*: all of its code is linked into `.text` (`_etext` + bounds it) and none runs from RAM. *Bus-invalid*: only the radio's own registers are touched. *Interrupt stacks*: the two + BLE interrupts run on the system stack as every interrupt does, which is inside the window. None of this has run on + hardware; a fault on the next flash is still possible, and the crash screen's `EMU` and `DBG` values say which guard it was. +7. **Regulation.** The pedal was certified with the stock firmware. This image uses the standard advertising channels, + the 1M PHY and the library's own power table; it is a hobby experiment, not a product. + +## Recovery + +If the image does not start, the existing safety net applies: two failed boots go back to the update loader +(`UBOOT`), and the web installer, the SD-card recovery (`make appimage`) and the official firmware's own updater all work as +before. The image does not touch the flash outside the application area. diff --git a/docs/BUILDING.md b/docs/BUILDING.md index de42e5f..e40cac8 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -28,11 +28,16 @@ Apple silicon and other ARM machines Docker emulates it (Rosetta or QEMU); you c | --- | --- | | `make doctor` | check Node, Docker and the image, one line each (exit 1 when the build cannot work) | | `make build` | `firmware/build.sh` in the container: `firmware/openpocket.bin`, the update loader, `docs/firmware/PocketX_9NN.fwsc`, `BlackBox_9NN.fwsc`, `manifest.json` | +| `make ble` | the advertise-only Bluetooth test image (`OP_BLE=1 firmware/build.sh`): `firmware/openpocket-ble.bin`, `build/ble/*.fwsc`. Own target, own output folders; [BLE-TEST.md](BLE-TEST.md) | +| `make serve-ble` | `make serve` with `build/ble/` as the installer's package folder, to flash the test image | | `make package` | `build`, then `build/SHA256SUMS` | | `make test` | `tests/run_tests.sh` (engine, preset store, screen, simulated pedal, installer, editor; the browser page tests also run when Playwright is installed) | | `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 log ARGS="--follow"` | read a connected pedal's state over USB MIDI, with the fields explained and a verdict (`docs/BLE-TEST.md`, "Reading the pedal from a computer") | +| `make sim [ARGS="--port 9000"]` | the simulated pedal on `http://127.0.0.1:8766/`: screen, controls, state, an HTTP API, the installer and editor pages on a virtual MIDI port (`docs/SIMULATOR.md`; needs a C compiler on the host, no Docker) | | `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 +48,12 @@ 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. + +**A pedal that restarts in a loop.** `make flash-rescue ARGS="--confirm-flash"` (or `node tools/op.mjs flash --rescue --confirm-flash [--package FILE] [--timeout SECONDS]`) is the installer page's rescue mode on the command line, and uses the same code (`Updater.rescueWithin` in `docs/pxota.js`). Start it, then plug the pedal in or let it restart. It asks every MIDI port for the pedal's identity every 100 ms; the moment one answers, it stops the pedal's sound processing (which ends the restarts) and installs the package of that pedal's model, printing `pedal answered: ...` and one line per step. If the pedal disappears before the update finished, nothing has been written yet: it waits for the pedal again, and an OpenPocket update loader left behind is finished when `--package` names the package (a loader does not report its model; without `--package` the timeout message says so). It waits 180 s by default (`--timeout`); an install that has started is never cut short. `--rescue --dry-run` only waits for the pedal and checks the model. The same refusals apply (no `--confirm-flash`, wrong model, damaged package). Only a pedal whose firmware answers the identity query for a moment is caught, as on the page; a pedal that crashes before it answers, or sits in the chip's mask-ROM boot loader (`WL80UBOOT`, USB 4C4A:8057, no MIDI), is out of reach and needs the SD-card recovery. Connect only one pedal: the first to answer is used. + ## What is pinned - The base image `debian:stable-slim`, by digest (`Dockerfile`). @@ -57,7 +68,7 @@ old one. The toolchain is JieLi's proprietary software. It is **not** in this repository and is not redistributed by it: the image build downloads it from JieLi's own server (`jl-update.oss-cn-shenzhen.aliyuncs.com`, the file `https://pkgman.jieliapp.com/s/linux-toolchain` redirects to). If you publish a container image, do not include the toolchain layer. The Apache-2.0 SDK parts in -`firmware/sdk/` are covered by `NOTICE.md`. +`firmware/sdk/` and `third_party/jieli-ble/` are covered by `NOTICE.md`. ## Reproducibility diff --git a/docs/EDITOR-PROTOCOL.md b/docs/EDITOR-PROTOCOL.md index 1a7a9c6..7096c25 100644 --- a/docs/EDITOR-PROTOCOL.md +++ b/docs/EDITOR-PROTOCOL.md @@ -42,6 +42,7 @@ The 12 blocks, by index: 0 GATE, 1 COMP, 2 PITCH, 3 SFX, 4 DRIVE, 5 AMP, 6 CAB, | 16 SAVE | slot | slot, rc. Saves the current sound into the slot, writes the flash, the slot becomes the current one (as the pedal's SAVE) | | 17 WATCH | on (0/1) | on. While on, the pedal pushes `STATE` (cmd 5) when the sound, the slot or the focused block changes on the pedal; it stops by itself 5 s after the last request | | 18 PING | — | DSP load % (0..127), flags (bit 0: bypass, bit 1: second core) | +| 19 DIAG | — | n (3), then n strings: this boot, the last boot, the status line. Read only. Only the Bluetooth test image answers: other firmware gives no reply. `docs/BLE-TEST.md`, "Reading the pedal from a computer" | Writing the flash mutes the sound for a moment, as saving on the pedal does. A whole import sends `SLOT_PUT` without bit 1 for every slot, then one `COMMIT`. diff --git a/docs/SIMULATOR.md b/docs/SIMULATOR.md new file mode 100644 index 0000000..b755c00 --- /dev/null +++ b/docs/SIMULATOR.md @@ -0,0 +1,224 @@ +# The simulated pedal + +`node tools/op.mjs sim` (or `make sim`) runs the pedal's own code natively on your computer and puts it behind a local +HTTP interface. A person plays with it in a browser; a script or an agent drives it with `curl`. It needs Node 18+ and a +C compiler (`cc`: Xcode command line tools on macOS, `build-essential` on Linux); no Docker, no toolchain, no pedal. + +``` +node tools/op.mjs sim # http://127.0.0.1:8766/ +node tools/op.mjs sim --port 9000 --reset +node tools/op.mjs sim --update FLASH.bin # the update flow, for the installer (see below) +``` + +| Option | Meaning | +| --- | --- | +| `--port N` | the port (default 8766; `0` picks a free one, the URL is printed) | +| `--host H` | the address to listen on (default `127.0.0.1`: this computer only) | +| `--flash FILE` | the pedal's flash, kept between runs (default `build/sim/flash.bin`) | +| `--reset` | delete the flash file first: factory presets only | +| `--update FILE` | run the update flow on this 1 MiB flash image (it is never written), see "The installer" | + +## What runs + +`tests/edsim.c`, built into `build/sim/edsim`, compiles the firmware's own code: the editor protocol +(`firmware/px_editor.h`), the preset store (`firmware/px_store.h`) on a 1 MiB flash array, the screen +(`firmware/ui.h`, `ui_text.h`, drawn into the 240 x 240 frame buffer of `tests/simlcd.h`) and the engine's catalogue +(`engine/px_dsp.c`). `tests/devsim.c` (`--update`) runs `firmware/bsp/chip/ota.c` and `loader/ldr_core.c`. The programs +are the ones the tests use; `tools/sim.mjs` only talks to them on stdin/stdout. Their line protocol is documented in the +header of each `.c` file; `tests/devsim.c` and the old `edsim` commands are unchanged. + +Only the control dispatch is mirrored: `press()` and the `button_*` functions in `tests/edsim.c` follow what the main +loop of `firmware/openpocket.c` does with a debounced press (PUSH, HOME, footswitches, the block buttons, a long press of +PUSH or HOME, a double click on PUSH, and their 800, 700 and 350 ms). If that loop changes, change them with it. + +**Not simulated:** the LEDs and any bypass footswitch (the firmware does not drive any), USB and Bluetooth, the debounce. `POST press` has no timing: a double click or a long press +is one request; `POST down` and `POST up` have the firmware's. + +## In a browser + +`http://127.0.0.1:8766/` is the pedal: its front panel, drawn in code (`tools/sim/pedal.html`), with the live screen in +the LCD. It works as the pedal does, through `POST down` and `POST up`: a button acts when the firmware's does, so hold +HOME 0.7 s for the tuner, hold PARA 0.8 s for SAVE, click PARA twice for MOVE. + +| Control | Mouse or touch | Keys | +| --- | --- | --- | +| Footswitches | press, hold, release | Up (right, next preset), Down (left, previous) | +| HOME | press, hold | Escape or H | +| FX, AMP, MOD, DLY, REV, CAB | press | F, A, M, D, R, C | +| PARA, as a knob | drag (up or right: clockwise), wheel | Left, Right | +| PARA, as PUSH | click, hold | Enter | +| MASTER | drag, wheel | `[`, `]` | + +Tab reaches each control. On a button, Enter and Space press it. On MASTER, the arrows move it by 2, Page Up and Down by +10, Home and End go to 0 and 100. On PARA, the four arrows turn it and Space presses it. + +A knob follows the firmware: PARA counts detents, and detents less than 45 ms apart are the fast turn (steps of 5). +MASTER is 0..100 with the firmware's hysteresis of 2, so its smallest move is 2, and it always reaches 0 and 100. PARA +waits 120 ms to tell a press from a turn, so its hold starts that much late. The skin draws 24 detents a turn: the +firmware only counts them, so that number is an assumption. + +Scripts on the page have `window.pedal`: `down(name)`, `up(name)`, `tap(name, ms)`, `turn(detents, fast)`, `master(volume)` +and `state()`. Each control has `data-control` (or the id `master` or `para`), and a button has `data-lit`. + +The firmware drives no LEDs, so the backlights are the skin's own reading of the state (`backlights()` in +`tools/sim/pedal-input.js`). `data-lit` is `off`, `on` or `focus`. A block button is on when its block is on; FX when one +of its seven blocks is on; a button is `focus` on its own page; HOME is `focus` on the chain page. A footswitch's ring +brightens while it is held. + +The panel's Sound section plays guitar through the pedal: see "The sound". + +The TUNER line on the panel is the stock firmware's: in OpenPocket both footswitches are two presses, and the tuner is a +long press of HOME. + +`http://127.0.0.1:8766/plain` is the earlier plain page: the screen, every control as a button, the battery, the tuner +pitch and the state. + +The installer and the editor pages work against the simulated pedal without a change and without a driver: +`/site/index.html` and `/site/editor.html` serve `docs/` with the virtual Web MIDI (`/sim/webmidi-shim.js`) injected. +The shim replaces `navigator.requestMIDIAccess` with one input and one output port, "OpenPocket", and works in any +browser (also those without Web MIDI). For another copy of the pages, run once in the page's console: + +```js +document.head.append(Object.assign(document.createElement("script"), { src: "http://127.0.0.1:8766/sim/webmidi-shim.js" })) +``` + +## The sound + +The skin plays guitar through the pedal's real chain: `engine/px_dsp.c`, the firmware's own engine and maths +(`firmware/px_math.c`), built to WebAssembly and run in an AudioWorklet of the page. The preset is the one on the screen: +the page follows `GET state` (10 times a second, and after every control) and hands the engine the same bytes the +firmware's `audio_poll()` hands it, so footswitches, PARA, the block buttons, an editor page and `curl` all change the +sound. MASTER is the firmware's output gain, so it sets the level through the same curve. + +| Control | Does | +| --- | --- | +| Input | a bundled loop, a file (drop one on the page, or choose one), or a device of the computer | +| Play, Stop | start and stop the source. The first click also starts the audio, because browsers require a gesture | +| Loop | repeat the loop or file | +| Bypass | the firmware's bypass: the dry input at MASTER, and "BYP" on the screen. It stays on across presets, unlike the pedal's, which is an overload guard and has no switch | +| Input level | the level into the pedal: 100 % puts a full-scale recording at guitar level (below) | +| Input, Output meters | peak dBFS going into and out of the engine, before the output volume | +| Output volume | the computer's master volume, after the pedal; MASTER is the pedal's own | + +A device starts muted, so that a microphone next to the speakers cannot howl. Its meters move, and "Hear this input" lets +it through. Echo cancelling, noise suppression and automatic gain are turned off. The picker shows names after the +browser has been allowed to use the input, which it asks for at the first Play. Scripts have `window.pedal.audio()`: +`{running, sampleRate, playing, device, muted, heard, latencyMs}`, where `heard` is the name of the preset the engine runs. + +The level matters to the sound: the amps are voiced for a guitar at the pedal's input, about 0.15 of full scale at the +engine (`tools/calib_levels.py`), which is what 100 % gives a full-scale recording. Drive it harder with Input level. + +**Why WebAssembly in a worklet, and not the native engine streamed over a WebSocket.** Latency: the worklet adds one +block of 128 frames (2.9 ms, the firmware's own block) to what the browser's output costs, and a stream would add a round +trip, two jitter buffers and the page's main thread. In Chrome on a Mac the context reported 5.4 ms of its own, the same +at 44.1 kHz as at the sound card's 48 kHz; the output device adds what it adds (168 ms reported on that Mac's output), +and the page shows the sum. Fidelity: +it is the same code with the same 128-frame blocks and the firmware's own `px_math.c`; `tests/test_audio.mjs` holds it to +the native build exactly, on all 52 factory presets (the pedal's MASTER is scaled by 0.25 there so nothing clips, which is exact). Effort: Node has no WebSocket server, and the repository has no dependencies. +The price is a committed binary: `tools/sim/px_engine.wasm` (82 KB), so that the simulator still needs only a C +compiler. `node tools/op.mjs wasm` (`make wasm`) rebuilds it in the container with Debian's clang and lld. Next to it, +`px_engine.wasm.sources` holds the sources' checksum, and the test fails when the engine changes without a rebuild. + +`engine/px_wasm.c` mirrors `dsp_block()`, `audio_apply()`, `audio_reset()` and `audio_volume()` of `firmware/px_audio.h`: +if those change, change it with them. Left out: the 24-bit output rounding, and the overload guard that drops to bypass, +because a computer's load says nothing about the pedal's. + +**Sample rates.** The audio context runs at the engine's 44.1 kHz, and the browser converts everything: files when it +decodes them, devices at the source, and the output to the sound card. Firefox refuses to connect a device whose rate +differs from the context's, and the page then says so; loops and files are not affected. This path was tested only in +Chromium. + +**The load.** The status text ("C1 3%") and `dsp.load` show the time the engine took for a block as a share of 2.9 ms, +as the firmware measures it, but on this computer, so it is no prediction of the pedal's load. It stays 42 until the +sound first runs. + +**Pages need a secure origin.** AudioWorklets and devices work on `localhost` and `127.0.0.1`. From another computer +(`--host`), the page needs https, or the Sound section says that it has no worklet. + +**The loops** are in `assets/loops/`: five dry, direct-input takes from the Guitar-TECHS dataset, CC BY 4.0, so they +are not under the repository's GPL. They are mono 16-bit WAV at 44.1 kHz, peaking at -3 dBFS, 2.6 MB in all, so they are +committed rather than fetched; WAV because every browser and the tests read it without a decoder. `manifest.json` and +`ATTRIBUTION.md` record each one's source, licence and checksum, and `prepare.py` how it was made. Each runs from a note +to the same note some bars later, with a 20 ms crossfade, and the browser loops it gaplessly. `node tools/sim/render.mjs OUTDIR [preset]` renders each one through the engine, twice around, to +WAV files. + +## The API + +All paths start with `/api/`. Answers are JSON unless stated; an error is `{"error": "..."}` with status 400 (bad +request), 405 (wrong method), 409 (not in this mode) or 503 (the program did not answer). Every request is applied +before it is answered, so a state read right after a press shows the press. + +| Request | Does | +| --- | --- | +| `GET info` | `{kind: "pedal" \| "update", port, flash, controls, log}`; `port` is the MIDI port name, `null` when none | +| `GET state` | the pedal's state (below) | +| `POST press/NAME` | a control: `fs_up` `fs_dn` `fx` `home` `push` `mod` `amp` `cab` `dly` `rev`; `push_long` (hold PUSH: SAVE), `dpush` (double click PUSH: MOVE), `home_long` (hold HOME: tuner) | +| `POST down/NAME`, `POST up/NAME` | a button goes down, comes up: `fs_up` `fs_dn` `fx` `home` `push` `mod` `amp` `cab` `dly` `rev`. Timed as the firmware does: HOME and PUSH act at a short release, or after 0.7 s and 0.8 s held; two PUSH clicks within 350 ms on the chain are MOVE, one acts after those 350 ms; the others act when they go down. A button already down, or not down, is left as it is | +| `POST knob/N[?fast]` | the PARA knob N detents (negative: the other way); `fast` is the quick-turn step of 5 | +| `POST volume/N` | the MASTER knob, 0..100 | +| `POST battery/MV[/USB_MV]` | the battery voltage the screen shows; `USB_MV` above 4300 shows "USB" | +| `POST tuner/HZ` | the pitch the tuner page hears (0: none) | +| `POST bypass/0`, `POST bypass/1` | the bypass: `dsp.bypass`, "BYP" on the screen, the dry input in the sound | +| `POST load/N` | the load in the status text and `dsp.load`, 0..999. The page sets it while the sound runs | +| `GET screen.png[?scale=N]` | the screen as a PNG, 240 x 240 times N (1..8, nearest neighbour) | +| `GET screen.rgb565` | the raw frame buffer: 240 x 240 pixels, 2 bytes each, big endian, row by row | +| `GET flash` | the 1 MiB flash, as it is now | +| `POST reset` | erase the flash (the file too) and restart the pedal: factory presets | +| `POST restart` | restart the pedal on the same flash, as a power cycle | +| `POST midi` | send one MIDI message (SysEx) and return at once. Body: hex (`f07d4f5001f7`, spaces allowed) or a JSON array of bytes | +| `POST sysex[?timeout=MS]` | send one message and wait for its answer: `{"reply": "hex", "ms": N}`, `reply` is `null` after the timeout (default 1000). An editor request (`F0 7D 4F 50 cmd ...`) is answered by the frame with the same `cmd`. One at a time | +| `GET events` | Server-Sent Events: `event: port` (`data:` the port name, empty when none) and `event: midi` (`data:` hex of each message the pedal sends) | + +`state`: + +```json +{"slot": 0, "name": "TWIN CLEAN", "dirty": 0, "saved": 0, "mode": "chain", "block": 5, "item": 0, "volume": 80, + "battery_mv": 3920, "usb_mv": 0, "tuner_hz": 0, "writes_ok": 1, "frame": 35324, + "dsp": {"load": 42, "bypass": 0, "core1": 1}, "last_press": "fs_up", "presses": 1, "knob": 0, "held": [], + "preset": {"name": "TWIN CLEAN", "on": [..12], "type": [..12], "order": [..12], "p": [[..5] x 12]}} +``` + +`slot` is 0..51 (the pedal shows 1..52); `dirty`: the sound differs from its slot (the pedal's "EDITED"); `saved`: the +slot holds a saved preset (0: factory); `mode`: the page, one of `chain` `block` `edit` `tuner` `save` `move`; `block`: +the focused block (0 GATE, 1 COMP, 2 PITCH, 3 SFX, 4 DRIVE, 5 AMP, 6 CAB, 7 EQ, 8 MOD, 9 DELAY, 10 REVERB, 11 SYN); +`writes_ok` is 0 once the firmware tried to write outside the preset area of the flash; `frame` counts the drawing +calls: it changes when the screen does; `knob`: the detents PARA has turned since the start; `held`: the buttons that are +down (`POST down`). `preset` is the current sound, as in `docs/EDITOR-PROTOCOL.md`. + +Examples: + +```sh +curl -X POST localhost:8766/api/press/fs_up # next preset +curl -X POST localhost:8766/api/press/amp # the AMP page +curl -X POST "localhost:8766/api/knob/2" # select the first setting +curl -X POST localhost:8766/api/press/push # edit it +curl -X POST "localhost:8766/api/knob/8" # +8 +curl -s localhost:8766/api/state +curl -s "localhost:8766/api/screen.png?scale=2" -o screen.png +curl -s -X POST localhost:8766/api/sysex -d 'f0 7d 4f 50 01 f7' # the editor protocol's INFO +``` + +## The flash + +The flash is a file (`build/sim/flash.bin`, or `--flash`). The pedal writes it after every change, whole or not at all, +so a session survives a restart, a crash and Ctrl-C. Save a preset (hold PUSH, turn the knob, PUSH), stop the simulator, +start it again: the preset is there. `POST reset` or `--reset` starts again from the factory presets. The file is a 1 MiB image; only its preset area (0xF0000 to 0xF4000) is ever written. + +## The installer + +The installer needs the update code, which is a different program, so `--update FLASH.bin` starts it instead of the +pedal: `tests/devsim.c` answers the handshake and runs the update of `ota.c` and `ldr_core.c` on a copy of the image in +memory (the file is never written; the result goes to `build/sim/update-dump.bin` when the simulator stops). The +port renames itself as the real pedal's does ("OpenPocket" -> "OpenPocket Update" -> "OpenPocket"); the shim reports each +change as a Web MIDI `statechange`. Open `/site/index.html`. `GET info` shows the program's `log` (the steps it took). +Controls, screen and state answer 409 in this mode. `tests/fixtures.py DIR` builds a flash image (`flash_old.bin`) and +fake packages for a try. + +## Tests + +`tests/test_audio.mjs` feeds a test tone and each loop through the WebAssembly engine and the native build for all 52 presets (identical, all finite), checks each loop against `manifest.json` and `ATTRIBUTION.md`, and +(with `EVIDENCE_DIR` set) writes the largest difference per preset and signal to `audio-parity.tsv`; +`tests/test_audio_page.mjs` drives the Sound section in Chromium with a fake audio input (with Playwright installed). +`tests/test_sim.mjs` (part of `make test`) starts the simulator and checks every row above, then runs the real editor +(`docs/pxedit.js`) and installer (`docs/pxota.js`) code over the shim. `tests/test_pedal_input.mjs` checks the skin's input +mapping, and `tests/test_pedal_page.mjs` drives the skin in Chromium when Playwright is installed. 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/pxedit.js b/docs/pxedit.js index 0600886..7ba0b19 100644 --- a/docs/pxedit.js +++ b/docs/pxedit.js @@ -6,7 +6,7 @@ import { PORT_RE } from "./pxota.js"; const H = [0x7D, 0x4F, 0x50]; export const C = { INFO: 1, CAT_BLOCK: 2, CAT_TYPE: 3, TEXTS: 4, STATE: 5, SET: 6, ORDER: 7, NAME: 8, PUT: 9, SELECT: 10, - FOCUS: 11, LIST: 12, SLOT_GET: 13, SLOT_PUT: 14, COMMIT: 15, SAVE: 16, WATCH: 17, PING: 18 }; + FOCUS: 11, LIST: 12, SLOT_GET: 13, SLOT_PUT: 14, COMMIT: 15, SAVE: 16, WATCH: 17, PING: 18, DIAG: 19 }; export const P_SIZE = 110; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function fail(code, msg) { const e = new Error(msg); e.code = code; return e; } @@ -116,6 +116,11 @@ export class Editor { } return null; } + // the log channel: its text lines; a timeout when this firmware has none (only the Bluetooth test image answers) + async diag() { + const r = new Reader(await this.request(C.DIAG, [], 600)); + return Array.from({ length: r.b() }, () => r.s()); + } close() { if (this.link) { try { this.link.input.onmidimessage = null; } catch (_) { } } this.link = null; diff --git a/docs/pxota.js b/docs/pxota.js index c6758ff..36ce326 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 @@ -116,7 +116,7 @@ async function handshake(link, tries = 3) { // timings (ms) as the hardware needs them; the tests shorten them export const TIMING = { afterUpgrade: 2000, afterStep1: 3000, loaderWait: 30000, step1Idle: 8000, step2Idle: 180000, - afterWrite: 3000, backWait: 40000, perRequest: 10, poll: 1000 }; + afterWrite: 3000, backWait: 40000, perRequest: 10, poll: 1000, rescuePoll: 100 }; export class Updater { constructor(access, log, timing = {}) { @@ -214,6 +214,33 @@ export class Updater { return this.installOn(dev, pkg.image, pkg.product, step); } + // rescue for a program with no button to press again (the command line): rescue() again and again until a pedal is + // installed or timeoutMs has passed. The time bounds the waiting only; an install that has started is never cut short. + // A pedal that vanished before the first transfer finished has had nothing written (stopped, lost): wait for it again. + // The update loader that a lost attempt left behind is resumed with the package of the pedal that was picked, else with + // `loader` ({ image, product }): a loader does not report its model, so the caller names the package. + // onEvent("wait", msLeft) and onEvent("retry", error) report on the way. Returns the version the pedal reports, or null + // when no pedal answered in time. + async rescueWithin(pick, onStep, { timeoutMs, onEvent, loader = null } = {}) { + const end = Date.now() + timeoutMs, tell = onEvent || (() => {}); + let pkg = null; + for (;;) { + try { + const done = await this.rescue((model) => (pkg = pick(model)), onStep); + if (done) return done; + const half = pkg || loader; + if (half && await this.resume(half.image, half.product, onStep)) return half.product; + } catch (e) { + if (!["stopped", "lost", "noloader"].includes(e.code)) throw e; + tell("retry", e); + } + const left = end - Date.now(); + if (left <= 0) return null; + tell("wait", left); + await sleep(Math.min(this.t.rescuePoll, left)); + } + } + async installOn(dev, image, product, step) { step("start", dev.id.text); dev.link.send(UPGRADE); 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/engine/px_wasm.c b/engine/px_wasm.c new file mode 100644 index 0000000..6b608d0 --- /dev/null +++ b/engine/px_wasm.c @@ -0,0 +1,56 @@ +/* SPDX-License-Identifier: GPL-3.0-only + * OpenPocket — the pedal's sound in a browser: engine/px_dsp.c and firmware/px_math.c, built to WebAssembly with no libc + * (tools/op.mjs wasm -> tools/sim/px_engine.wasm), driven by tools/sim/px-engine.js. docs/SIMULATOR.md. + * + * px_process() is dsp_block() of firmware/px_audio.h, without the I2S and the load measurement: 128 frames as two + * blocks of 64, bypass, the NaN net, the tuner's mute, MASTER and the clip at full scale. If that function changes, + * change this one with it. audio_apply(), audio_reset() and audio_volume() are mirrored the same way. */ +#include "px_dsp.h" + +#define A_FR 128u +_Static_assert(sizeof(px_preset_t) == 110, "tools/sim/px-engine.js writes the preset byte by byte"); + +static px_engine g_eng; +static px_preset_t preset; +static float a_in[A_FR], a_out[A_FR]; +static float a_gain = 0.64f, a_mg = 1.0f; +static int a_bypass, a_tmute, a_nan; + +void *memcpy(void *d, const void *s, unsigned long n) { char *a = d; const char *b = s; while (n--) *a++ = *b++; return d; } +void *memset(void *d, int c, unsigned long n) { char *a = d; while (n--) *a++ = (char)c; return d; } + +#define EXPORT(name) __attribute__((export_name(#name))) + +EXPORT(px_input) float *px_input(void) { return a_in; } +EXPORT(px_output) float *px_output(void) { return a_out; } +EXPORT(px_preset) px_preset_t *px_preset(void) { return &preset; } +EXPORT(px_factory) void px_factory(int idx) { px_preset_factory(&preset, idx); } +EXPORT(px_apply) void px_apply(void) { px_engine_set(&g_eng, &preset); } +EXPORT(px_reset) void px_reset(void) { px_engine_init(&g_eng, 44100.0f); px_engine_set(&g_eng, &preset); a_nan = 0; } +EXPORT(px_volume) void px_volume(int vol) { float v = (float)vol * 0.01f; a_gain = v * v; } +EXPORT(px_bypass) void px_bypass(int on) { a_bypass = on; } +EXPORT(px_tuner_mute) void px_tuner_mute(int on) { a_tmute = on; } + +EXPORT(px_process) void px_process(void) +{ + if (a_nan) px_reset(); + if (!a_bypass) { + for (unsigned o = 0; o < A_FR; o += PX_MAX_BLOCK) px_engine_process(&g_eng, a_in + o, a_out + o, PX_MAX_BLOCK); + } else { + for (unsigned i = 0; i < A_FR; i++) a_out[i] = a_in[i]; + } + for (unsigned i = 0; i < A_FR; i++) { + float v = a_out[i]; + if (v != v || v > 1.0e6f || v < -1.0e6f) { a_nan = 1; break; } + } + if (a_nan) for (unsigned i = 0; i < A_FR; i++) a_out[i] = 0.0f; + float tg = a_tmute ? 0.0f : 1.0f; + if (a_mg != tg) { + for (unsigned i = 0; i < A_FR; i++) { a_mg += (tg - a_mg) * 0.005f; a_out[i] *= a_mg; } + if (tg > 0.5f ? a_mg > 0.999f : a_mg < 0.0005f) a_mg = tg; + } else if (tg == 0.0f) for (unsigned i = 0; i < A_FR; i++) a_out[i] = 0.0f; + for (unsigned i = 0; i < A_FR; i++) { + float v = a_out[i] * a_gain; + a_out[i] = v > 1.0f ? 1.0f : v < -1.0f ? -1.0f : v; + } +} diff --git a/engine/wasm.sh b/engine/wasm.sh new file mode 100755 index 0000000..8a2770d --- /dev/null +++ b/engine/wasm.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# The engine as WebAssembly, for the simulated pedal's sound: tools/sim/px_engine.wasm and, next to it, the SHA-256 of the +# sources it was built from (tests/test_audio.mjs fails when they differ). In the container: tools/op.mjs wasm. +set -e +cd "$(dirname "$0")/.." +SRC="engine/px_wasm.c engine/px_dsp.c firmware/px_math.c" +O=build/wasm; mkdir -p "$O" +for f in $SRC; do + clang --target=wasm32 -O2 -fno-builtin -ffp-contract=off -nostdlibinc -Iengine -Ifirmware/inc -Wall -Wno-unused-function -c "$f" -o "$O/$(basename "$f" .c).o" +done +wasm-ld --no-entry --strip-all -z stack-size=65536 "$O"/*.o -o tools/sim/px_engine.wasm +cat $SRC engine/px_dsp.h engine/px_trim.h | sha256sum | cut -d' ' -f1 > tools/sim/px_engine.wasm.sources +chmod 644 tools/sim/px_engine.wasm +ls -l tools/sim/px_engine.wasm diff --git a/firmware/app.ld b/firmware/app.ld index fb14fa1..49e4016 100644 --- a/firmware/app.ld +++ b/firmware/app.ld @@ -3,8 +3,10 @@ /* Memory map of a standalone FM-1 app: code and constants run in place from XIP * (app.bin offset 0 = 0x02000120); .data is copied to RAM by fm1_cstart, and so is .ram_text (code that runs * while the flash is busy: the flash driver) into RAMTEXT, the guard's RAM code window. + * The ble build (OP_BLE=1) adds the JieLi controller's own sections: .*_code and .*_const (XIP), .*_data, .*_bss (RAM). * Stacks sit above the RAM with 256-byte guard bands; NOINIT survives resets. * 0x01C7FD50.. is reserved (boot info, mailbox, vector table). */ +PROVIDE(op_guard_stack = 1); /* build.sh passes --defsym op_guard_stack=0 with OP_GUARD_STACK=0 */ MEMORY { XIP (rx) : ORIGIN = 0x02000120, LENGTH = 0x8DFBC @@ -15,17 +17,22 @@ MEMORY } SECTIONS { - .text : { KEEP(*(.text.crt0)) *(.text*) *(.rodata*) . = ALIGN(4); _etext = .; } > XIP + .text : { KEEP(*(.text.crt0)) *(.text*) *(.rodata*) *(.*_code) *(.*_const) . = ALIGN(4); _etext = .; } > XIP .ram_text : { _rt_start = .; KEEP(*(.ram_text*)) . = ALIGN(4); _c1_start = .; KEEP(*(.c1_text*)) . = ALIGN(4); _c1_end = .; /* the second core's loop */ _rt_end = .; } > RAMTEXT AT > XIP _rt_load = LOADADDR(.ram_text); - .data : { . = ALIGN(4); _data_start = .; *(.data*) *(.sdata*) . = ALIGN(4); _data_end = .; } > RAM AT > XIP + .data : { . = ALIGN(4); _data_start = .; *(.data*) *(.sdata*) *(.*_data) . = ALIGN(4); _data_end = .; } > RAM AT > XIP _data_load = LOADADDR(.data); - .bss (NOLOAD) : { _bss_start = .; *(.bss*) *(.sbss*) *(COMMON) . = ALIGN(4); _bss_end = .; } > RAM + .bss (NOLOAD) : { _bss_start = .; _ble_stack_lo = .; *(.bss.ble_stack) _ble_stack_hi = .; *(.bss*) *(.sbss*) *(.*_bss) *(COMMON) . = ALIGN(4); _bss_end = .; } > RAM .noinit (NOLOAD) : { *(.noinit*) } > NOINIT .pool (NOLOAD) : { . = ALIGN(8); *(.pool*) } > POOL _guard0 = 0x01C74000; _ustack_lo = 0x01C74100; _ustack_top = 0x01C7A000; _guard1 = 0x01C7A000; _sstack_lo = 0x01C7A100; _sstack_top = 0x01C7C000; + /* The hardware stack guard (fm1_guard.h) faults any sp outside [_ustack_lo, _sstack_top]. The Bluetooth test image's task + * stacks (ble_os.c) are in RAM below that window, so it must be linked with op_guard_stack=0 (build.sh); with the guard on + * this link fails instead of the pedal crash-looping. Empty in the normal firmware. */ + ASSERT(op_guard_stack == 0 || _ble_stack_lo == _ble_stack_hi || (_ble_stack_lo >= _ustack_lo && _ble_stack_hi <= _sstack_top), + "BLE task stacks lie outside the stack-guard window while the stack guard is enabled") /DISCARD/ : { *(.comment) *(.note*) } } diff --git a/firmware/ble/ble_adv.h b/firmware/ble/ble_adv.h new file mode 100644 index 0000000..87330b4 --- /dev/null +++ b/firmware/ble/ble_adv.h @@ -0,0 +1,27 @@ +/* SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 */ +/* The advertisement the Bluetooth test image sends, as the Bluetooth Core Specification wants it (Vol 3 Part C 11, Vol 6 + * Part B 2.3, Vol 4 Part E 7.8.5-7.8.9). No hardware access: tests/test_ble_status.c checks it on the PC. + * + * PDU ADV_SCAN_IND: scannable, not connectable. Scan requests are answered; no connection is accepted. + * interval 0x00A0 x 0.625 ms = 100 ms (min = max). ADV_SCAN_IND needs at least 100 ms in Core 4.0 and 20 ms since 4.1. + * channels 37, 38, 39 (map 0x07). filter policy 0: scans from anybody. + * address the controller's public address slot (the library's own-address type is a build setting of its HCI layer, + * 0 = public, and cannot be changed from here). Left alone it is zero, which is not a valid device address; + * it is set to a fixed, non-zero, locally administered unicast address. Not an IEEE-assigned one: a test image. + * advertising data flags (LE general discoverable, BR/EDR not supported) + complete list of 128-bit service UUIDs + * (BLE-MIDI 03B80E5A-EDE8-4B33-A751-6CE34EC4C700, little endian): 21 of the 31 bytes + * scan response complete local name "OpenPocket-BLE-TEST": 21 bytes + * Transmit power is the library's own power table (no stored trims are read: docs/BLE-TEST.md). */ +#pragma once +#include + +enum { BLE_ADV_SCAN_IND = 2, BLE_ADV_INTERVAL = 0x00A0, BLE_ADV_CHMAP = 7, BLE_ADV_FILTER = 0 }; + +static uint8_t ble_adv_data[] = { + 2, 0x01, 0x06, /* flags: LE general discoverable, no BR/EDR */ + 17, 0x07, /* complete list of 128-bit service UUIDs, little endian */ + 0x00, 0xC7, 0xC4, 0x4E, 0xE3, 0x6C, 0x51, 0xA7, 0x33, 0x4B, 0xE8, 0xED, 0x5A, 0x0E, 0xB8, 0x03, +}; +static uint8_t ble_scan_rsp[] = { 20, 0x09, 'O','p','e','n','P','o','c','k','e','t','-','B','L','E','-','T','E','S','T' }; +static uint8_t ble_adv_mac[6] = { 0x54, 0x4C, 0x42, 0x50, 0x4F, 0x02 }; /* 02:4F:50:42:4C:54, little endian: bit 1 of the first octet = locally administered, bit 0 = unicast */ +static uint8_t ble_no_addr[6]; /* direct address: unused by ADV_SCAN_IND, but the library reads six bytes */ diff --git a/firmware/ble/ble_cfg.c b/firmware/ble/ble_cfg.c new file mode 100644 index 0000000..53db78e --- /dev/null +++ b/firmware/ble/ble_cfg.c @@ -0,0 +1,60 @@ +/* SPDX-License-Identifier: Apache-2.0 */ +/* Controller configuration for the advertise-only image: the values the JieLi SDK's own + * apps/common/config/log_config/lib_btctrler_config.c (fw-AC79_AIoT_SDK V1.2.0, Apache-2.0, see + * third_party/jieli-ble/) gives a Bluetooth LE only build, reduced to LE advertising and one slave link. The names and + * types are the library's; the values for features we do not build are the SDK's defaults. */ +#include + +#define BIT(n) (1u << (n)) +enum { BT_MODULE_CLASSIC = BIT(0), BT_MODULE_LE = BIT(1) }; +enum { BT_NORMAL = 0x01 }; +enum { LE_MASTER = BIT(0), LE_SLAVE = BIT(1), LE_ADV = BIT(2), LE_SCAN = BIT(3), LE_INIT = BIT(4) }; + +const int config_btctler_modules = BT_MODULE_LE; +int config_btctler_mode = BT_NORMAL; +int config_btctler_hci_standard = 0; +const int config_bt_function = 0; +const int config_btctler_le_tws = 0; +const int CONFIG_BTCTLER_TWS_ENABLE = 0; +const int CONFIG_TWS_AFH_ENABLE = 0; +const int CONFIG_WIFI_DETECT_ENABLE = 0; +const int ESCO_FORWARD_ENABLE = 0; +const int CONFIG_LOW_LATENCY_ENABLE = 0; +const int CONFIG_ESCO_MUX_RX_BULK_ENABLE = 0; +const int CONFIG_BTCTLER_FAST_CONNECT_ENABLE = 0; +const int CONFIG_LMP_NAME_REQ_ENABLE = 1; +const int CONFIG_LMP_PASSKEY_ENABLE = 1; +const int CONFIG_LMP_MASTER_ESCO_ENABLE = 1; +const int CONFIG_INQUIRY_PAGE_OFFSET_ADJUST = 0; +const int config_force_bt_pwr_tab_using_normal_level = 0; /* 0: the power table of each mode (the stock trims) */ +unsigned char config_btctler_bredr_master = 0; +const int config_btctler_dual_a2dp = 0; +const int config_btctler_role_switch_fail_disconnect = 1; +const int config_btctler_role_switch_fail_back_to_slave = 0; +const int config_bredr_afh_user = 0; +const uint8_t rx_fre_offset_adjust_enable = 1; +const int config_fix_fre_enable = 0; +const int ble_disable_wait_enable = 1; +const int config_delete_link_key = 1; +const int config_btstask_auto_exit_sniff = 1; +const int CONFIG_TEST_DUT_CODE = 0; +const int CONFIG_TEST_FCC_CODE = 0; +const int CONFIG_TEST_DUT_ONLY_BOX_CODE = 0; +const int CONFIG_BREDR_INQUIRY = 0; + +const uint64_t config_btctler_le_features = 0; /* no encryption: no connections in this image */ +const int config_btctler_le_roles = LE_ADV | LE_SLAVE; +const int config_btctler_le_afh_en = 0; +const int config_btctler_le_hw_ram_use_static = 1; +const int config_btctler_le_master_multilink = 0; +const int config_multilink_support_esco_enalbe = 0; +const int config_btctler_le_slave_multilink = 1; +const int config_btctler_le_hw_nums = 1; +const int config_btctler_le_rx_nums = 5 * 1; +const int config_btctler_le_acl_packet_length = 27; +const int config_btctler_le_acl_total_nums = 5 * 1; +const uint32_t config_vendor_le_bb = 0; +const int config_btctler_le_slave_conn_update_winden = 2500; +const int config_btctler_single_carrier_en = 0; +const int sniff_support_reset_anchor_point = 0; +const int config_bt_security_vulnerability = 0; diff --git a/firmware/ble/ble_ctx.S b/firmware/ble/ble_ctx.S new file mode 100644 index 0000000..d526bb7 --- /dev/null +++ b/firmware/ble/ble_ctx.S @@ -0,0 +1,29 @@ +/* SPDX-License-Identifier: MPL-2.0 */ +/* Context switch for the cooperative scheduler in ble_os.c. A task is suspended by pushing the registers a C call must + * preserve (rets and r4-r15: every register a compiled function saves, the list its own prologue pushes) on its own stack and storing the stack + * pointer; it is resumed by loading that pointer and popping the same list. Push and pop are the same instruction pair + * the compiler emits, so the order in memory does not matter. + * + * void ble_ctx_swap(uint32_t **save, uint32_t *load) suspend here into *save, resume the context in load + * void ble_ctx_enter(uint32_t **save, uint32_t *top, void (*fn)(void)) + * suspend here into *save, call fn on the stack at top + * (fn never returns) + */ + .section .text.ble_ctx,"ax",@progbits + .balign 4 + .globl ble_ctx_swap +ble_ctx_swap: + [--sp] = {rets, r15-r4} + r2 = sp + [r0 + 0] = r2 + sp = r1 + {pc, r15-r4} = [sp++] + + .globl ble_ctx_enter +ble_ctx_enter: + [--sp] = {rets, r15-r4} + r3 = sp + [r0 + 0] = r3 + sp = r1 + call r2 + goto . diff --git a/firmware/ble/ble_diag.h b/firmware/ble/ble_diag.h new file mode 100644 index 0000000..0ec41db --- /dev/null +++ b/firmware/ble/ble_diag.h @@ -0,0 +1,47 @@ +/* SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 */ +/* The Bluetooth test image's log channel (DIAG in docs/EDITOR-PROTOCOL.md, node tools/op.mjs log): this boot and the boot + * before it as text, so that the "LAST BOOT FAILED" report can be read from a computer as well as photographed. The + * Bluetooth line itself is ble_test_line, the text of the status area. No hardware access, so tests/test_ble_status.c + * compiles it on the PC. */ +#pragma once +#include "ble_status.h" + +/* the previous boot, kept before app_main clears op_diag for this one (the fields of its report; stage: ST_*) */ +typedef struct { uint32_t failed, crashed, stage, blocks, load, peak, nans, c1, loops, vec, pc, rets, dbg, emu, uptime; } ble_boot_t; +static ble_boot_t ble_last; + +/* "UP 12345 SAFE 0 STAGE 8 RST 40 GUARD 0" (out: 64 bytes): ms_now (it wraps every 179 s), safe mode, ST_*, the reset source bits, the failed boots counted */ +static void ble_boot_text(char *out, uint32_t up, uint32_t safe, uint32_t stage, uint32_t rst, uint32_t guard_failed) +{ + char *o = out; + o = ble__put(o, "UP "); o = ble__dec(o, up); + o = ble__put(o, " SAFE "); o = ble__dec(o, safe); + o = ble__put(o, " STAGE "); o = ble__dec(o, stage); + o = ble__put(o, " RST "); o = ble__hex(o, rst); + o = ble__put(o, " GUARD "); o = ble__dec(o, guard_failed); + *o = 0; +} + +/* "LAST OK", or "LAST FAILED STAGE 7 BLOCKS 9 LOAD 42 PEAK 61 NAN 0 CPU1 1 LOOPS 0", which a crash continues with + * " CRASH VEC 3F PC 2000DC8 RETS 2001234 DBG 0 EMU 0 UP 4100" (out: 224 bytes, for the widest values) */ +static void ble_last_text(char *out, const ble_boot_t *b) +{ + char *o = out; + if (!b->failed) { ble__put(o, "LAST OK")[0] = 0; return; } + o = ble__put(o, "LAST FAILED STAGE "); o = ble__dec(o, b->stage); + o = ble__put(o, " BLOCKS "); o = ble__dec(o, b->blocks); + o = ble__put(o, " LOAD "); o = ble__dec(o, b->load); + o = ble__put(o, " PEAK "); o = ble__dec(o, b->peak); + o = ble__put(o, " NAN "); o = ble__dec(o, b->nans); + o = ble__put(o, " CPU1 "); o = ble__dec(o, b->c1); + o = ble__put(o, " LOOPS "); o = ble__dec(o, b->loops); + if (b->crashed) { + o = ble__put(o, " CRASH VEC "); o = ble__hex(o, b->vec); + o = ble__put(o, " PC "); o = ble__hex(o, b->pc); + o = ble__put(o, " RETS "); o = ble__hex(o, b->rets); + o = ble__put(o, " DBG "); o = ble__hex(o, b->dbg); + o = ble__put(o, " EMU "); o = ble__hex(o, b->emu); + o = ble__put(o, " UP "); o = ble__dec(o, b->uptime); + } + *o = 0; +} diff --git a/firmware/ble/ble_isr.S b/firmware/ble/ble_isr.S new file mode 100644 index 0000000..bd4ca3c --- /dev/null +++ b/firmware/ble/ble_isr.S @@ -0,0 +1,29 @@ +/* SPDX-License-Identifier: MPL-2.0 */ +/* The two baseband interrupts of the Bluetooth test image, counted. The library's own interrupt functions are complete + * interrupt entries (they save the registers and end in rti), so this cannot be a C function that calls them: it counts, + * restores the two registers it used and jumps to the library's function, which then runs as if it had been entered + * directly. The jump target is on the stack below the saved registers so that no register is left over. It uses add, load + * and store only (the compiler's own compares are fused with their branches), so the flags the library saves are those + * of the interrupted code. + * + * ble_isr_ev IRQ 45 counts ble_cnt_irq_ev, jumps to *ble_irq_target_ev + * ble_isr_rx IRQ 29 counts ble_cnt_irq_rx, jumps to *ble_irq_target_rx (ble_os.c request_irq sets them up) */ + .macro BLE_ISR name, count, target + .section .text.\name,"ax",@progbits + .globl \name +\name: + sp += -4 + [--sp] = {r1-r0} + r0 = \count + r1 = [r0+0] + r1 += 1 + [r0+0] = r1 + r0 = \target + r0 = [r0+0] + [sp+8] = r0 + {r1-r0} = [sp++] + {pc} = [sp++] + .endm + + BLE_ISR ble_isr_ev, ble_cnt_irq_ev, ble_irq_target_ev + BLE_ISR ble_isr_rx, ble_cnt_irq_rx, ble_irq_target_rx diff --git a/firmware/ble/ble_libc.c b/firmware/ble/ble_libc.c new file mode 100644 index 0000000..130d099 --- /dev/null +++ b/firmware/ble/ble_libc.c @@ -0,0 +1,27 @@ +/* SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 */ +/* The C library routines and diagnostics hooks the controller library calls and px_audio.h does not already supply + * (log_print, printf, config_asser, cpu_assert_debug, P33_SYSTEM_RESET, os_current_task_rom). Logging is switched off in + * the library by its log tags (ble_stubs.c), so the print functions here do nothing. */ +#include +#include +#include "ble_os.h" + +size_t strlen(const char *s) { const char *p = s; while (*p) p++; return (size_t)(p - s); } +int strcmp(const char *a, const char *b) { while (*a && *a == *b) { a++; b++; } return (uint8_t)*a - (uint8_t)*b; } +char *strcpy(char *d, const char *s) { char *r = d; while ((*d++ = *s++)) {} return r; } +char *strncpy(char *d, const char *s, size_t n) { size_t i = 0; for (; i < n && s[i]; i++) d[i] = s[i]; for (; i < n; i++) d[i] = 0; return d; } +void *memmove(void *d, const void *s, size_t n) +{ + uint8_t *dp = d; + const uint8_t *sp = s; + if (dp < sp) + while (n--) *dp++ = *sp++; + else + for (dp += n, sp += n; n--;) *--dp = *--sp; + return d; +} + +void printf_buf(const uint8_t *b, uint32_t n) { (void)b; (void)n; } +void put_buf(const uint8_t *b, uint32_t n) { (void)b; (void)n; } +int puts(const char *s) { (void)s; return 0; } +int putchar(int c) { return c; } diff --git a/firmware/ble/ble_os.c b/firmware/ble/ble_os.c new file mode 100644 index 0000000..342f09a --- /dev/null +++ b/firmware/ble/ble_os.c @@ -0,0 +1,382 @@ +/* SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 */ +/* The few operating-system services the JieLi Bluetooth controller library (third_party/jieli-ble) calls, written for + * OpenPocket's main loop: a cooperative scheduler for the controller's tasks, task message queues, semaphores and + * mutexes, software timers, a heap, and the interrupt registration. Signatures and error numbers follow the SDK's + * public headers (include_lib/system/os/os_api.h, os_error.h, timer.h, asm/hwi.h; Apache-2.0). + * + * Tasks never run by themselves: ble_poll() (called from the main loop) resumes every task that is ready and returns + * when all of them are waiting again. A task gives up the processor only inside os_taskq_pend, os_sem_pend, + * os_mutex_pend or os_time_dly; interrupts only post messages and semaphores. */ +#include +#include +#include "fm1_irq.h" +#include "fm1_time.h" +#include "ble_os.h" +#include "ble_status.h" + +enum { OS_NO_ERR = 0, OS_TIMEOUT = 11, OS_TASKQ = 13, OS_TASK_NOT_EXIST = 14, OS_Q_FULL = 21, OS_Q_EMPTY = 22 }; +enum { OS_TICK_MS = 10 }; /* OS_TICKS_PER_SEC is 100 in the SDK */ + +extern void ble_ctx_swap(uint32_t **save, uint32_t *load); +extern void ble_ctx_enter(uint32_t **save, uint32_t *top, void (*fn)(void)); + +/* ---- interrupts: one level of nesting count, as the SDK's local_irq_disable / local_irq_enable */ +static uint32_t irq_nest; +void local_irq_disable(void) { fm1_irq_off(); irq_nest++; } +void local_irq_enable(void) { if (irq_nest && !--irq_nest) fm1_irq_on(); } +void __local_irq_disable(void) { local_irq_disable(); } +void __local_irq_enable(void) { local_irq_enable(); } +void sys_local_irq_disable(void) { local_irq_disable(); } +void sys_local_irq_enable(void) { local_irq_enable(); } +int cpu_irq_disabled(void) { return !(fm1_icfg() & 0x200u); } /* icfg bit 9: the global enable that cli and sti clear and set */ +int cpu_in_irq(void) { return 0; } + +/* ---- heap: first fit with coalescing on free, 8-byte aligned, one static pool */ +#define HEAP_BYTES (24u * 1024u) +typedef struct blk { uint32_t size; uint32_t used; } blk_t; /* size includes this header */ +static uint8_t heap[HEAP_BYTES] __attribute__((aligned(8))); +static int heap_ready; +static uint32_t heap_free_bytes, heap_min_free; + +static void heap_init(void) +{ + blk_t *b = (blk_t *)heap; + b->size = HEAP_BYTES; + b->used = 0; + heap_free_bytes = heap_min_free = HEAP_BYTES; + heap_ready = 1; +} + +void *malloc(size_t n) +{ + uint32_t need = (uint32_t)((n + sizeof(blk_t) + 7u) & ~7u); + uint8_t *p, *end = heap + HEAP_BYTES; + local_irq_disable(); + if (!heap_ready) + heap_init(); + for (p = heap; p < end; p += ((blk_t *)p)->size) { + blk_t *b = (blk_t *)p; + if (b->used || b->size < need) + continue; + if (b->size >= need + 16u) { /* split */ + blk_t *r = (blk_t *)(p + need); + r->size = b->size - need; + r->used = 0; + b->size = need; + } + b->used = 1; + heap_free_bytes -= b->size; + if (heap_free_bytes < heap_min_free) + heap_min_free = heap_free_bytes; + local_irq_enable(); + return p + sizeof(blk_t); + } + local_irq_enable(); + return 0; +} + +void free(void *ptr) +{ + uint8_t *p, *end = heap + HEAP_BYTES; + blk_t *b; + if (!ptr) + return; + local_irq_disable(); + b = (blk_t *)((uint8_t *)ptr - sizeof(blk_t)); + b->used = 0; + heap_free_bytes += b->size; + for (p = heap; p < end;) { /* merge neighbouring free blocks */ + blk_t *c = (blk_t *)p, *n = (blk_t *)(p + c->size); + if ((uint8_t *)n < end && !c->used && !n->used) + c->size += n->size; + else + p += c->size; + } + local_irq_enable(); +} + +void *zalloc(size_t n) { void *p = malloc(n); if (p) memset(p, 0, n); return p; } +void *ram_malloc(unsigned long n) { return malloc(n); } +void ram_free(void *p) { free(p); } +unsigned int xPortGetPhysiceMemorySize(void) { return HEAP_BYTES; } +uint32_t ble_heap_free(void) { return heap_free_bytes; } +uint32_t ble_heap_min_free(void) { return heap_min_free; } + +/* ---- tasks and their message queues */ +#define MAXTASK 3 /* the library starts one ("btctrler"); room for two more */ +#define STACK_WORDS 1024u /* 4 KB each: the radio start-up replay runs on one */ +#define QDEPTH 16 +#define QARGS 12 /* type + up to 11 words */ +typedef struct { int v[QARGS]; } msg_t; +enum { T_FREE, T_READY, T_WAIT_Q, T_WAIT_SEM, T_SLEEP, T_DEAD }; +typedef struct { + char name[16]; + void (*fn)(void *); + void *arg; + uint32_t *sp; /* saved stack pointer while suspended */ + uint32_t state; + uint32_t wake_ms; /* T_SLEEP, and the timeout of the waits (0: none) */ + void *wait_on; + int timed_out; + msg_t q[QDEPTH]; + uint32_t qh, qn; + uint32_t stack[STACK_WORDS] __attribute__((aligned(8))); +} task_t; +static task_t tasks[MAXTASK] __attribute__((section(".bss.ble_stack"))); /* app.ld brackets it as _ble_stack_lo.._hi */ +static task_t *cur; /* the task being run, 0 in the scheduler */ +static uint32_t *sched_sp; + +static task_t *task_find(const char *name) +{ + int i; + for (i = 0; i < MAXTASK; i++) + if (tasks[i].state != T_FREE && !strcmp(tasks[i].name, name)) + return &tasks[i]; + return 0; +} + +static void task_body(void) +{ + task_t *t = cur; + t->fn(t->arg); + t->state = T_DEAD; + for (;;) + ble_ctx_swap(&t->sp, sched_sp); +} + +static void task_yield(void) { ble_ctx_swap(&cur->sp, sched_sp); } + +int task_create(void (*fn)(void *), void *arg, const char *name) +{ + int i; + for (i = 0; i < MAXTASK; i++) + if (tasks[i].state == T_FREE || tasks[i].state == T_DEAD) + break; + if (i == MAXTASK) + return -1; + memset(&tasks[i], 0, sizeof(task_t) - sizeof(tasks[i].stack)); + strncpy(tasks[i].name, name, sizeof(tasks[i].name) - 1); + tasks[i].fn = fn; + tasks[i].arg = arg; + tasks[i].sp = 0; /* 0: not started, ble_poll enters task_body on first run */ + tasks[i].state = T_READY; + return 0; +} + +int task_kill(const char *name) +{ + task_t *t = task_find(name); + if (!t) + return OS_TASK_NOT_EXIST; + t->state = T_FREE; + return 0; +} + +const char *os_current_task(void) { return cur ? cur->name : "main"; } +/* what a task is blocked on, for the failure line: the semaphore or mutex address, or its state when it is not waiting on one */ +uint32_t ble_task_waiting(const char *name) +{ + task_t *t = task_find(name); + return !t ? 0xFFu : t->state == T_WAIT_SEM ? (uint32_t)(uintptr_t)t->wait_on : t->state; +} + +int os_taskq_post_type(const char *name, int type, int argc, int *argv) +{ + task_t *t = task_find(name); + msg_t *m; + int i; + if (!t) + return OS_TASK_NOT_EXIST; + local_irq_disable(); + if (t->qn == QDEPTH) { + local_irq_enable(); + return OS_Q_FULL; + } + m = &t->q[(t->qh + t->qn++) % QDEPTH]; + m->v[0] = type; + for (i = 0; i < argc && i < QARGS - 1; i++) + m->v[1 + i] = argv[i]; + if (t->state == T_WAIT_Q) + t->state = T_READY; + local_irq_enable(); + return 0; +} + +/* the SDK's os_taskq_pend blocks until a message is queued; argv[0] is the type, argv[1..] the words */ +int os_taskq_pend(const char *fmt, int *argv, int argc) +{ + msg_t m; + int i; + (void)fmt; + if (!cur) + return OS_TIMEOUT; + for (;;) { + local_irq_disable(); + if (cur->qn) { + m = cur->q[cur->qh]; + cur->qh = (cur->qh + 1u) % QDEPTH; + cur->qn--; + local_irq_enable(); + for (i = 0; i < argc && i < QARGS; i++) + argv[i] = m.v[i]; + return OS_TASKQ; + } + cur->state = T_WAIT_Q; + local_irq_enable(); + task_yield(); + } +} + +void os_time_dly(int ticks) +{ + if (!cur) + return; + if (ticks < 1) + ticks = 1; + cur->wake_ms = fm1_uptime_ms() + (uint32_t)ticks * OS_TICK_MS; + cur->state = T_SLEEP; + task_yield(); +} + +/* ---- semaphores and mutexes. The SDK's OS_SEM / OS_MUTEX are opaque and at least 16 bytes: the first word is the + * count (a mutex: 1 free, 0 taken), the second the owner for the mutex. */ +typedef struct { volatile int32_t count; void *owner; } sem_t; + +int os_sem_create(void *s, int cnt) { ((sem_t *)s)->count = cnt; ((sem_t *)s)->owner = 0; return 0; } +int os_sem_set(void *s, uint16_t cnt) { ((sem_t *)s)->count = cnt; return 0; } +int os_sem_post(void *s) +{ + int i; + local_irq_disable(); + ((sem_t *)s)->count++; + for (i = 0; i < MAXTASK; i++) + if (tasks[i].state == T_WAIT_SEM && tasks[i].wait_on == s) + tasks[i].state = T_READY; + local_irq_enable(); + return 0; +} + +int os_sem_pend(void *s, int timeout) +{ + sem_t *sm = s; + uint32_t until = timeout ? fm1_uptime_ms() + (uint32_t)timeout * OS_TICK_MS : 0; + for (;;) { + local_irq_disable(); + if (sm->count > 0) { + sm->count--; + local_irq_enable(); + return 0; + } + if (!cur || (until && (int32_t)(fm1_uptime_ms() - until) >= 0)) { + local_irq_enable(); + return OS_TIMEOUT; + } + cur->state = T_WAIT_SEM; + cur->wait_on = s; + cur->wake_ms = until; + local_irq_enable(); + task_yield(); + } +} + +int os_mutex_create(void *m) { return os_sem_create(m, 1); } +int os_mutex_del(void *m, int force) { (void)m; (void)force; return 0; } +int os_mutex_pend(void *m, int timeout) { return os_sem_pend(m, timeout); } +int os_mutex_post(void *m) { return os_sem_post(m); } + +/* ---- software timers (sys_timer_add: periodic, sys_timeout_add: once), run from ble_poll */ +#define MAXTIMER 16 +typedef struct { + void (*fn)(void *); + void *priv; + uint32_t period, due; + uint8_t used, once; +} timer_t_; +static timer_t_ timers[MAXTIMER]; + +static uint16_t timer_add(void *priv, void (*fn)(void *), uint32_t ms, int once) +{ + int i; + for (i = 0; i < MAXTIMER; i++) + if (!timers[i].used) { + timers[i].fn = fn; timers[i].priv = priv; timers[i].period = ms ? ms : 1u; + timers[i].due = fm1_uptime_ms() + timers[i].period; timers[i].once = (uint8_t)once; timers[i].used = 1; + return (uint16_t)(i + 1); + } + return 0; +} +static void timer_del(uint16_t id) { if (id && id <= MAXTIMER) timers[id - 1].used = 0; } + +uint16_t sys_timer_add(void *p, void (*f)(void *), uint32_t ms) { return timer_add(p, f, ms, 0); } +uint16_t sys_timeout_add(void *p, void (*f)(void *), uint32_t ms) { return timer_add(p, f, ms, 1); } +uint16_t usr_timeout_add(void *p, void (*f)(void *), uint32_t ms, uint8_t prio) { (void)prio; return timer_add(p, f, ms, 1); } +void sys_timer_del(uint16_t id) { timer_del(id); } +void sys_timeout_del(uint16_t id) { timer_del(id); } +void usr_timeout_del(uint16_t id) { timer_del(id); } +void sys_timer_re_run(uint16_t id) { if (id && id <= MAXTIMER && timers[id - 1].used) timers[id - 1].due = fm1_uptime_ms() + timers[id - 1].period; } +int sys_timer_modify(uint16_t id, uint32_t ms) +{ + if (!id || id > MAXTIMER || !timers[id - 1].used) + return -1; + timers[id - 1].period = ms ? ms : 1u; + timers[id - 1].due = fm1_uptime_ms() + timers[id - 1].period; + return 0; +} +void sys_timer_set_user_data(uint16_t id, void *p) { if (id && id <= MAXTIMER) timers[id - 1].priv = p; } +void *sys_timer_get_user_data(uint16_t id) { return id && id <= MAXTIMER ? timers[id - 1].priv : 0; } +uint32_t sys_timer_get_ms(void) { return fm1_uptime_ms(); } + +/* ble_isr.S: counts the baseband interrupts, then continues in the library's own interrupt function (saved here) */ +void (*ble_irq_target_ev)(void), (*ble_irq_target_rx)(void); +extern void ble_isr_ev(void), ble_isr_rx(void); + +/* ---- interrupt registration: the library hands over its own interrupt functions (already complete interrupt entries) */ +void request_irq(uint8_t idx, uint8_t prio, void (*handler)(void), uint8_t cpu) +{ + (void)cpu; + ble_stage_set(BLE_STAGE_BB); /* le_hw_init: the baseband is set up and asks for its interrupts */ + if (idx == 45u) { ble_irq_target_ev = handler; handler = ble_isr_ev; } /* ble_isr.S counts, then runs the library's entry */ + else if (idx == 29u) { ble_irq_target_rx = handler; handler = ble_isr_rx; } + fm1_irq_attach(idx, handler, prio); +} +void unrequest_irq(uint8_t idx, uint8_t cpu) { (void)cpu; FM1_ICFG(idx) &= ~(1u << ((idx & 7u) * 4u)); } +void bit_set_ie(uint8_t idx, uint8_t cpu) { (void)cpu; FM1_ICFG(idx) |= 1u << ((idx & 7u) * 4u); } +void bit_clr_ie(uint8_t idx, uint8_t cpu) { (void)cpu; FM1_ICFG(idx) &= ~(1u << ((idx & 7u) * 4u)); } +uint8_t irq_read(uint32_t idx) { return (uint8_t)((FM1_ICFG(idx) >> ((idx & 7u) * 4u)) & 0xFu); } + +/* ---- the scheduler */ +void ble_poll(void) +{ + int i, ran; + fm1_wdt_feed(); + uint32_t now = fm1_uptime_ms(); + for (i = 0; i < MAXTIMER; i++) { + timer_t_ *t = &timers[i]; + if (t->used && (int32_t)(now - t->due) >= 0) { + if (t->once) + t->used = 0; + else + t->due = now + t->period; + ble_cnt_timer++; + t->fn(t->priv); + } + } + do { + ran = 0; + now = fm1_uptime_ms(); + for (i = 0; i < MAXTASK; i++) { + task_t *t = &tasks[i]; + if ((t->state == T_SLEEP || t->state == T_WAIT_SEM) && t->wake_ms && (int32_t)(now - t->wake_ms) >= 0) + t->state = T_READY; + if (t->state != T_READY) + continue; + cur = t; + ran = 1; + if (!t->sp) + ble_ctx_enter(&sched_sp, t->stack + STACK_WORDS, task_body); + else + ble_ctx_swap(&sched_sp, t->sp); + cur = 0; + } + } while (ran); +} diff --git a/firmware/ble/ble_os.h b/firmware/ble/ble_os.h new file mode 100644 index 0000000..beeb766 --- /dev/null +++ b/firmware/ble/ble_os.h @@ -0,0 +1,26 @@ +/* SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 */ +#pragma once +#include +#include + +/* C library routines the controller library needs (ble_libc.c); memset and memcpy are bsp/chip/libc.c's */ +void *memset(void *d, int c, unsigned n); +void *memcpy(void *d, const void *s, unsigned n); +size_t strlen(const char *s); +int strcmp(const char *a, const char *b); +char *strcpy(char *d, const char *s); +char *strncpy(char *d, const char *s, size_t n); +int memcmp(const void *a, const void *b, unsigned n); +void *memmove(void *d, const void *s, size_t n); +void *malloc(size_t n); +void free(void *p); + +void ble_poll(void); /* the main loop calls this often: runs the controller's timers and tasks */ +uint32_t ble_heap_free(void); +uint32_t ble_heap_min_free(void); + +/* ble_test.c */ +void ble_test_start(void); /* radio on, controller up, one advertisement */ +void ble_test_poll(void); +void ble_test_adv_line(char *out); /* the long advertising line: counters and baseband registers (ble_status.h) */ +void ble_test_state(char *out); /* the short status text for the screen (ble_status.h), 24 bytes */ diff --git a/firmware/ble/ble_rf.c b/firmware/ble/ble_rf.c new file mode 100644 index 0000000..b46af4f --- /dev/null +++ b/firmware/ble/ble_rf.c @@ -0,0 +1,34 @@ +/* SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 */ +/* The part of the radio start-up that the JieLi controller library leaves to the Wi-Fi library: its bt_rf_init (RF.c) + * first calls wf_rf_common_init, which brings up the shared Wi-Fi front end (about 34,000 register writes). Linking + * the Wi-Fi library for that would pull in its SDIO/MAC stack, so the writes come from the committed capture of the + * stock start-up instead (bsp/chip/ble_rf_tables_v15.h, run by bsp/chip/fm1_ble_rf.h: stock's boot-2 order, groups 2 + * to 13), with this unit's trims measured by fm1_rf_cal.h (docs/BLE-TEST.md, "What is not verified"). + * + * The saved radio records (VM items 106, 107, 108, 187) are not read: the VM stays untouched. The records are given + * as zeros, which leaves the crystal trim (106) and the other stored fields at the captured defaults. */ +#include "ble_os.h" +#include "ble_status.h" +#include "fm1_ble_rf.h" + +static const uint8_t no_record[68]; + +void wf_rf_common_init(void) +{ + ble_stage_set(BLE_STAGE_RF); + fm1_wdt_feed(); /* the replay takes a while: keep the watchdog fed around it */ + fm1_rf_cal_on = 1; + ble_busy(0); /* the replay is long, and its waits are bounded */ + fm1_ble_rf_run(no_record, no_record, no_record, no_record); + ble_busy(1); + fm1_wdt_feed(); + ble_stage_set(BLE_STAGE_RF_DONE); /* the replay returned: what follows is the library's own start-up */ +} + +/* The library asks for saved items (trims, bonding): none, as in audio_probe.h. It stores nothing: the VM and BTIF + * areas are never written by this image. */ +int syscfg_read(uint16_t id, void *buf, uint16_t len) { (void)id; (void)buf; (void)len; return -1; } +int syscfg_write(uint16_t id, void *buf, uint16_t len) { (void)id; (void)buf; return len; } +void hw_udelay(uint32_t us) { fm1_delay_us(us); fm1_wdt_feed(); } +uint32_t sys_clk_get(void) { return 360000000u; } /* SYS_CLK in the stock isd_config.ini */ +uint8_t boot_info[64]; /* read by the library's chip-id helpers only */ diff --git a/firmware/ble/ble_rf_tables.h b/firmware/ble/ble_rf_tables.h new file mode 100644 index 0000000..5d84e3a --- /dev/null +++ b/firmware/ble/ble_rf_tables.h @@ -0,0 +1,3 @@ +/* SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 */ +/* fm1_ble_rf.h includes the radio start-up program as "ble_rf_tables.h"; here it is the committed capture. */ +#include "../bsp/chip/ble_rf_tables_v15.h" diff --git a/firmware/ble/ble_status.h b/firmware/ble/ble_status.h new file mode 100644 index 0000000..0df09d2 --- /dev/null +++ b/firmware/ble/ble_status.h @@ -0,0 +1,160 @@ +/* SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 */ +/* What the Bluetooth test image shows on the screen (docs/BLE-TEST.md, "Reading the status text"): where start-up has got + * to, and, when it failed, which step and which return value. No hardware access, so tests/test_ble_status.c compiles it + * on the PC. + * + * STARTING the last stage reached (BLE_STAGE_*): a timeout names the stage it stalled in + * FAILED the step that failed (BLE_FAIL_*) and its return value; for BLE_FAIL_TIMEOUT the code is the stage + * ADVERTISING the controller accepted the command to advertise (not proof of a packet: see the proof counters below) + * and the marquee that scrolls the full failure line across the small status area. */ +#pragma once +#include +#include + +enum { BLE_FAIL_NONE, BLE_FAIL_TASK_CREATE, BLE_FAIL_INIT, BLE_FAIL_READY, BLE_FAIL_ADV_ENABLE, BLE_FAIL_TIMEOUT }; +enum { BLE_STAGE_IDLE, BLE_STAGE_TASK, BLE_STAGE_INIT, BLE_STAGE_RF, BLE_STAGE_READY, BLE_STAGE_ADV, BLE_STAGE_ENABLE, + BLE_STAGE_RF_DONE, BLE_STAGE_BB }; +enum { BLE_OFF, BLE_STARTING, BLE_ADV, BLE_FAILED }; + +static volatile uint32_t ble_state; +static volatile uint32_t ble_stage; +static volatile uint32_t ble_fail_step; +static volatile int32_t ble_fail_code; +static volatile uint32_t ble_wait; /* at a timeout: what the controller task is blocked on (ble_task_waiting) */ + +static void ble_stage_set(uint32_t stage) { ble_stage = stage; } + +/* The stall watch. The scheduler is cooperative, so library code that never returns (a failed assertion parks in + * P33_SYSTEM_RESET, a wait on a register bit) holds the main loop until the watchdog resets, and that leaves no trace. + * The 10 kHz tick (px_usb.h) therefore counts while a ble_poll runs; one that has not returned after BLE_STALL_TICKS + * makes the next tick the crash screen, whose PC and RETS name the code (VEC 63). 0: no poll running, or held off. */ +enum { BLE_STALL_TICKS = 40000 }; /* 4 s: before the watchdog (8 s) */ +static volatile uint32_t ble_busy_ticks; +static void ble_busy(int on) { ble_busy_ticks = on ? 1u : 0u; } +static int ble_stall_tick(void) { return ble_busy_ticks && ++ble_busy_ticks > BLE_STALL_TICKS; } + +static void ble_fail(uint32_t step, int32_t code) +{ + ble_fail_step = step; + ble_fail_code = code; + ble_state = BLE_FAILED; +} + +/* the results of btctrler_task_init and btctrler_task_ready (1 when the controller task answered) -> the failing step, or 0 */ +static uint32_t ble_judge_start(int init_rc, int ready_rc) +{ + if (init_rc) { ble_fail(BLE_FAIL_INIT, init_rc); return BLE_FAIL_INIT; } + if (!ready_rc) { ble_fail(BLE_FAIL_READY, ready_rc); return BLE_FAIL_READY; } + return 0; +} + +static char *ble__put(char *o, const char *s) { while (*s) *o++ = *s++; return o; } + +static char *ble__num(char *o, int32_t v) +{ + char t[12]; int n = 0; + uint32_t u = v < 0 ? 0u - (uint32_t)v : (uint32_t)v; + if (v < 0) *o++ = '-'; + do { t[n++] = (char)('0' + u % 10u); u /= 10u; } while (u); + while (n) *o++ = t[--n]; + return o; +} + +/* the status text into out (at least 24 bytes) */ +static void ble_status_text(char *out) +{ + char *o = out; + if (ble_state == BLE_ADV) o = ble__put(o, "ADVERTISING"); + else if (ble_state == BLE_STARTING) { o = ble__put(o, "STARTING "); o = ble__num(o, (int32_t)ble_stage); } + else if (ble_state == BLE_FAILED) { + o = ble__put(o, "FAILED "); o = ble__num(o, (int32_t)ble_fail_step); + *o++ = ' '; o = ble__num(o, ble_fail_code); + } else o = ble__put(o, "OFF"); + *o = 0; +} + +/* The full failure line, for the marquee: the short code first, then the step and the stage in words (out: 96 bytes) */ +static void ble_status_full(char *out) +{ + static const char *const step[] = { "none", "task_create", "btctrler_task_init", "btctrler_task_ready", "ll_hci_adv_enable", "timeout" }; + static const char *const stage[] = { "idle", "task", "init", "radio-init", "ready-wait", "adv-setup", "enable", "radio-done", "baseband-irq" }; + char *o; + ble_status_text(out); + if (ble_state != BLE_FAILED) return; + o = out + strlen(out); + o = ble__put(o, " step "); + o = ble__put(o, ble_fail_step < 6 ? step[ble_fail_step] : "?"); + o = ble__put(o, " stage "); + o = ble__put(o, ble_stage < 9 ? stage[ble_stage] : "?"); + if (ble_wait) { /* look the address up in firmware/build-ble/symbols.txt */ + int i; + o = ble__put(o, " wait "); + for (i = 28; i >= 0; i -= 4) *o++ = "0123456789ABCDEF"[(ble_wait >> i) & 15u]; + } + *o = 0; +} + +/* A marquee: the text, then a gap, looping; tick counts the 500 ms redraws. It rests for BLE_MARQUEE_PAUSE ticks on the + * start, then moves one character per tick (2 a second). win characters go to out, which gets a terminating 0. */ +enum { BLE_MARQUEE_PAUSE = 4, BLE_MARQUEE_GAP = 3 }; +static void ble_marquee(const char *text, uint32_t tick, int win, char *out) +{ + uint32_t len = (uint32_t)strlen(text), period = len + BLE_MARQUEE_GAP, phase, off; + int i; + if (len <= (uint32_t)win) { memcpy(out, text, len + 1u); return; } /* it fits: no scroll */ + phase = tick % (BLE_MARQUEE_PAUSE + period); + off = phase < BLE_MARQUEE_PAUSE ? 0u : phase - BLE_MARQUEE_PAUSE; + for (i = 0; i < win; i++) { + uint32_t k = (off + (uint32_t)i) % period; + out[i] = k < len ? text[k] : ' '; + } + out[win] = 0; +} + +/* ---- proof counters: does the radio run at all? Shown next to ADVERTISING (docs/BLE-TEST.md, "Reading the status text"). + * ble_isr.S counts the two baseband interrupts as they arrive; the others are counted where the library calls us. */ +uint32_t ble_cnt_irq_ev; /* IRQ 45: baseband event (an advertising event, a transmit done) */ +uint32_t ble_cnt_irq_rx; /* IRQ 29: a packet received (a scanner's request) */ +static volatile uint32_t ble_cnt_timer; /* the library's software timers that fired */ +static volatile uint32_t ble_cnt_hci; /* HCI packets the controller returned (hci_packet_handler) */ +static volatile uint32_t ble_cnt_slot; /* calls of the slot-timer stand-ins: the link scheduler wanting a timer we do not have */ +static volatile uint32_t ble_hci_last; /* type << 8 | event code of the last HCI packet */ + +static int ble_count_hci(uint8_t type, const uint8_t *pkt) +{ + ble_cnt_hci++; + ble_hci_last = ((uint32_t)type << 8) | (pkt ? pkt[0] : 0u); + return 0; +} + +static char *ble__dec(char *o, uint32_t v) { return ble__num(o, (int32_t)(v > 0x7FFFFFFFu ? 0x7FFFFFFF : v)); } +static char *ble__hex(char *o, uint32_t v) +{ + int i = 28; + while (i > 0 && !((v >> i) & 15u)) i -= 4; + for (; i >= 0; i -= 4) *o++ = "0123456789ABCDEF"[(v >> i) & 15u]; + return o; +} + +/* the advertising line, the numbers first so that they are on the screen before the marquee moves: + * "ADV i12 e5 r0 t340 s0 CON 2001 IEN 101 IPND 0 STAT 80 IC45 3 IC29 3 BEN 1" (out: 96 bytes; the registers are read by the caller) + * i interrupts 45 e HCI packets returned r interrupts 29 t library timers s slot-timer stand-in calls + * CON/IEN/IPND/STAT baseband 0x28000/28/30/38 IC45, IC29 the interrupt configuration nibble (bit 0 = enabled, bits 3:1 = + * priority; 0 = the line is off) BEN the BLE block enable 0x2FC80 (bit 0) */ +static void ble_adv_text(char *out, uint32_t con, uint32_t ien, uint32_t ipnd, uint32_t stat, uint32_t ic45, uint32_t ic29, uint32_t ben) +{ + char *o = out; + o = ble__put(o, "ADV i"); o = ble__dec(o, ble_cnt_irq_ev); + o = ble__put(o, " e"); o = ble__dec(o, ble_cnt_hci); + o = ble__put(o, " r"); o = ble__dec(o, ble_cnt_irq_rx); + o = ble__put(o, " t"); o = ble__dec(o, ble_cnt_timer); + o = ble__put(o, " s"); o = ble__dec(o, ble_cnt_slot); + o = ble__put(o, " CON "); o = ble__hex(o, con); + o = ble__put(o, " IEN "); o = ble__hex(o, ien); + o = ble__put(o, " IPND "); o = ble__hex(o, ipnd); + o = ble__put(o, " STAT "); o = ble__hex(o, stat); + o = ble__put(o, " IC45 "); o = ble__hex(o, ic45); + o = ble__put(o, " IC29 "); o = ble__hex(o, ic29); + o = ble__put(o, " BEN "); o = ble__hex(o, ben); + *o = 0; +} diff --git a/firmware/ble/ble_stubs.c b/firmware/ble/ble_stubs.c new file mode 100644 index 0000000..2d04af5 --- /dev/null +++ b/firmware/ble/ble_stubs.c @@ -0,0 +1,209 @@ +/* SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 */ +/* What the controller library references but this image never runs: Bluetooth classic (BR/EDR, LMP, eSCO, TWS), LE + * 5 extended features, controller-side test modes, Wi-Fi coexistence and low-power negotiation. The configuration + * (ble_cfg.c: LE only, advertising and one slave link) keeps the library from calling them; each is an empty function + * that returns 0 and counts the call in ble_stub_calls so a call that should not happen shows on the screen. + * Generated from the link errors of the "ble" target (list the names with: grep BLE_STUB firmware/ble/ble_stubs.c). */ +#include +#include "ble_status.h" + +uint32_t ble_stub_calls; +#define BLE_STUB(name) int name(void) { ble_stub_calls++; return 0; } +#define BLE_STUB_SLOT(name) int name(void) { ble_stub_calls++; ble_cnt_slot++; return 0; } /* the link scheduler's timer: counted apart */ +BLE_STUB(__ble5_hw_anchor_cnt_timeout) +BLE_STUB(__ble5_hw_event_process) +BLE_STUB(__ble5_hw_rx_process) +BLE_STUB(__hw5_free_slots) +BLE_STUB(__ll_receive_length_req) +BLE_STUB(__ll_send_phy_req) +BLE_STUB(__master_ll_phy_update_done) +BLE_STUB(__master_ll_send_phy_update_ind) +BLE_STUB(__master_reset_transmitWindowDelay) +BLE_STUB(__reset_CodedPHY_conn_winsize) +BLE_STUB(__reset_connection_winsize) +BLE_STUB(__set_extended_conn_dead_times) +BLE_STUB(__set_extended_empty_tx_data) +BLE_STUB(__set_extended_tx_data) +BLE_STUB(__set_ll_effective_data_args) +BLE_STUB(__slave_ll_phy_update_done) +BLE_STUB(__slave_ll_send_vendor_wifi_detect_req) +BLE_STUB(__slave_reset_widen) +BLE_STUB(__slave_vendor_wifi_detect_done) +BLE_STUB(aes128_start_enc) +BLE_STUB(ble5_master_primary_event_end_handle) +BLE_STUB(ble_ext_primary_scan_preprocess) +BLE_STUB(ble_master_multilink_schedule) +BLE_STUB(bredr_bd_close) +BLE_STUB(bredr_bd_init) +BLE_STUB(bredr_close_all_scan) +BLE_STUB(bredr_fcc_init) +BLE_STUB(bredr_goto_background) +BLE_STUB(bredr_link_check_used) +BLE_STUB(bredr_link_close) +BLE_STUB(bredr_link_init) +BLE_STUB(bredr_link_reset_power) +BLE_STUB(bredr_power_off) +BLE_STUB(bredr_power_on) +BLE_STUB(bredr_power_resume) +BLE_STUB(bredr_power_suspend) +BLE_STUB(bredr_radiopwrupdn_reset) +BLE_STUB(bredr_read_slot_clk) +BLE_STUB(bredr_rx_bulk_empty) +BLE_STUB(bredr_rx_bulk_resume) +BLE_STUB(bredr_rx_bulk_suspend) +BLE_STUB(bredr_rx_remain_bulk_empty) +BLE_STUB(bredr_tx_bulk_empty) +BLE_STUB(bt_edr_prio_settings) +BLE_STUB(bt_event_notify) +BLE_STUB(bt_sfr_pop) +BLE_STUB(ccm_memory_v2) +BLE_STUB(conn_master_rx_post_handler) +BLE_STUB(dut_bredr_close_recall) +BLE_STUB(dut_hci_emit_le_meta_event) +BLE_STUB(dut_le_hw_open) +BLE_STUB(find_interspace_between_connected) +BLE_STUB(get_bredr_is_init) +BLE_STUB(get_if_exist_ble5) +BLE_STUB(hci_add_cmd_handler) +BLE_STUB(hci_lmp_controller_baseband_cmd_handler) +BLE_STUB(hci_lmp_informational_parameters_cmd_handler) +BLE_STUB(hci_lmp_link_control_cmd_handler) +BLE_STUB(hci_lmp_link_policy_cmd_handler) +BLE_STUB(hci_lmp_status_parameters_cmd_handler) +BLE_STUB(hci_lmp_testing_cmd_handler) +BLE_STUB(hci_lmp_vendor_cmd_handler) +BLE_STUB(hci_push_iso_sdu) +BLE_STUB(hci_vendor_cmd_handler) +BLE_STUB(le_controller_handler_for_ble5) +BLE_STUB(le_dut_hci_reset_handle) +BLE_STUB(le_enhanced_receiver_test_handle) +BLE_STUB(le_enhanced_transmitter_test_handle) +BLE_STUB(le_receive_test_handle) +BLE_STUB(le_test_end_handle) +BLE_STUB(le_transmitter_test_handle) +BLE_STUB(link_bulk_init) +BLE_STUB(link_change_bulk_init) +BLE_STUB(link_change_rx_bulk_size) +BLE_STUB(link_data_extension_init) +BLE_STUB(ll_data_extension_init) +BLE_STUB(ll_exit_ext_legacy_adv_status) +BLE_STUB(ll_ext_destroy) +BLE_STUB(ll_ext_handle_malloc) +BLE_STUB(ll_ext_legacy_adv_scan_req_received) +BLE_STUB(ll_ext_rx_post_process) +BLE_STUB(ll_read_maximum_data_length) +BLE_STUB(ll_read_suggested_default_data_length) +BLE_STUB(ll_set_default_prefers_phys) +BLE_STUB(ll_update_ch_done_wfdet_callback) +BLE_STUB(ll_wifi_detect_ext) +BLE_STUB(ll_wifi_detect_init) +BLE_STUB(ll_wifi_detect_probe_process) +BLE_STUB(ll_write_suggested_default_data_length) +BLE_STUB(lmp_conn_for_address) +BLE_STUB(lmp_conn_for_handle) +BLE_STUB(lmp_conn_resume) +BLE_STUB(lmp_conn_suspend) +BLE_STUB(lmp_esco_conn_for_handle) +BLE_STUB(lmp_event_handler) +BLE_STUB(lmp_get_conn_num) +BLE_STUB(lmp_hci_cmd_handler) +BLE_STUB(lmp_hci_cmd_to_conn_handler) +BLE_STUB(lmp_hci_disconnect) +BLE_STUB(lmp_hci_read_remote_version_information) +BLE_STUB(lmp_hci_send_packet_standard) +BLE_STUB(lmp_init) +BLE_STUB(lmp_reset) +BLE_STUB(lmp_tx_suspend) +BLE_STUB(low_power_exit_request) +BLE_STUB(low_power_get_osc_type) +BLE_STUB(low_power_on) +BLE_STUB(low_power_put) +BLE_STUB(low_power_request) +BLE_STUB(low_power_trace_drift) +BLE_STUB(master_rx_conn_post_handler) +BLE_STUB(master_setup_connection_updatea_first_data_location) +BLE_STUB(master_setup_first_data_location) +BLE_STUB(mult_slave_or_master_check_up_point) +BLE_STUB(mult_slave_or_master_del_conn_id) +BLE_STUB(mult_slave_or_master_event_enter) +BLE_STUB(mult_slave_or_master_fist_con_event_set) +BLE_STUB(mult_slave_or_master_get_con_num) +BLE_STUB(mult_slave_or_master_get_rx_irq_hw) +BLE_STUB(mult_slave_or_master_save_conn_id) +BLE_STUB(mult_slave_or_master_set_adv_interval) +BLE_STUB(mult_slave_or_master_set_init_param) +BLE_STUB(mult_slave_or_master_set_scan_param) +BLE_STUB(ogf_vendor_le_controller) +BLE_STUB(power_is_poweroff_post) +BLE_STUB(record_rx_chsel_algorithm) +BLE_STUB(refind_interspace_between_connected) +BLE_STUB(refresh_phy_param) +BLE_STUB(repair_algorithm2_hop) +BLE_STUB(rf_dc_set_max) +BLE_STUB(rf_dc_set_reload) +BLE_STUB(set_hw_channel_selection_algorithm) +BLE_STUB(set_tx_pdu_channel_selection_algorithm) +BLE_STUB(slave_ll_receive_phy_req) +BLE_STUB_SLOT(slot_timer_disable) +BLE_STUB_SLOT(slot_timer_get) +BLE_STUB_SLOT(slot_timer_put) +BLE_STUB_SLOT(slot_timer_remain) +BLE_STUB_SLOT(slot_timer_reset) +BLE_STUB_SLOT(slot_timer_set) +BLE_STUB(suspend_init) +BLE_STUB(tws_link_ble_slave_conn_establish_handler) +BLE_STUB(tws_link_ble_slave_conn_establish_probe_handler) +BLE_STUB(tws_link_get_ble_role_slave) +BLE_STUB(tws_phone_ble_link_connection) +BLE_STUB(tws_phone_ble_link_disconnection) +BLE_STUB(wf_get_phcom_cnt) + +/* The low-power manager's registration. Not an empty stand-in: the library asserts the handle it gets back (ble_power_get, + * called when advertising starts), and a failed assertion never returns. It only tests the handle afterwards. */ +void *low_power_get(void *priv, void *ops) { static uint8_t handle; (void)priv; (void)ops; return &handle; } + +/* values the library keeps for the radio's offset trims (analog.c): written by it, read by it */ +int keep_BT_CMPUK1; +int keep_BT_CMPUK2; +int keep_BT_CMPUK3; +int keep_BT_OFSI; +int keep_BT_OFSQ; + +/* log tags: the library prints nothing (a tag of 0 turns its log call off) */ +#define TAG(n) const char n = 0; +TAG(log_tag_const_d_HCI_LL) +TAG(log_tag_const_d_HCI_STD) +TAG(log_tag_const_d_LE_BB) +TAG(log_tag_const_d_LL_AFH) +TAG(log_tag_const_d_LL_E) +TAG(log_tag_const_e_HCI_STD) +TAG(log_tag_const_e_LE_BB) +TAG(log_tag_const_e_LL) +TAG(log_tag_const_e_LL_ADV) +TAG(log_tag_const_e_LL_AFH) +TAG(log_tag_const_e_LL_E) +TAG(log_tag_const_e_LL_INIT) +TAG(log_tag_const_e_LL_RL) +TAG(log_tag_const_e_LL_S) +TAG(log_tag_const_e_LL_SCAN) +TAG(log_tag_const_e_LL_WL) +TAG(log_tag_const_e_LMP) +TAG(log_tag_const_i_Analog) +TAG(log_tag_const_i_HCI_LL) +TAG(log_tag_const_i_HCI_LMP) +TAG(log_tag_const_i_HCI_STD) +TAG(log_tag_const_i_LE_BB) +TAG(log_tag_const_i_LL) +TAG(log_tag_const_i_LL_ADV) +TAG(log_tag_const_i_LL_AFH) +TAG(log_tag_const_i_LL_E) +TAG(log_tag_const_i_LL_INIT) +TAG(log_tag_const_i_LL_RL) +TAG(log_tag_const_i_LL_S) +TAG(log_tag_const_i_LL_SCAN) +TAG(log_tag_const_i_LL_WL) +TAG(log_tag_const_i_LMP) +TAG(log_tag_const_i_RF) +TAG(log_tag_const_i_Thread) +TAG(log_tag_const_w_LL_AFH) +TAG(log_tag_const_i_WLC) diff --git a/firmware/ble/ble_test.c b/firmware/ble/ble_test.c new file mode 100644 index 0000000..6115cc7 --- /dev/null +++ b/firmware/ble/ble_test.c @@ -0,0 +1,88 @@ +/* SPDX-License-Identifier: GPL-3.0-only OR MPL-2.0 */ +/* Advertise-only Bluetooth test (build target "ble", docs/BLE-TEST.md): the controller from the JieLi SDK library + * (third_party/jieli-ble) is started, and one scannable, non-connectable advertisement is sent on the three + * advertising channels: + * advertising data flags, the BLE-MIDI service UUID 03B80E5A-EDE8-4B33-A751-6CE34EC4C700 (128 bit) + * scan response the complete local name "OpenPocket-BLE-TEST" + * No connection is ever accepted (the type is ADV_SCAN_IND), no pairing, nothing is sent over Bluetooth but this. */ +#include +#include +#include "fm1_irq.h" +#include "ble_os.h" +#include "ble_status.h" +#include "ble_adv.h" + +/* the controller library's entry points (include_lib/btctrler/btctrler_task.h, ble/hci_ll.h; hci_transport.h) */ +extern int btctrler_task_init(const void *transport, const void *config); +extern int btctrler_task_ready(void); /* blocks until the controller task answers, 1 when it did */ +extern const void *hci_transport_h4_controller_instance(void); +extern void ll_hci_adv_set_params(uint16_t adv_int_min, uint16_t adv_int_max, uint8_t adv_type, uint8_t direct_address_type, + uint8_t *direct_address, uint8_t channel_map, uint8_t filter_policy); +extern void ll_hci_adv_set_data(uint8_t len, uint8_t *data); +extern void ll_hci_adv_scan_response_set_data(uint8_t len, uint8_t *data); +extern int ll_hci_adv_enable(bool enable); +extern int task_create(void (*fn)(void *), void *arg, const char *name); /* ble_os.c */ +extern uint32_t ble_task_waiting(const char *name); /* ble_os.c */ +extern int le_controller_set_mac(const uint8_t *mac); /* cpu.a/btctrler: the public address slot */ +extern int __crc16_mutex_init(void); /* cpu.a crc16.c: creates the lock its CRC helpers take; the SDK's start-up calls it */ + +static uint32_t t_start; +static uint8_t cfg[64]; /* the controller reads its host-side settings here: none */ + +/* btctrler_task_ready waits (on a semaphore) until the controller's own task has started, so this runs as a task of + * its own: the main loop must never block there. */ +static void test_task(void *arg) +{ + int rc; + (void)arg; + ble_stage_set(BLE_STAGE_INIT); /* (BLE_STAGE_RF: ble_rf.c, inside the init) */ + rc = btctrler_task_init(hci_transport_h4_controller_instance(), cfg); + if (rc) { ble_judge_start(rc, 0); return; } + ble_stage_set(BLE_STAGE_READY); + if (ble_judge_start(0, btctrler_task_ready())) return; + ble_stage_set(BLE_STAGE_ADV); + le_controller_set_mac(ble_adv_mac); /* the public address slot is zero otherwise (ble_adv.h) */ + ll_hci_adv_set_params(BLE_ADV_INTERVAL, BLE_ADV_INTERVAL, BLE_ADV_SCAN_IND, 0, ble_no_addr, BLE_ADV_CHMAP, BLE_ADV_FILTER); + ll_hci_adv_set_data(sizeof(ble_adv_data), ble_adv_data); + ll_hci_adv_scan_response_set_data(sizeof(ble_scan_rsp), ble_scan_rsp); + ble_stage_set(BLE_STAGE_ENABLE); + rc = ll_hci_adv_enable(true); + if (rc) ble_fail(BLE_FAIL_ADV_ENABLE, rc); else ble_state = BLE_ADV; +} + +void ble_test_start(void) +{ + __crc16_mutex_init(); /* else thread_create (ll_init) waits for ever in crc_get_32bit */ + ble_state = BLE_STARTING; + ble_stage_set(BLE_STAGE_TASK); + t_start = fm1_uptime_ms(); + if (task_create(test_task, 0, "bletest")) + ble_fail(BLE_FAIL_TASK_CREATE, -1); +} + +void ble_test_poll(void) +{ + ble_busy(1); + ble_poll(); + ble_busy(0); + if (ble_state == BLE_STARTING && fm1_uptime_ms() - t_start > 5000u) + { /* (the task, if it is still waiting, is left alone) */ + ble_wait = ble_task_waiting("btctrler"); + ble_fail(BLE_FAIL_TIMEOUT, (int32_t)ble_stage); + } +} + +/* every packet the controller returns to its host: we have none, so this only counts them (h4_controller_interface.c) */ +int hci_packet_handler(uint8_t type, uint8_t *pkt, uint16_t size) { (void)size; return ble_count_hci(type, pkt); } + +void ble_test_state(char *out) { ble_status_text(out); } + +/* the long advertising line, with the baseband registers read now (0x28000 control, 0x28028 enables, 0x28030 pending, 0x28038 status) */ +void ble_test_adv_line(char *out) +{ + ble_adv_text(out, *(volatile uint32_t *)0x28000u, *(volatile uint32_t *)0x28028u, *(volatile uint32_t *)0x28030u, *(volatile uint32_t *)0x28038u, + (FM1_ICFG(45) >> ((45u & 7u) * 4u)) & 0xFu, (FM1_ICFG(29) >> ((29u & 7u) * 4u)) & 0xFu, *(volatile uint32_t *)0x2FC80u); +} + +/* the line the status area scrolls, and the log channel sends (out: 96 bytes) */ +void ble_test_line(char *out) { if (ble_state == BLE_ADV) ble_test_adv_line(out); else ble_status_full(out); } diff --git a/firmware/build.sh b/firmware/build.sh index 506303d..eed31cb 100755 --- a/firmware/build.sh +++ b/firmware/build.sh @@ -1,6 +1,9 @@ #!/bin/sh # Build OpenPocket -> firmware/openpocket.bin (app), build/loader/ota.bin (update loader), # docs/firmware/*.fwsc (packages for the web installer) (in the container: tools/op.mjs build) +# OP_BLE=1 sh firmware/build.sh builds the advertise-only Bluetooth test image instead (docs/BLE-TEST.md): the same +# firmware plus the JieLi controller (third_party/jieli-ble) -> firmware/openpocket-ble.bin and build/ble/*.fwsc. +# Nothing of the normal build's outputs is touched. set -e HERE="$(cd "$(dirname "$0")" && pwd)" BSP="${BSP:-$HERE/bsp}" @@ -17,7 +20,12 @@ if [ ! -x "$TC/pi32v2/bin/ld" ]; then echo "ERROR: linker '$TC/pi32v2/bin/ld' missing (incomplete toolchain)." exit 1 fi -O="$HERE/build"; rm -rf "$O"; mkdir -p "$O" +# the stack guard: on, except in the Bluetooth test image (its task stacks are outside the guard window); app.ld refuses the +# link if the guard is on and those stacks are outside. OP_GUARD_STACK=1 with OP_BLE=1 is how that check is shown to fire. +GUARD_STACK=1; [ -n "$OP_BLE" ] && GUARD_STACK="${OP_GUARD_STACK:-0}" +if [ -n "$OP_BLE" ]; then O="$HERE/build-ble"; APP="$HERE/openpocket-ble.bin"; PKG="$HERE/../build/ble"; BLEDEF="-DOP_BLE_TEST=1 -I$HERE/ble" +else O="$HERE/build"; APP="$HERE/openpocket.bin"; PKG="$HERE/../docs/firmware"; BLEDEF=""; fi +rm -rf "$O"; mkdir -p "$O" CC="$TC/pi32v2/bin/clang -target pi32v2" $CC -c "$BSP/chip/crt0.S" -o "$O/crt0.o" $CC -c "$BSP/chip/fm1_vec.S" -o "$O/vec.o" @@ -31,19 +39,30 @@ fi FP="-mcpu=r3 -mfprev1" DSPDEF="-DPX_SR=44100.0f -DPX_OVERSAMPLE=4" INC="-I$HERE/inc -I$HERE/../engine" -$CC $FP -Os -ffunction-sections -fno-builtin -Wall -Wno-unused-function $DSPDEF $INC \ +$CC $FP -Os -ffunction-sections -fno-builtin -Wall -Wno-unused-function $DSPDEF $BLEDEF -DOP_GUARD_STACK=$GUARD_STACK $INC \ -I"$BSP/chip" -I"$BSP/boards/fm1" -c "$HERE/openpocket.c" -o "$O/openpocket.o" $CC $FP -O2 -fno-builtin $DSPDEF $INC -c "$HERE/../engine/px_dsp.c" -o "$O/px_dsp.o" $CC $FP -O2 -fno-builtin -c "$HERE/px_math.c" -o "$O/px_math.o" +BLEOBJ=""; MAPOPT="" +if [ -n "$OP_BLE" ]; then + MAPOPT="-Map $O/openpocket.map" + # JieLi's LE controller (LLVM bitcode, Apache-2.0, third_party/jieli-ble) -> pi32v2 objects, and our context switch + mkdir -p "$O/jl" + for b in "$HERE"/../third_party/jieli-ble/*/*.bc; do + $CC -Os -c -x ir "$b" -o "$O/jl/$(basename "$(dirname "$b")")_$(basename "$b" .bc).o" + done + rm -f "$O/libjl.a"; "$TC/pi32v2/bin/ar" rcs "$O/libjl.a" "$O"/jl/*.o # an archive: the linker takes only the objects the image calls + $CC -c "$HERE/ble/ble_ctx.S" -o "$O/ble_ctx.o"; $CC -c "$HERE/ble/ble_isr.S" -o "$O/ble_isr.o"; BLEOBJ="$O/ble_ctx.o $O/ble_isr.o $O/libjl.a" +fi # official JieLi I2S driver (LLVM bitcode from the AC79 SDK, Apache-2.0) -> pi32v2 object $CC -Os -c -x ir "$HERE/sdk/iis.bc" -o "$O/iis.o" -"$TC/pi32v2/bin/ld" -T "$HERE/app.ld" "$O/crt0.o" "$O/vec.o" "$O/isr.o" $CPU1OBJ "$O/openpocket.o" "$O/px_dsp.o" "$O/px_math.o" "$O/iis.o" -o "$O/openpocket.elf" +"$TC/pi32v2/bin/ld" --defsym "op_guard_stack=$GUARD_STACK" -T "$HERE/app.ld" "$O/crt0.o" "$O/vec.o" "$O/isr.o" $CPU1OBJ "$O/openpocket.o" "$O/px_dsp.o" "$O/px_math.o" "$O/iis.o" $BLEOBJ $MAPOPT -o "$O/openpocket.elf" # symbol map: used to translate a crash address (PC) into a function name "$TC/common/bin/objdump" -t "$O/openpocket.elf" 2>/dev/null | sort > "$O/symbols.txt" || true "$TC/common/bin/objcopy" -O binary -j .text "$O/openpocket.elf" "$O/text.bin" "$TC/common/bin/objcopy" -O binary -j .data "$O/openpocket.elf" "$O/data.bin" "$TC/common/bin/objcopy" -O binary -j .ram_text "$O/openpocket.elf" "$O/rt.bin" 2>/dev/null || : > "$O/rt.bin" -python3 - "$TC" "$O" "$HERE/openpocket.bin" <<'PY' +python3 - "$TC" "$O" "$APP" <<'PY' import re,subprocess,sys tc,out,dest=sys.argv[1:] syms=subprocess.run([f"{tc}/common/bin/objdump","-t",f"{out}/openpocket.elf"],capture_output=True,text=True,check=True).stdout @@ -79,5 +98,6 @@ python3 "$HERE/../tools/mkloader.py" "$L/loader.bin" "$L/ota.bin" # ---- update packages for the web installer -> docs/firmware/ (PocketX_9NN.fwsc, BlackBox_9NN.fwsc, manifest.json) ---- VER=$(sed -n 's/^#define OP_VERSION_NUM "\([0-9]*\)".*/\1/p' "$HERE/openpocket.c") -python3 "$HERE/../tools/pxpkg.py" "$HERE/openpocket.bin" "$L/ota.bin" "$HERE/../docs/firmware" --version "$VER" \ +mkdir -p "$PKG" +python3 "$HERE/../tools/pxpkg.py" "$APP" "$L/ota.bin" "$PKG" --version "$VER" \ --sdk-files "$HERE/sdk/packager" diff --git a/firmware/openpocket.c b/firmware/openpocket.c index 2a1956f..ea5d78e 100644 --- a/firmware/openpocket.c +++ b/firmware/openpocket.c @@ -303,6 +303,9 @@ static void fm1_fault(const fm1_crash_t *c) #define PH_DIR (*(volatile uint32_t *)0x501C8u) #define ENC_MASK 0x3u /* PC0=A, PC1=B */ #define OP_VERSION_NUM "36" +#ifndef OP_GUARD_STACK /* hardware stack-limit guard (fm1_guard.h). build.sh turns it off for the Bluetooth test image, */ +#define OP_GUARD_STACK 1 /* whose task stacks (ble_os.c) lie outside [_ustack_lo, _sstack_top]; app.ld checks this at link time */ +#endif #define OP_VERSION "v" OP_VERSION_NUM /* shown at boot: changes with every released build */ #define PROBE 0 /* 1 = GPIO diagnostic: shows the pins that change at the bottom */ #define ADC_PROBE 0 /* 1 = ADC diagnostic: shows the channels that move at the bottom (C2:512) */ @@ -541,9 +544,29 @@ static const int8_t QDEC[16] = {0,-1,1,0, 1,0,0,-1, -1,0,0,1, 0,1,-1,0}; #if !AUDIO_PROBE static uint32_t ed_load(void) { return a_bypass ? a_peak : a_load; } /* PING: the DSP load, as at the bottom right */ static uint32_t ed_flags(void) { return (a_bypass ? 1u : 0u) | (a_c1 ? 2u : 0u); } +#if OP_BLE_TEST /* the log channel (firmware/ble/ble_diag.h): only this image answers DIAG, the normal firmware does not change */ +#define ED_DIAG_LINES 3u +#define ED_DIAG_LEN 224u +static void ed_diag_line(uint32_t i, char *out); +#endif #include "px_editor.h" /* the web editor (docs/EDITOR-PROTOCOL.md) */ #endif #endif +#if OP_BLE_TEST /* the advertise-only Bluetooth test image (docs/BLE-TEST.md): make ble */ +#include "ble/ble_os.c" +#include "ble/ble_libc.c" +#include "ble/ble_cfg.c" +#include "ble/ble_rf.c" +#include "ble/ble_stubs.c" +#include "ble/ble_test.c" +#include "ble/ble_diag.h" +static void ed_diag_line(uint32_t i, char *out) +{ + if (i == 0u) ble_boot_text(out, ms_now(), op_diag.safe, op_diag.stage, op_pwr.rst, guard.failed); + else if (i == 1u) ble_last_text(out, &ble_last); + else ble_test_line(out); +} +#endif /* P3_RST_SRC as read at boot: why the chip last reset (fm1_sys.h) */ static void rst_names(char *l, uint32_t r) @@ -576,7 +599,11 @@ static void app_main(void) /* boot screen with the version: shows which image is running */ lcd_rect(0,0,240,240,UI_BG); ft_box(0, 98, 240, "OPENPOCKET", &FT_MD, UI_ACC, UI_BG, 1); +#if OP_BLE_TEST + ft_box(0, 126, 240, OP_VERSION " BLE TEST", &FT_SM, UI_DIM, UI_BG, 1); +#else ft_box(0, 126, 240, OP_VERSION, &FT_SM, UI_DIM, UI_BG, 1); +#endif #if !AUDIO_PROBE { /* the supplies as found -> as set (fm1_cstart): "PWR 3>7 11>15 DCDC" */ char l[32]; l[0] = 0; @@ -631,6 +658,10 @@ static void app_main(void) ft_box(0, 98, 240, "OPENPOCKET", &FT_MD, UI_ACC, UI_BG, 1); ft_box(0, 126, 240, OP_VERSION " SAFE MODE", &FT_SM, UI_DIM, UI_BG, 1); } +#if OP_BLE_TEST + ble_last = (ble_boot_t){ st != ST_OK || crashed, crashed, st, op_diag.blocks, op_diag.load, op_diag.peak, op_diag.nans, op_diag.c1, + op_diag.loops, fm1_crash.vec, fm1_crash.pc, fm1_crash.rets, fm1_crash.dbg, fm1_crash.emu, fm1_crash.uptime_ms }; +#endif op_diag.crash_seen = cc; op_diag.safe = (uint32_t)safe; op_diag.blocks = op_diag.load = op_diag.peak = op_diag.nans = op_diag.c1 = op_diag.loops = 0; } @@ -653,11 +684,18 @@ static void app_main(void) ui_invalidate(); ui_render(); op_stage(ST_RUN, 0); +#endif +#if OP_BLE_TEST + if (!safe) /* after a failed boot Bluetooth stays off: the pedal runs, and can be re-flashed */ + ble_test_start(); /* radio on, controller up, one advertisement (the pedal keeps working) */ #endif enc_last = (uint8_t)(PC_IN & ENC_MASK); for (;;) { uint32_t now = ms_now(); fm1_wdt_feed(); +#if OP_BLE_TEST + ble_test_poll(); +#endif #if !AUDIO_PROBE px_usb_service(now); /* web installer: handshake, update, UBOOT soft key */ audio_poll(now); /* the sound follows the UI (preset, settings, volume) */ @@ -674,7 +712,25 @@ static void app_main(void) if (l >= 100u) c[i++] = (char)('0' + l / 100u); if (l >= 10u) c[i++] = (char)('0' + (l / 10u) % 10u); c[i++] = (char)('0' + l % 10u); c[i++] = '%'; c[i] = 0; +#if OP_BLE_TEST + { /* "STARTING 4 C1 42%": the Bluetooth state first; FAILED and ADVERTISING scroll their long lines instead (ble_status.h) */ + char b[48], full[96]; int k, j; + static uint32_t scroll; + ble_test_state(b); + if (ble_state == BLE_FAILED || ble_state == BLE_ADV) { /* longer than the status area: scroll it (ble_status.h) */ + ble_test_line(full); + ble_marquee(full, scroll++, 11, b); + } else { + for (k = 0; b[k]; k++) ; + b[k++] = ' '; + for (j = 0; c[j]; j++) b[k++] = c[j]; + b[k] = 0; + } + ui_status(b, a_bypass); + } +#else ui_status(c, a_bypass); +#endif } } else if (now - t_tuner >= 100u) { t_tuner = now; tuner_draw(tuner_estimate()); } #endif @@ -831,7 +887,7 @@ void fm1_cstart(void) op_pwr.vdc0 = (uint8_t)vdc0; op_pwr.vdc1 = (uint8_t)fm1_vdc14(); op_pwr.sys0 = (uint8_t)sys0; op_pwr.sys1 = (uint8_t)fm1_sysvdd(); op_pwr.dcdc = (uint8_t)dcdc; op_pwr.rst = (uint8_t)rst0; op_pwr.pinr = (uint8_t)pinr0; - fm1_guard_enable(FM1_GUARD_STACK | FM1_GUARD_WRITE | FM1_GUARD_BUS | FM1_GUARD_PC); + fm1_guard_enable((OP_GUARD_STACK ? FM1_GUARD_STACK : 0) | FM1_GUARD_WRITE | FM1_GUARD_BUS | FM1_GUARD_PC); app_main(); for (;;) ; } diff --git a/firmware/px_audio.h b/firmware/px_audio.h index 183714e..72e12b8 100644 --- a/firmware/px_audio.h +++ b/firmware/px_audio.h @@ -46,9 +46,11 @@ void *os_current_task_rom(void) { return 0; } void cpu_assert_debug(void) { } void P33_SYSTEM_RESET(void) { for (;;) ; } +#if !OP_BLE_TEST /* the ble build has a real heap (ble/ble_os.c); iis_open draws its block from it */ static uint8_t jl_heap[4096] __attribute__((aligned(8))); /* iis_open: 2 channels x 8 x sr_points (256) */ void *malloc(unsigned n) { return (n <= sizeof jl_heap) ? jl_heap : 0; } /* single block: iis_open */ void free(void *p) { (void)p; } +#endif /* memset / memcpy: felucca-bsp libc.c (included by openpocket.c) */ static volatile uint32_t *jl_preg(uint32_t gpio, uint32_t off) diff --git a/firmware/px_editor.h b/firmware/px_editor.h index 5fe89d8..f5f1cb1 100644 --- a/firmware/px_editor.h +++ b/firmware/px_editor.h @@ -9,7 +9,8 @@ * editor made itself is not echoed back. * The including file provides: PRESET, cur_preset, load_preset, ui_blk / ui_mode / ui_item (ui.h), st_gen, the * store (px_store.h), BLK_COL (ui_colors.h), ota_frame_get / ota_frame_done / ota_wire_send, ms_now, OP_VERSION, - * FELUCCA_ID_PTR, and ed_load / ed_flags (the DSP load for PING). + * FELUCCA_ID_PTR, and ed_load / ed_flags (the DSP load for PING). An image with a log channel also defines ED_DIAG_LINES + * and ed_diag_line(i, out), which writes line i (at most ED_DIAG_LEN - 1 characters); without them DIAG gets no reply. */ #pragma once @@ -21,7 +22,7 @@ #define ED_P_SIZE 110u typedef char ed_size_check[sizeof(px_preset_t) == ED_P_SIZE ? 1 : -1]; enum { ED_INFO = 1, ED_CAT_BLOCK, ED_CAT_TYPE, ED_TEXTS, ED_STATE, ED_SET, ED_ORDER, ED_NAME, ED_PUT, ED_SELECT, - ED_FOCUS, ED_LIST, ED_SLOT_GET, ED_SLOT_PUT, ED_COMMIT, ED_SAVE, ED_WATCH, ED_PING }; + ED_FOCUS, ED_LIST, ED_SLOT_GET, ED_SLOT_PUT, ED_COMMIT, ED_SAVE, ED_WATCH, ED_PING, ED_DIAG }; static uint8_t ed_out[1024]; static uint32_t ed_n; @@ -231,6 +232,14 @@ static void ed_handle(const uint8_t *a, uint32_t n) /* a: the args, after the case ED_PING: ed_b(ed_load() > 127u ? 127u : ed_load()); ed_b(ed_flags()); break; +#ifdef ED_DIAG_LINES + case ED_DIAG: { /* read only: nothing here changes the pedal */ + static char l[ED_DIAG_LEN]; + ed_b(ED_DIAG_LINES); + for (i = 0; i < ED_DIAG_LINES; i++) { ed_diag_line(i, l); ed_str(l, sizeof l - 1u); } + break; + } +#endif default: return; } diff --git a/firmware/px_usb.h b/firmware/px_usb.h index 851134a..de3e0b7 100644 --- a/firmware/px_usb.h +++ b/firmware/px_usb.h @@ -25,6 +25,9 @@ static const volatile char OP_PRODUCT[32] __attribute__((used, aligned(4))) = "O #define FELUCCA_ID_PTR ((const char *)OP_PRODUCT + 11) #include "fm1_timer.h" +#if OP_BLE_TEST +static int ble_stall_tick(void); /* ble/ble_status.h, included after this file */ +#endif #include "fm1_flash.h" #include "fm1_cpu1_stop.h" #include "usb.c" @@ -36,6 +39,10 @@ static volatile uint32_t px_t5_sub; void fm1_timer5_irq(void) /* 10 kHz; USB at 2 kHz */ { fm1_timer5_ack(); +#if OP_BLE_TEST /* a ble_poll that does not return: the crash screen says where (ble_status.h) */ + if (ble_stall_tick()) + FM1_VEC[FM1_IRQ_TIMER5] = (uint32_t)(uintptr_t)(fm1_fatal_stubs + 6u * FM1_IRQ_TIMER5); +#endif if (++px_t5_sub >= 5u) { px_t5_sub = 0; usb_poll(); diff --git a/tests/edsim.c b/tests/edsim.c index e9a3b43..c95e54a 100644 --- a/tests/edsim.c +++ b/tests/edsim.c @@ -1,17 +1,39 @@ /* SPDX-License-Identifier: GPL-3.0-only - * OpenPocket — a simulated pedal for the web editor tests (tests/test_editor.mjs). + * OpenPocket — a simulated pedal for the web editor tests (tests/test_editor.mjs) and the hosted simulator + * (tools/sim.mjs, docs/SIMULATOR.md). * - * It runs, natively on the PC, the pedal's own editor code (firmware/px_editor.h), preset store (firmware/px_store.h) - * and engine catalogue (engine/px_dsp.c), on a 1 MiB flash array. As tests/devsim.c: MIDI SysEx through - * stdin/stdout, one message per line in hex; '#' lines are for the harness: + * It runs, natively on the PC, the pedal's own editor code (firmware/px_editor.h), preset store (firmware/px_store.h), + * screen (firmware/ui.h, drawn into the frame buffer of tests/simlcd.h) and engine catalogue (engine/px_dsp.c), on a + * 1 MiB flash array. As tests/devsim.c: MIDI SysEx through stdin/stdout, one message per line in hex; '#' lines are + * for the harness: * out "#port NAME" the USB port appears * in "#pedal set B K V" a knob turned on the pedal: parameter K of block B = V * in "#pedal on B V" a block switched on/off on the pedal * in "#pedal preset N" a footswitch: slot N (0..51) * in "#pedal focus B" a block button * in "#dump" prints "#state SLOT|NAME|WRITES_OK|FOCUS|ON|TYPE|AMP P|ORDER" (the current sound) - * edsim [FLASH.bin] [--dump OUT.bin] - */ + * and, for the hosted simulator, each answered by one line that starts with the same word: + * in "#press NAME" a control, as the pedal's main loop (openpocket.c) dispatches it: fs_up fs_dn fx home + * push mod amp cab dly rev, push_long (SAVE), dpush (double click: MOVE), home_long + * (tuner). "#press ok" / "#press err REASON" + * in "#down NAME" a button goes down / comes up, timed as the main loop times it: fs_up fs_dn fx home push + * in "#up NAME" mod amp cab dly rev. "#down ok" / "#up ok" / "... err REASON" + * in "#knob N [fast]" the PARA knob turned N detents (negative: the other way) + * in "#vol N" the MASTER knob, 0..100 + * in "#bat MV [USB_MV]" the battery voltage (and the 5 V supply) the screen shows + * in "#hz F" the pitch the tuner page hears (0: none) + * in "#dsp load N" the load the status text shows, measured by the page that runs the sound (tools/sim/) + * in "#dsp bypass 0|1" the bypass state: the status text shows "BYP" and the sound passes the dry input + * in "#status" one JSON line: slot, mode, focused block, volume, the whole current sound, ... + * in "#screen" "#screen " + 240*240 RGB565 pixels, 4 hex digits each, row by row + * in "#flash PATH" writes the 1 MiB flash to PATH + * Built with -DOP_BLE_TEST=1 it also answers DIAG, as the Bluetooth test image does, from the real text code (ble_diag.h): + * in "#ble adv I T" advertising; the counters i and t + * in "#ble failed S C" FAILED, step S, code C + * in "#ble safe" the boot after a watchdog reset in the first 6 s: safe mode, Bluetooth off + * edsim [FLASH.bin] [--persist FILE] [--dump OUT.bin] + * --persist FILE: the flash lives in FILE (read at the start when it exists, written again after each change); + * --dump OUT.bin: the flash is written to OUT.bin when stdin closes. */ #define _GNU_SOURCE #include #include @@ -20,7 +42,9 @@ #include #include #include +#include #include "../engine/px_dsp.h" +#include "simlcd.h" static uint8_t nor[0x100000]; #define FL_IN(off, n, lo, hi) ((uint32_t)(off) >= (lo) && (uint32_t)(off) <= (hi) && (uint32_t)(n) <= (hi) - (uint32_t)(off)) @@ -46,12 +70,14 @@ static uint32_t ota_crc16(const uint8_t *p, uint32_t n, uint32_t c) /* as ota. /* ---- flash (only the preset store may be written, as FL_RANGE_OK on the pedal) ---- */ static int writes_ok = 1; +static int flash_dirty; static int ota_fread(uint32_t off, void *p, uint32_t n) { if (off + n > sizeof nor) return -1; memcpy(p, nor + off, n); return 0; } static int fl_erase4k(uint32_t off, uint32_t *took) { *took = 0; if (!FL_IN(off, 0x1000u, 0xF0000u, 0xF4000u) || (off & 0xFFFu)) { writes_ok = 0; return -1; } memset(nor + off, 0xFF, 0x1000u); + flash_dirty = 1; return 0; } static int fl_write(uint32_t off, const void *p, uint32_t n) @@ -59,13 +85,14 @@ static int fl_write(uint32_t off, const void *p, uint32_t n) const uint8_t *s = p; if (!FL_IN(off, n, 0xF0000u, 0xF4000u)) { writes_ok = 0; return -1; } for (uint32_t i = 0; i < n; i++) nor[off + i] &= s[i]; + flash_dirty = 1; return 0; } /* ---- what px_store.h's audio hold touches on the pedal ---- */ static volatile uint8_t a_mute; static void fm1_wdt_feed(void) { } -static void fm1_delay_ms(uint32_t ms) { (void)ms; } +static void fm1_delay_ms(int ms) { (void)ms; } #define FM1_IRQ_ALNK0 0 static void fm1_irq_mask(int i) { (void)i; } static void fm1_irq_unmask(int i) { (void)i; } @@ -78,11 +105,17 @@ static px_preset_t PRESET; static uint32_t cur_preset; static void st_get(uint32_t k, px_preset_t *p); static void load_preset(uint32_t i) { st_get(i, &PRESET); } -enum { M_CHAIN, M_BLOCK, M_EDIT, M_TUNER, M_SAVE, M_MOVE }; -static uint32_t st_gen, ui_mode = M_CHAIN, ui_blk = PX_B_AMP, ui_item; -#include "../firmware/ui_colors.h" -static uint32_t ed_load(void) { return 42u; } -static uint32_t ed_flags(void) { return 2u; } +static uint32_t master_vol = 80; +static int bat_mv = 3920, bat_usb_mv = 0; +static int a_level_db(int ch) { return ch ? -84 : -62; } +static volatile uint8_t a_tmute; +static float tuner_hz; +#include "../firmware/ui_text.h" +#include "../firmware/ui.h" +static uint32_t dsp_load = 42u; +static int dsp_bypass; +static uint32_t ed_load(void) { return dsp_load; } +static uint32_t ed_flags(void) { return (dsp_bypass ? 1u : 0u) | 2u; } /* ---- SysEx in/out ---- */ static uint8_t frame[2048]; @@ -98,6 +131,26 @@ static int ota_wire_send(const uint8_t *p, uint32_t n) } #include "../firmware/px_store.h" +#if OP_BLE_TEST +#include "../firmware/ble/ble_diag.h" +#define ED_DIAG_LINES 3u +#define ED_DIAG_LEN 224u +static uint32_t sim_safe, sim_rst = 0x40u; +static void ed_diag_line(uint32_t i, char *out) +{ + if (i == 0u) ble_boot_text(out, 30000u, sim_safe, 8u, sim_rst, 0u); + else if (i == 1u) ble_last_text(out, &ble_last); + else if (ble_state == BLE_ADV) ble_adv_text(out, 0x2001u, 0x101u, 0u, 0x80u, 3u, 3u, 1u); + else ble_status_full(out); +} +static void sim_ble(const char *l) +{ + int a, b; + if (sscanf(l, "adv %d %d", &a, &b) == 2) { ble_state = BLE_ADV; ble_cnt_irq_ev = (uint32_t)a; ble_cnt_timer = (uint32_t)b; } + else if (sscanf(l, "failed %d %d", &a, &b) == 2) ble_fail((uint32_t)a, b); + else if (!strcmp(l, "safe")) { ble_state = BLE_OFF; sim_safe = 1; sim_rst = 4u; ble_last = (ble_boot_t){ .failed = 1, .stage = 7, .blocks = 9 }; } +} +#endif #include "../firmware/px_editor.h" static int hexv(int c) { return c <= '9' ? c - '0' : (c | 32) - 'a' + 10; } @@ -110,6 +163,224 @@ static void pedal(const char *l) else if (sscanf(l, "preset %d", &v) == 1 && v >= 0 && v < (int)ST_NSLOT) { cur_preset = (uint32_t)v; load_preset(cur_preset); } else if (sscanf(l, "focus %d", &b) == 1 && b >= 0 && b < PX_NBLK) { ui_blk = (uint32_t)b; ui_mode = M_BLOCK; } } + +/* ---- the controls, as the pedal's main loop (openpocket.c) dispatches a debounced press ---- */ +static char last_press[32]; +static uint32_t n_press; +static const char *press(const char *n) +{ + static const struct { const char *name; uint32_t blk; } BLKBTN[] = { + {"mod", PX_B_MOD}, {"amp", PX_B_AMP}, {"cab", PX_B_CAB}, {"dly", PX_B_DELAY}, {"rev", PX_B_REVERB}}; + int is_push = !strcmp(n, "push"), is_home = !strcmp(n, "home"), known = is_push || is_home; + uint32_t i; + for (i = 0; i < sizeof BLKBTN / sizeof *BLKBTN; i++) known |= !strcmp(n, BLKBTN[i].name); + known |= !strcmp(n, "fs_up") || !strcmp(n, "fs_dn") || !strcmp(n, "fx") || !strcmp(n, "push_long") || !strcmp(n, "home_long") || !strcmp(n, "dpush"); + if (!known) return "unknown control"; + if (ui_mode == M_SAVE) { /* the save page: PUSH saves, HOME cancels, the others do nothing */ + if (is_push) save_confirm(); else if (is_home) save_close(); + } else if (ui_mode == M_TUNER) { /* any control leaves the tuner, but a hold of HOME does nothing */ + if (strcmp(n, "home_long")) tuner_close(); + } else if (!strcmp(n, "push_long")) { /* hold PUSH (0.8 s) */ + save_open(); + } else if (ui_mode == M_MOVE) { /* moving a block: PUSH drops it, HOME puts it back */ + if (is_push) ui_move_drop(); else if (is_home) ui_move_cancel(); + } else if (!strcmp(n, "dpush")) { /* double-click PUSH on the chain */ + if (ui_mode != M_CHAIN) return "dpush: only on the chain page"; + ui_move_start(); + } else if (!strcmp(n, "home_long")) { /* hold HOME (0.7 s) */ + tuner_open(); + } else if (is_push) ui_push(); + else if (!strcmp(n, "fs_up")) ui_preset_step(+1); + else if (!strcmp(n, "fs_dn")) ui_preset_step(-1); + else if (is_home) ui_home(); + else if (!strcmp(n, "fx")) ui_fx_cycle(); + else for (i = 0; i < sizeof BLKBTN / sizeof *BLKBTN; i++) if (!strcmp(n, BLKBTN[i].name)) ui_enter_block(BLKBTN[i].blk); + snprintf(last_press, sizeof last_press, "%s", n); + n_press++; + return 0; +} +static int knob_pos; /* detents turned since the start: where the knob points */ +static void knob(int n, int fast) +{ + int dd = n < 0 ? -1 : 1; + knob_pos += n; + for (int i = 0; i < (n < 0 ? -n : n); i++) { + if (ui_mode == M_SAVE) save_knob(dd); + else if (ui_mode != M_TUNER) ui_knob(dd, fast); + } +} + +/* ---- a button held down, timed as the main loop times it: PUSH and HOME act at the release or after a hold, the others + * at the press. Two footswitches down are two presses: the firmware has no gesture for both. ---- */ +static const char *const BUTTON[] = {"fs_up", "fs_dn", "fx", "home", "push", "mod", "amp", "cab", "dly", "rev"}; +enum { N_BUTTON = sizeof BUTTON / sizeof *BUTTON, B_HOME = 3, B_PUSH = 4 }; +static struct { int down, fired; uint32_t t; } btn[N_BUTTON]; +static int clk_pend; /* chain page: a PUSH waiting to see if a 2nd one makes it a double-click */ +static uint32_t clk_t; +static int button(const char *n) +{ + for (int i = 0; i < N_BUTTON; i++) if (!strcmp(n, BUTTON[i])) return i; + return -1; +} +static const char *button_down(const char *n, uint32_t now) +{ + int b = button(n); + if (b < 0) return "unknown button"; + if (btn[b].down) return 0; + btn[b].down = 1; btn[b].fired = 0; btn[b].t = now; + return b == B_HOME || b == B_PUSH ? 0 : press(n); +} +static const char *button_up(const char *n, uint32_t now) +{ + int b = button(n); + if (b < 0) return "unknown button"; + if (!btn[b].down) return 0; + btn[b].down = 0; + if (btn[b].fired) return 0; + if (b == B_HOME) return press(n); + if (b != B_PUSH) return 0; + if (ui_mode != M_CHAIN) return press(n); + if (clk_pend && now - clk_t < 350u) { clk_pend = 0; return press("dpush"); } + clk_pend = 1; clk_t = now; + return 0; +} +static void buttons_service(uint32_t now) +{ + if (btn[B_PUSH].down && !btn[B_PUSH].fired && now - btn[B_PUSH].t >= 800u) { btn[B_PUSH].fired = 1; press("push_long"); } + if (btn[B_HOME].down && !btn[B_HOME].fired && now - btn[B_HOME].t >= 700u) { btn[B_HOME].fired = 1; press("home_long"); } + if (ui_mode == M_MOVE) clk_pend = 0; + else if (clk_pend && ui_mode != M_SAVE && ui_mode != M_TUNER && (now - clk_t >= 350u || ui_mode != M_CHAIN)) { + clk_pend = 0; + if (ui_mode == M_CHAIN) press("push"); + } +} + +/* ---- what the host asks for ---- */ +static void frame_draw(void) /* what the main loop does after each pass */ +{ + ui_render(); + char c[12]; + snprintf(c, sizeof c, "%s %u%%", dsp_bypass ? "BYP" : "C1", dsp_load); + ui_status(c, dsp_bypass); + if (ui_mode == M_TUNER) tuner_draw(tuner_hz); +} +static void json_str(const char *s) +{ + putchar('"'); + for (; *s; s++) { if (*s == '"' || *s == '\\') putchar('\\'); putchar(*s); } + putchar('"'); +} +static void json_list(const uint8_t *a, int n) +{ + putchar('['); + for (int i = 0; i < n; i++) printf("%s%d", i ? "," : "", a[i]); + putchar(']'); +} +static void status(void) +{ + static const char *const MODE[] = {"chain", "block", "edit", "tuner", "save", "move"}; + frame_draw(); + printf("#status {\"slot\":%u,\"name\":", cur_preset); + json_str(PRESET.name); + printf(",\"dirty\":%d,\"saved\":%d,\"mode\":\"%s\",\"block\":%u,\"item\":%u,\"volume\":%u,\"battery_mv\":%d,\"usb_mv\":%d," + "\"tuner_hz\":%.2f,\"writes_ok\":%d,\"frame\":%u,\"dsp\":{\"load\":%u,\"bypass\":%u,\"core1\":%u}," + "\"last_press\":", ed_dirty(), st_saved(cur_preset), MODE[ui_mode], ui_blk, ui_item, master_vol, bat_mv, bat_usb_mv, + (double)tuner_hz, writes_ok, fb_gen, ed_load(), ed_flags() & 1u, ed_flags() >> 1 & 1u); + json_str(last_press); + printf(",\"presses\":%u,\"knob\":%d,\"held\":[", n_press, knob_pos); + for (int i = 0, n = 0; i < N_BUTTON; i++) if (btn[i].down) { printf(n++ ? "," : ""); json_str(BUTTON[i]); } + printf("],\"preset\":{\"name\":"); + json_str(PRESET.name); + printf(",\"on\":"); json_list(PRESET.on, PX_NBLK); + printf(",\"type\":"); json_list(PRESET.type, PX_NBLK); + printf(",\"order\":"); json_list(PRESET.order, PX_NBLK); + printf(",\"p\":["); + for (int b = 0; b < PX_NBLK; b++) { printf(b ? "," : ""); json_list(PRESET.p[b], PX_NPAR); } + printf("]}}\n"); + fflush(stdout); +} +static void screen(void) +{ + frame_draw(); + printf("#screen "); + for (int i = 0; i < 240 * 240; i++) printf("%04x", fb[i]); + printf("\n"); + fflush(stdout); +} +static int write_flash(const char *path) /* whole, or not at all: a session never reads half a flash */ +{ + char tmp[1024]; + FILE *f; + snprintf(tmp, sizeof tmp, "%s.tmp", path); + f = fopen(tmp, "wb"); + if (!f) return -1; + if (fwrite(nor, 1, sizeof nor, f) != sizeof nor) { fclose(f); remove(tmp); return -1; } + if (fclose(f)) { remove(tmp); return -1; } + return rename(tmp, path); +} +static const char *persist; + +static void command(const char *l) +{ + int v, w; + char name[32]; + double d; + if (!strncmp(l, "#pedal ", 7)) pedal(l + 7); +#if OP_BLE_TEST + else if (!strncmp(l, "#ble ", 5)) sim_ble(l + 5); +#endif + else if (!strcmp(l, "#dump")) { /* "#state SLOT|NAME|WRITES_OK|FOCUS|ON..|TYPE..|P(AMP)..|ORDER.." */ + int i; + printf("#state %u|%s|%d|%u|", cur_preset, PRESET.name, writes_ok, ui_blk); + for (i = 0; i < PX_NBLK; i++) printf("%d%s", PRESET.on[i], i < PX_NBLK - 1 ? "," : "|"); + for (i = 0; i < PX_NBLK; i++) printf("%d%s", PRESET.type[i], i < PX_NBLK - 1 ? "," : "|"); + for (i = 0; i < PX_NPAR; i++) printf("%d%s", PRESET.p[PX_B_AMP][i], i < PX_NPAR - 1 ? "," : "|"); + for (i = 0; i < PX_NBLK; i++) printf("%d%s", PRESET.order[i], i < PX_NBLK - 1 ? "," : "\n"); + fflush(stdout); + } else if (sscanf(l, "#press %31s", name) == 1) { + const char *err = press(name); + printf(err ? "#press err %s\n" : "#press ok\n", err); + fflush(stdout); + } else if (sscanf(l, "#down %31s", name) == 1) { + const char *err = button_down(name, ms_now()); + printf(err ? "#down err %s\n" : "#down ok\n", err); + fflush(stdout); + } else if (sscanf(l, "#up %31s", name) == 1) { + const char *err = button_up(name, ms_now()); + printf(err ? "#up err %s\n" : "#up ok\n", err); + fflush(stdout); + } else if ((w = sscanf(l, "#knob %d %31s", &v, name)) >= 1) { + knob(v, w == 2 && !strcmp(name, "fast")); + printf("#knob ok\n"); + fflush(stdout); + } else if (sscanf(l, "#vol %d", &v) == 1) { + master_vol = (uint32_t)(v < 0 ? 0 : v > 100 ? 100 : v); + printf("#vol ok\n"); + fflush(stdout); + } else if ((w = sscanf(l, "#bat %d %d", &v, &bat_usb_mv)) >= 1) { + bat_mv = v; + if (w == 1) bat_usb_mv = 0; + printf("#bat ok\n"); + fflush(stdout); + } else if (sscanf(l, "#hz %lf", &d) == 1) { + tuner_hz = (float)d; + printf("#hz ok\n"); + fflush(stdout); + } else if (sscanf(l, "#dsp load %d", &v) == 1 && v >= 0 && v <= 999) { + dsp_load = (uint32_t)v; + printf("#dsp ok\n"); + fflush(stdout); + } else if (sscanf(l, "#dsp bypass %d", &v) == 1) { + dsp_bypass = v != 0; + printf("#dsp ok\n"); + fflush(stdout); + } else if (!strcmp(l, "#status")) status(); + else if (!strcmp(l, "#screen")) screen(); + else if (!strncmp(l, "#flash ", 7)) { + printf(write_flash(l + 7) ? "#flash err\n" : "#flash ok\n"); + fflush(stdout); + } +} static void poll_in(uint32_t ms) { static char line[8192]; @@ -129,19 +400,7 @@ static void poll_in(uint32_t ms) } line[ll] = 0; ll = 0; - if (line[0] == '#') { - if (!strncmp(line, "#pedal ", 7)) pedal(line + 7); - else if (!strcmp(line, "#dump")) { /* "#state SLOT|NAME|WRITES_OK|FOCUS|ON..|TYPE..|P(AMP)..|ORDER.." */ - int i; - printf("#state %u|%s|%d|%u|", cur_preset, PRESET.name, writes_ok, ui_blk); - for (i = 0; i < PX_NBLK; i++) printf("%d%s", PRESET.on[i], i < PX_NBLK - 1 ? "," : "|"); - for (i = 0; i < PX_NBLK; i++) printf("%d%s", PRESET.type[i], i < PX_NBLK - 1 ? "," : "|"); - for (i = 0; i < PX_NPAR; i++) printf("%d%s", PRESET.p[PX_B_AMP][i], i < PX_NPAR - 1 ? "," : "|"); - for (i = 0; i < PX_NBLK; i++) printf("%d%s", PRESET.order[i], i < PX_NBLK - 1 ? "," : "\n"); - fflush(stdout); - } - return; - } + if (line[0] == '#') { command(line); return; } { uint32_t n = 0, i; uint8_t m[4096]; @@ -160,17 +419,30 @@ int main(int argc, char **argv) memset(nor, 0xFF, sizeof nor); for (int i = 1; i < argc; i++) { if (!strcmp(argv[i], "--dump") && i + 1 < argc) dump = argv[++i]; + else if (!strcmp(argv[i], "--persist") && i + 1 < argc) persist = argv[++i]; else { FILE *f = fopen(argv[i], "rb"); if (f) { if (fread(nor, 1, sizeof nor, f)) { } fclose(f); } } } + if (persist) { /* a session's flash: left as it was, or blank the first time */ + FILE *f = fopen(persist, "rb"); + if (f) { + if (fread(nor, 1, sizeof nor, f) != sizeof nor) { fprintf(stderr, "edsim: %s: need 1 MiB\n", persist); return 2; } + fclose(f); + } + } st_ok = 1; st_load(); cur_preset = st_last; load_preset(cur_preset); + uc.screen = -1; + ui_invalidate(); printf("#port OpenPocket\n"); fflush(stdout); while (!closed) { poll_in(5); ed_service(ms_now()); + buttons_service(ms_now()); + frame_draw(); + if (persist && flash_dirty) { flash_dirty = 0; if (write_flash(persist)) fprintf(stderr, "edsim: cannot write %s\n", persist); } } if (dump) { FILE *f = fopen(dump, "wb"); if (f) { fwrite(nor, 1, sizeof nor, f); fclose(f); } } return 0; diff --git a/tests/run_tests.sh b/tests/run_tests.sh index ff82108..8ef9b2c 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -6,7 +6,12 @@ # - 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) -# - page: docs/index.html in headless Chromium (needs Playwright: npm i playwright && npx playwright install chromium) +# - flasher: tools/flash.mjs (node tools/op.mjs flash) against the same simulated pedal: update, dry run, wrong model, damaged package, rescue of a pedal that restarts in a loop +# - hosted simulator: tools/sim.mjs driven over HTTP (controls, held buttons, screen, flash file, SysEx, the Web MIDI shim) +# - pedal skin: its input mapping (tools/sim/pedal-input.js) +# - sound: the WebAssembly engine on known signals, against the native build; the bundled loops against their manifest +# - pages: docs/index.html, docs/editor.html and the pedal skin in headless Chromium (needs Playwright: npm i playwright +# && npx playwright install chromium) set -e cd "$(dirname "$0")/.." mkdir -p build @@ -23,9 +28,24 @@ $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 "== hosted simulator (tools/sim.mjs)" +node tests/test_sim.mjs +echo "== Bluetooth test image sources (third_party/jieli-ble)" +node tests/test_ble_sources.mjs +echo "== Bluetooth test image status codes" +$CC -O1 -Wall -Wno-unused-function -o build/ble_status tests/test_ble_status.c && build/ble_status +echo "== pedal skin input" +node tests/test_pedal_input.mjs +echo "== simulated sound" +node tests/test_audio.mjs echo "== editor" $CC -O1 -Wall -Wno-unused-function -o build/edsim tests/edsim.c engine/px_dsp.c firmware/px_math.c -lm node tests/test_editor.mjs -if node -e "require('playwright')" 2>/dev/null; then echo "== page"; node tests/test_page.mjs build/page; echo "== editor page"; node tests/test_editor_page.mjs build/editor; else echo "(Playwright not installed: page tests skipped)"; fi +echo "== log channel (tools/op.mjs log)" +$CC -O1 -Wall -Wno-unused-function -DOP_BLE_TEST=1 -o build/edsim_diag tests/edsim.c engine/px_dsp.c firmware/px_math.c -lm +node tests/test_log.mjs +if node -e "require('playwright')" 2>/dev/null; then echo "== page"; node tests/test_page.mjs build/page; echo "== editor page"; node tests/test_editor_page.mjs build/editor; echo "== pedal skin"; node tests/test_pedal_page.mjs build/pedal; echo "== pedal sound"; node tests/test_audio_page.mjs build/audio; else echo "(Playwright not installed: page tests skipped)"; fi diff --git a/tests/simlcd.h b/tests/simlcd.h new file mode 100644 index 0000000..dade6f6 --- /dev/null +++ b/tests/simlcd.h @@ -0,0 +1,30 @@ +/* SPDX-License-Identifier: GPL-3.0-only + * OpenPocket — the pedal's LCD on the PC: the 240x240 RGB565 frame buffer behind the lcd_win / lcd_px / lcd_end / + * lcd_rect calls that firmware/ui.h and ui_text.h draw through (the pedal streams the same calls to the panel). + * Shared by tests/uisim.c (screenshots of a script) and tests/edsim.c (the live, hosted pedal: tools/sim.mjs). + * fb_gen counts the drawing calls, so a host can tell whether the screen changed. */ +#pragma once + +enum { RED = 0xF800, GRN = 0x07E0, BLU = 0x001F, WHT = 0xFFFF, BLK = 0x0000, CYN = 0x07FF, YEL = 0xFFE0, ORG = 0xFD20 }; +enum { UI_BG = 0x0841, UI_PANEL = 0x18E3, UI_SEL = 0xFB00, UI_ACC = 0xFD20, UI_TXT = 0xEF7D, UI_DIM = 0x8410, UI_OK = 0x4E6B, UI_OFF = 0x2124 }; + +static uint16_t fb[240 * 240]; +static uint32_t fb_gen; +static uint32_t wx, wy, ww, wh, wi; +static unsigned long px_sent; +static void lcd_win(uint32_t x, uint32_t y, uint32_t w, uint32_t h) { wx = x; wy = y; ww = w; wh = h; wi = 0; } +static void lcd_px(uint16_t c) +{ + uint32_t x = wx + wi % ww, y = wy + wi / ww; + if (x < 240u && y < 240u) fb[y * 240u + x] = c; + wi++; px_sent++; fb_gen++; +} +static void lcd_end(void) { } +static void lcd_rect(uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint16_t c) +{ + if (w == 0u || h == 0u || x >= 240u || y >= 240u) return; + if (x + w > 240u) w = 240u - x; + if (y + h > 240u) h = 240u - y; + for (uint32_t j = 0; j < h; j++) for (uint32_t i = 0; i < w; i++) fb[(y + j) * 240u + x + i] = c; + px_sent += w * h; fb_gen++; +} diff --git a/tests/simpedal.mjs b/tests/simpedal.mjs new file mode 100644 index 0000000..f81d7db --- /dev/null +++ b/tests/simpedal.mjs @@ -0,0 +1,89 @@ +// 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; } }; +} + +// a pedal that restarts in a loop, over a simulated pedal's access: its USB ports are there for `up` ms, then gone for +// `down` ms, and while they are there the pedal says nothing until cycle `answerOn` (1 = the first). From that cycle on it +// answers and stays up (stopping its sound processing ended the loop), unless `vanishAfterSends` cuts it off once, after +// that many MIDI messages to it in the answering cycle. Returns { access, cycles(), end }. +export function loopAccess(sim, { up = 600, down = 300, answerOn = 1, vanishAfterSends = 0 } = {}) { + const state = { cycle: 0, visible: false, locked: false, sends: 0, vanished: false }; + let timer = null, stopped = false; + const phase = () => { + if (stopped || state.locked) return; + state.visible = !state.visible; + if (state.visible) { state.cycle++; if (state.cycle >= answerOn) state.locked = true; } + timer = setTimeout(phase, state.visible ? up : down); + }; + phase(); + const proxies = new WeakMap(); + const wrap = (real, kind) => { + if (!proxies.has(real)) { + const p = { + get name() { return real.name; }, + get state() { return state.visible && real.state === "connected" ? "connected" : "disconnected"; }, + open: async () => {}, + get onmidimessage() { return real.onmidimessage; }, + set onmidimessage(f) { real.onmidimessage = f; }, + }; + if (kind === "out") { + p.send = (bytes) => { + if (!state.visible) throw new Error("disconnected"); + if (!state.locked) return; // restarting firmware: no answer + if (vanishAfterSends && !state.vanished && ++state.sends > vanishAfterSends) { + state.vanished = true; state.locked = false; state.visible = false; + timer = setTimeout(phase, down); // back after `down` ms, and then up for good + state.cycle = answerOn - 1; + throw new Error("disconnected"); + } + real.send(bytes); + }; + } + proxies.set(real, p); + } + return proxies.get(real); + }; + const access = { + inputs: { values: () => (state.visible ? [...sim.access.inputs.values()].map((r) => wrap(r, "in")) : [])[Symbol.iterator]() }, + outputs: { values: () => (state.visible ? [...sim.access.outputs.values()].map((r) => wrap(r, "out")) : [])[Symbol.iterator]() }, + }; + return { access, cycles: () => state.cycle, end: async () => { stopped = true; clearTimeout(timer); return sim.end(); } }; +} diff --git a/tests/test_audio.mjs b/tests/test_audio.mjs new file mode 100644 index 0000000..c9a452b --- /dev/null +++ b/tests/test_audio.mjs @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: GPL-3.0-only +// The simulated pedal's sound: the WebAssembly engine (tools/sim/px_engine.wasm) on known signals, against the native build, +// against the pedal's preset as the simulator reports it; and the bundled loops against their manifest. Run by tests/run_tests.sh. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { startSim } from "../tools/sim.mjs"; +import { BLOCK, ENGINE_RATE, PxEngine, presetBytes } from "../tools/sim/px-engine.js"; +import { engineModule, readWav, run, wavBytes } from "../tools/sim/render.mjs"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const TMP = mkdtempSync(join(tmpdir(), "opaudio-")); +const read = (p) => readFileSync(join(ROOT, p)); +const sha256 = (...buffers) => { const h = createHash("sha256"); for (const b of buffers) h.update(b); return h.digest("hex"); }; +const check = (what, fn) => { fn(); console.log(`${what.padEnd(76)} ok`); }; + +check("the committed engine is built from these sources (else: node tools/op.mjs wasm)", () => { + const sources = ["engine/px_wasm.c", "engine/px_dsp.c", "firmware/px_math.c", "engine/px_dsp.h", "engine/px_trim.h"]; + assert.equal(sha256(...sources.map(read)), read("tools/sim/px_engine.wasm.sources").toString().trim()); +}); + +const module = engineModule(); +const tone = (hz, seconds, level) => Float32Array.from({ length: Math.round(seconds * ENGINE_RATE / BLOCK) * BLOCK }, (_, i) => level * Math.sin(2 * Math.PI * hz * i / ENGINE_RATE)); +const engine = (preset = 0, master = 100) => { const e = new PxEngine(module); e.factory(preset); e.volume(master); return e; }; +const peak = (a) => a.reduce((m, v) => Math.max(m, Math.abs(v)), 0); +const finite = (a) => a.every(Number.isFinite); +const tail = (a) => a.subarray(a.length >> 1); +const power = (a, hz) => { // Goertzel: the power at one frequency + let s1 = 0, s2 = 0; + const k = 2 * Math.cos(2 * Math.PI * hz / ENGINE_RATE); + for (const v of a) { const s = v + k * s1 - s2; s2 = s1; s1 = s; } + return (s1 * s1 + s2 * s2 - k * s1 * s2) / a.length ** 2; +}; +const harmonics = (a, hz) => Array.from({ length: 8 }, (_, n) => power(a, hz * (n + 1))); +const IN = tone(220, 1, 0.15); + +check("a tone through the first preset is heard, without a NaN", () => { + const out = run(engine(0), IN); + assert.ok(finite(out)); + assert.ok(peak(tail(out)) > 0.02, `peak ${peak(tail(out))}`); +}); +check("every factory preset: tone, silence and overload stay finite", () => { + const count = 52, silence = new Float32Array(IN.length), loud = tone(82.4, 1, 4); + for (let p = 0; p < count; p++) { + const e = engine(p); + for (const input of [IN, silence, loud]) assert.ok(finite(run(e, input)), `preset ${p + 1}`); + } +}); +check("bypass passes the dry input, at MASTER", () => { + const e = engine(15, 80); + e.bypass(true); + const out = run(e, IN); + out.forEach((v, i) => assert.ok(Math.abs(v - IN[i] * 0.64) < 1e-6)); +}); +check("a preset change changes the spectrum", () => { + const clean = harmonics(tail(run(engine(0), IN)), 220), metal = harmonics(tail(run(engine(15), IN)), 220); + const share = (h) => h.slice(1).reduce((a, b) => a + b) / h[0]; + assert.ok(share(metal) > 3 * share(clean), `harmonics over fundamental: ${share(clean)} ${share(metal)}`); +}); +check("a block switched off changes the sound", () => { + const s = new PxEngine(module); + s.factory(15); + const bytes = new Uint8Array(s.x.memory.buffer, s.x.px_preset(), 110).slice(), amp = 14 + 5; + const a = run(s, IN); + bytes[amp] = 0; + s.preset(bytes); + const b = run(s, IN); + assert.notDeepEqual(harmonics(tail(a), 220), harmonics(tail(b), 220)); +}); +check("MASTER follows the firmware's curve", () => { + const ratio = peak(tail(run(engine(0, 40), IN))) / peak(tail(run(engine(0, 80), IN))); + assert.ok(Math.abs(ratio - 0.25) < 0.01, `ratio ${ratio}`); +}); +check("the tuner mutes the output with a ramp", () => { + const e = engine(0); + run(e, IN); + e.tunerMute(true); + const out = run(e, IN); + assert.ok(peak(out.subarray(0, BLOCK)) > 0, "the first block fades"); + assert.equal(peak(tail(out)), 0); +}); +check("output never leaves full scale", () => assert.ok(peak(run(engine(5), tone(110, 1, 8))) <= 1)); +// Every factory preset, on a test tone and on each bundled loop (at guitar level, 0.15), through the WebAssembly engine +// (MASTER 50, x0.25, so nothing clips) and the native build of the same code. Both must be finite and agree exactly: +// the code is the same, both use the pedal's px_math.c, and x0.25 is exact in floating point. The largest difference of +// each pair is the evidence (EVIDENCE_DIR: audio-parity.tsv). +const TOLERANCE = 0; +const GAIN = Math.fround(0.15); +check("every factory preset, on a tone and every loop: WebAssembly sounds as the native build", () => { + const exe = join(TMP, "preset"), input = join(TMP, "in.wav"), output = join(TMP, "out.f32"); + const cc = spawnSync(process.env.CC || "cc", ["-O2", "-fno-builtin", "-ffp-contract=off", "-Iengine", "tests/engine/preset.c", "engine/px_dsp.c", "firmware/px_math.c", "-lm", "-o", exe], { cwd: ROOT, stdio: "inherit" }); + assert.equal(cc.status, 0); + const signals = [{ name: "tone 220 Hz", wav: wavBytes(IN), gain: 1 }]; + for (const l of JSON.parse(read("assets/loops/manifest.json")).loops) signals.push({ name: l.file, wav: read(join("assets/loops", l.file)), gain: GAIN }); + const rows = ["preset\tsignal\tnative_peak\twasm_peak\tmax_abs_diff\tdiff_over_native_peak"]; + for (const { name, wav, gain } of signals) { + writeFileSync(input, wav); + const samples = readWav(wav).samples, heard = samples.map((v) => v * gain); + for (let preset = 0; preset < 52; preset++) { + assert.equal(spawnSync(exe, [String(preset), input, output, String(gain)]).status, 0); + const b = readFileSync(output), native = new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.length)), e = engine(preset, 50); + e.x.px_reset(); // as the harness: one px_engine_set() on a new engine + const wasm = run(e, heard).subarray(0, native.length); + assert.ok(finite(native) && finite(wasm), `preset ${preset + 1}, ${name}: not finite`); + const scale = peak(native); + assert.ok(scale > 0.001 && scale < 4, `preset ${preset + 1}, ${name}: peak ${scale}`); + let worst = 0; + wasm.forEach((v, i) => { worst = Math.max(worst, Math.abs(v - native[i] * 0.25)); }); + assert.ok(worst <= TOLERANCE * scale, `preset ${preset + 1}, ${name}: differs by ${worst} (native peak ${scale})`); + rows.push([preset + 1, name, scale, peak(wasm), worst, worst / scale].join("\t")); + } + } + if (process.env.EVIDENCE_DIR) writeFileSync(join(process.env.EVIDENCE_DIR, "audio-parity.tsv"), rows.join("\n") + "\n"); +}); + +const sim = await startSim({ port: 0, flash: join(TMP, "flash.bin") }); +try { + const state = await (await fetch(sim.url + "/api/state")).json(); + check("the pedal's preset, as the simulator reports it, is the engine's", () => { + const e = engine(state.slot); + const memory = new Uint8Array(e.x.memory.buffer, e.x.px_preset(), 110); + assert.deepEqual([...presetBytes(state.preset)], [...memory]); + }); + const served = await fetch(sim.url + "/sim/px_engine.wasm"); + check("the engine is served as WebAssembly", () => assert.equal(served.headers.get("content-type"), "application/wasm")); + await served.arrayBuffer(); + const post = (path) => fetch(sim.url + "/api/" + path, { method: "POST" }); + await post("load/17"); + await post("bypass/1"); + const s = await (await fetch(sim.url + "/api/state")).json(); + check("the status follows the measured load and the bypass", () => assert.deepEqual(s.dsp, { load: 17, bypass: 1, core1: 1 })); +} finally { + await sim.close(); +} + +const ALLOWED = { "CC0-1.0": "publicdomain/zero/1.0", "CC-BY-4.0": "licenses/by/4.0", "CC-BY-3.0": "licenses/by/3.0", "CC-BY-SA-4.0": "licenses/by-sa/4.0" }; +const manifest = JSON.parse(read("assets/loops/manifest.json")); +const attribution = read("assets/loops/ATTRIBUTION.md").toString(); +check("four to six loops, a few MB", () => { + assert.ok(manifest.loops.length >= 4 && manifest.loops.length <= 6); + const bytes = manifest.loops.reduce((n, l) => n + statSync(join(ROOT, "assets/loops", l.file)).size, 0); + assert.ok(bytes < 5e6, `${bytes} bytes`); +}); +for (const l of manifest.loops) { + const bytes = read(join("assets/loops", l.file)), { rate, channels, samples } = readWav(bytes); + check(`${l.file}: licence, checksum, format, level, seam`, () => { + assert.ok(l.license in ALLOWED, l.license); + assert.ok(l.license_url.includes(ALLOWED[l.license]), l.license_url); + assert.ok(l.title && l.author && l.source.startsWith("https://") && l.attribution); + assert.ok(attribution.includes(l.attribution), "ATTRIBUTION.md"); + for (const field of [l.file, l.author, l.source, l.license_url]) assert.ok(attribution.includes(field), `ATTRIBUTION.md lacks ${field}`); + assert.ok(["dry", "amped"].includes(l.signal)); + assert.equal(l.sha256, sha256(bytes)); + assert.equal(rate, l.sample_rate); + assert.equal(rate, ENGINE_RATE); + assert.equal(channels, 1); + assert.ok(Math.abs(samples.length / rate - l.seconds) < 0.01); + const p = peak(samples); + assert.ok(p > 0.5 && p <= 0.75, `peak ${p}`); + const largest = peak(samples.slice(1).map((v, i) => v - samples[i])); + assert.ok(Math.abs(samples[0] - samples.at(-1)) < largest / 2, "the end meets the start"); + assert.ok(finite(run(engine(0), samples.map((v) => v * 0.15)))); + }); +} +rmSync(TMP, { recursive: true, force: true }); diff --git a/tests/test_audio_page.mjs b/tests/test_audio_page.mjs new file mode 100644 index 0000000..41c106f --- /dev/null +++ b/tests/test_audio_page.mjs @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: GPL-3.0-only +// The pedal's sound on the skin in a real browser (headless Chromium, Playwright, a fake audio input): the input picker, the +// meters, the engine in its AudioWorklet following the pedal. A screenshot goes to OUTDIR. +// node tests/test_audio_page.mjs OUTDIR +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { startSim } from "../tools/sim.mjs"; +import { wavBytes } from "../tools/sim/render.mjs"; +const require = createRequire(import.meta.url); +const { chromium } = require("playwright"); + +const OUT = process.argv[2] || mkdtempSync(join(tmpdir(), "opaudiopage-")); +mkdirSync(OUT, { recursive: true }); +const TMP = mkdtempSync(join(tmpdir(), "opaudiosim-")); +let failed = 0; +const ok = (c, what) => { console.log(`${what.padEnd(76)} ${c ? "ok" : "FAIL"}`); if (!c) failed++; }; + +const tone = (rate, seconds, level) => wavBytes(Float32Array.from({ length: Math.round(rate * seconds) }, (_, i) => level * Math.sin(2 * Math.PI * 330 * i / rate)), rate); +const LONG = join(TMP, "tone-48k.wav"), SHORT = join(TMP, "short.wav"); +writeFileSync(LONG, tone(48000, 2, 0.5)); +writeFileSync(SHORT, tone(44100, 0.3, 0.5)); + +const sim = await startSim({ port: 0, flash: join(TMP, "flash.bin") }); +const state = async () => (await fetch(sim.url + "/api/state")).json(); +const post = (path) => fetch(sim.url + "/api/" + path, { method: "POST" }); +const browser = await chromium.launch({ args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", "--autoplay-policy=user-gesture-required"] }); +const page = await (await browser.newContext({ viewport: { width: 1280, height: 1000 } })).newPage(); +page.on("pageerror", (e) => ok(false, `page error: ${e.message}`)); +await page.goto(sim.url + "/"); +await page.waitForSelector("body[data-frame]"); +await page.waitForSelector("#loop option", { state: "attached" }); + +const audio = () => page.evaluate(() => window.pedal.audio()); +const db = async (id) => { const v = await page.locator(id).getAttribute("data-db"); return v === null || v === "-inf" ? -Infinity : Number(v); }; +const settle = (ms = 400) => page.waitForTimeout(ms); +const until = async (test, ms = 3000) => { const end = Date.now() + ms; for (;;) { if (await test()) return true; if (Date.now() > end) return false; await page.waitForTimeout(25); } }; +// the loudest reading over a moment: the meters follow the sound +const loudest = async (id, ms = 500) => { let m = -Infinity; const end = Date.now() + ms; while (Date.now() < end) { m = Math.max(m, await db(id)); await page.waitForTimeout(40); } return m; }; +const near = (a, b, tolerance) => Math.abs(a - b) <= tolerance; + +let a = await audio(); +ok(a.running === false && a.sampleRate === null, "nothing runs before a click: browsers forbid it"); +const loops = await page.locator("#loop option").count(); +ok(loops >= 4 && loops <= 6, `the picker lists the bundled loops (${loops})`); +ok(/CC/.test(await page.locator("#credit").textContent()), "the chosen loop's credit and licence are on the page"); +ok(await page.locator("[data-panel=device]").isHidden(), "the device panel waits for its radio"); + +await page.click("#play"); +ok(await until(async () => (await audio()).running && await db("#in-meter") > -50), "Play starts the context and the input meter moves"); +a = await audio(); +ok(a.sampleRate === 44100, "the context runs at the engine's rate"); +ok(await until(async () => await db("#out-meter") > -50), "the output meter moves"); +ok(await until(async () => (await audio()).heard === (await state()).name), "what is heard is the preset on the screen"); +ok(await until(async () => (await state()).dsp.load !== 42, 2000), "the load on the pedal's status is measured"); +ok((await page.locator("#play").textContent()) === "Stop", "the button stops now"); +await page.locator("[data-control=fs_up]").click(); +ok(await until(async () => (await audio()).heard === (await state()).name && (await state()).slot === 1), "a footswitch changes what is heard"); +await page.screenshot({ path: join(OUT, "sound.png") }); +await page.click("#play"); +ok((await audio()).playing === false, "Stop stops the source"); +ok(await until(async () => await db("#in-meter") === -Infinity), "the input meter falls to nothing"); + +await page.check("[name=source][value=file]"); +ok(await page.locator("#drop").isVisible(), "the File radio shows the drop zone"); +await page.setInputFiles("#file", LONG); +ok(await until(async () => (await audio()).playing), "a chosen file plays at once"); +const level = await loudest("#in-meter"); +ok(near(level, 20 * Math.log10(0.5 * 0.15), 1), `a 48 kHz file reaches the engine at the guitar level (${level.toFixed(1)} dB)`); +await page.locator("#level").fill("200"); +ok(near(await (settle(), loudest("#in-meter")), level + 6, 1), "the level slider moves the input"); +await page.locator("#level").fill("100"); +await post("volume/0"); +ok(await until(async () => await db("#out-meter") === -Infinity), "MASTER at 0 silences the output"); +await post("volume/80"); +await settle(); +const wet = await loudest("#out-meter"); +await page.check("#bypass"); +ok(await until(async () => (await state()).dsp.bypass === 1), "the Bypass box reaches the pedal"); +await settle(); +const dry = await loudest("#out-meter"); +ok(near(dry, level + 20 * Math.log10(0.64), 1), `bypass passes the dry input, at MASTER (${dry.toFixed(1)} dB)`); +ok(!near(dry, wet, 1), `and it differs from the chain (${wet.toFixed(1)} dB)`); +await page.uncheck("#bypass"); +ok(await until(async () => (await state()).dsp.bypass === 0), "bypass comes off"); +await post("press/amp"); await post("knob/2"); await post("press/push"); await post("knob/-100"); +await settle(600); +const low = await loudest("#out-meter"); +await post("knob/100"); +await settle(600); +const high = await loudest("#out-meter"); +ok(high - low > 1, `a setting on the AMP page changes the sound (${low.toFixed(1)} to ${high.toFixed(1)} dB)`); +await post("press/push"); await post("press/home"); + +await page.uncheck("#repeat"); +await page.evaluate(async (bytes) => { + const transfer = new DataTransfer(); + transfer.items.add(new File([new Uint8Array(bytes)], "dropped.wav", { type: "audio/wav" })); + document.dispatchEvent(new DragEvent("drop", { dataTransfer: transfer, bubbles: true, cancelable: true })); +}, [...tone(44100, 0.3, 0.5)]); +ok(await until(async () => (await page.locator("#file-name").textContent()) === "dropped.wav"), "a dropped file is taken"); +ok(await until(async () => (await page.locator("#play").textContent()) === "Play"), "without Loop, a short file ends by itself"); +await page.check("#repeat"); + +await page.check("[name=source][value=device]"); +ok(await page.locator("#device-warning").isVisible(), "the device panel warns about feedback"); +ok(!(await page.isChecked("#hear")), "a device starts unheard"); +await page.click("#play"); +ok(await until(async () => (await audio()).device), "Play opens the device"); +ok(await until(async () => (await page.locator("#device option").count()) > 1), "the picker lists the computer's inputs"); +ok((await audio()).muted === true, "the device is muted by default"); +ok(await until(async () => await db("#in-meter") > -60), "and its meter moves"); +await page.check("#hear"); +ok((await audio()).muted === false, "the box lets it through"); +await page.selectOption("#device", { index: 1 }); +ok(await until(async () => (await audio()).device), "another input can be picked"); +await page.click("#play"); +ok((await audio()).playing === false, "Stop closes it"); + +await page.check("[name=source][value=loop]"); +await page.focus("#level"); +const { knob } = await state(); +await page.keyboard.press("ArrowRight"); +await settle(200); +ok((await state()).knob === knob && (await page.inputValue("#level")) === "105", "arrow keys on a slider stay with the slider"); + +await browser.close(); +await sim.close(); +rmSync(TMP, { recursive: true, force: true }); +console.log(failed ? `${failed} FAILED` : "all ok"); +process.exit(failed ? 1 : 0); diff --git a/tests/test_ble_sources.mjs b/tests/test_ble_sources.mjs new file mode 100644 index 0000000..0a918a2 --- /dev/null +++ b/tests/test_ble_sources.mjs @@ -0,0 +1,21 @@ +// The Bluetooth test image's vendor objects (third_party/jieli-ble, docs/BLE-TEST.md): MEMBERS lists exactly the files +// that are there, each is LLVM bitcode, and the licence text is JieLi's Apache-2.0 file. (The image itself is built by +// `make ble`; this needs no Docker.) +import assert from "node:assert/strict"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const dir = join(repo, "third_party/jieli-ble"); +const listed = readFileSync(join(dir, "MEMBERS"), "utf8").split("\n").filter((l) => l && !l.startsWith("#")).map((l) => l.split("\t")[2]); +assert.ok(listed.length > 0 && listed.every(Boolean), "MEMBERS has archive, member and file on each line"); +const onDisk = ["btctrler", "cpu"].flatMap((d) => readdirSync(join(dir, d)).map((f) => `${d}/${f}`)); +assert.deepEqual([...listed].sort(), onDisk.sort(), "MEMBERS lists exactly the objects in the directory"); +for (const f of listed) { + const head = readFileSync(join(dir, f)).subarray(0, 4); + assert.deepEqual([...head], [0x42, 0x43, 0xc0, 0xde], `${f} is LLVM bitcode, as published`); + assert.ok(statSync(join(dir, f)).size > 100, `${f} is not empty`); +} +assert.equal(readFileSync(join(dir, "LICENSE-JieLi-Apache-2.0"), "utf8"), readFileSync(join(repo, "firmware/sdk/LICENSE-JieLi-Apache-2.0"), "utf8"), "the licence text is JieLi's"); +console.log(`ble sources: ${listed.length} objects ok`); diff --git a/tests/test_ble_status.c b/tests/test_ble_status.c new file mode 100644 index 0000000..0d8ad7e --- /dev/null +++ b/tests/test_ble_status.c @@ -0,0 +1,134 @@ +/* The Bluetooth test image's status text and failure codes (firmware/ble/ble_status.h): which step failed, with which + * return value, and the stage a timeout stalled in. The header has no hardware access, so it is compiled as is. */ +#include +#include +#include "../firmware/ble/ble_status.h" +#include "../firmware/ble/ble_diag.h" +#include "../firmware/ble/ble_adv.h" + +static int bad; +static void is(const char *what, const char *want) +{ + char b[48]; + ble_status_text(b); + if (strcmp(b, want)) { printf("FAIL %s: \"%s\", wanted \"%s\"\n", what, b, want); bad++; } + if (strlen(b) >= 24) { printf("FAIL %s: \"%s\" does not fit 24 bytes\n", what, b); bad++; } +} +static void reset(void) { ble_state = BLE_OFF; ble_stage = 0; ble_fail_step = 0; ble_fail_code = 0; } + +int main(void) +{ + reset(); is("off", "OFF"); + ble_state = BLE_STARTING; ble_stage_set(BLE_STAGE_TASK); is("starting, task", "STARTING 1"); + ble_stage_set(BLE_STAGE_RF); is("starting, radio init", "STARTING 3"); + ble_stage_set(BLE_STAGE_READY); is("starting, waiting for the controller", "STARTING 4"); + + reset(); ble_state = BLE_STARTING; + if (ble_judge_start(0, 1) != 0 || ble_state != BLE_STARTING) { puts("FAIL: a good start was judged failed"); bad++; } + if (ble_judge_start(-7, 0) != BLE_FAIL_INIT) { puts("FAIL: init error not judged as step 2"); bad++; } + is("btctrler_task_init returned -7", "FAILED 2 -7"); + + reset(); ble_state = BLE_STARTING; + if (ble_judge_start(0, 0) != BLE_FAIL_READY) { puts("FAIL: not-ready not judged as step 3"); bad++; } + is("btctrler_task_ready returned 0", "FAILED 3 0"); + + reset(); ble_state = BLE_STARTING; ble_fail(BLE_FAIL_TASK_CREATE, -1); is("task_create failed", "FAILED 1 -1"); + reset(); ble_state = BLE_STARTING; ble_fail(BLE_FAIL_ADV_ENABLE, 12); is("ll_hci_adv_enable returned 12", "FAILED 4 12"); + + reset(); ble_state = BLE_STARTING; ble_stage_set(BLE_STAGE_RF); + ble_fail(BLE_FAIL_TIMEOUT, (int32_t)ble_stage); is("timeout in the radio init", "FAILED 5 3"); + + reset(); ble_fail(BLE_FAIL_INIT, -2147483647 - 1); is("most negative code", "FAILED 2 -2147483648"); + reset(); ble_state = BLE_ADV; is("advertising", "ADVERTISING"); + /* the full line */ + reset(); ble_state = BLE_STARTING; ble_stage_set(BLE_STAGE_READY); ble_judge_start(-7, 0); + { char f[96]; ble_status_full(f); + if (strcmp(f, "FAILED 2 -7 step btctrler_task_init stage ready-wait")) { printf("FAIL full line: \"%s\"\n", f); bad++; } + if (strlen(f) >= 96) { puts("FAIL: full line too long"); bad++; } } + reset(); ble_state = BLE_STARTING; ble_stage_set(BLE_STAGE_RF); ble_fail(BLE_FAIL_TIMEOUT, (int32_t)ble_stage); + { char f[96]; ble_status_full(f); + if (strcmp(f, "FAILED 5 3 step timeout stage radio-init")) { printf("FAIL timeout line: \"%s\"\n", f); bad++; } } + /* the marquee: "ABCDEFGH" in a window of 4, gap 3, rest 4 ticks */ + { static const struct { uint32_t tick; const char *want; } m[] = { + {0, "ABCD"}, {3, "ABCD"}, /* the rest on the start */ + {4, "ABCD"}, {5, "BCDE"}, {8, "EFGH"}, + {9, "FGH "}, {11, "H "}, {12, " A"}, /* through the gap and round */ + {14, " ABC"}, /* the last step of the cycle: one gap column left */ + {15, "ABCD"}, {19, "ABCD"}, /* a whole cycle (rest 4 + period 11 = 15 ticks) is back at the start */ + }; + int i; char o[8]; + for (i = 0; i < (int)(sizeof m / sizeof m[0]); i++) { + ble_marquee("ABCDEFGH", m[i].tick, 4, o); + if (strcmp(o, m[i].want)) { printf("FAIL marquee tick %u: \"%s\", wanted \"%s\"\n", m[i].tick, o, m[i].want); bad++; } + } + ble_marquee("ABCD", 7, 4, o); /* fits: never moves */ + if (strcmp(o, "ABCD")) { puts("FAIL: a text that fits was scrolled"); bad++; } } + reset(); ble_state = BLE_STARTING; ble_stage_set(BLE_STAGE_BB); ble_wait = 0x01C1A5E0u; ble_fail(BLE_FAIL_TIMEOUT, (int32_t)ble_stage); + { char f[96]; ble_status_full(f); ble_wait = 0; + if (strcmp(f, "FAILED 5 8 step timeout stage baseband-irq wait 01C1A5E0")) { printf("FAIL wait line: \"%s\"\n", f); bad++; } } + /* the stall watch: silent while no poll runs, fires once a poll has run for more than BLE_STALL_TICKS ticks */ + { uint32_t i; int fired = 0; + ble_busy(0); for (i = 0; i < 2u * BLE_STALL_TICKS; i++) fired |= ble_stall_tick(); + if (fired) { puts("FAIL: the stall watch fired with no poll running"); bad++; } + ble_busy(1); for (i = 0; i < BLE_STALL_TICKS - 1u; i++) fired |= ble_stall_tick(); + if (fired) { puts("FAIL: the stall watch fired before its limit"); bad++; } + ble_busy(0); ble_busy(1); /* each poll starts the count again */ + for (i = 0; i < BLE_STALL_TICKS - 1u; i++) fired |= ble_stall_tick(); + if (fired) { puts("FAIL: the stall watch did not restart with the poll"); bad++; } + if (!ble_stall_tick()) { puts("FAIL: the stall watch did not fire at its limit"); bad++; } + ble_busy(0); } + /* the proof counters: the advertising line, numbers first, registers in hex */ + { char f[96]; + ble_cnt_irq_ev = 12; ble_cnt_hci = 5; ble_cnt_irq_rx = 0; ble_cnt_timer = 340; ble_cnt_slot = 0; + ble_adv_text(f, 0x2001u, 0x101u, 0u, 0x80u, 3u, 3u, 1u); + if (strcmp(f, "ADV i12 e5 r0 t340 s0 CON 2001 IEN 101 IPND 0 STAT 80 IC45 3 IC29 3 BEN 1")) { printf("FAIL adv line: \"%s\"\n", f); bad++; } + if (strlen(f) >= 96) { puts("FAIL: adv line too long"); bad++; } + ble_cnt_irq_ev = 4000000000u; ble_adv_text(f, 0xFFFFFFFFu, 0, 0, 0, 0, 0, 0); + if (strncmp(f, "ADV i2147483647 ", 16) || strlen(f) >= 96) { printf("FAIL big counter: \"%s\"\n", f); bad++; } + ble_cnt_irq_ev = 0; ble_cnt_hci = 0; + static const uint8_t evt[] = { 0x0E, 4, 1, 0x0A, 0x20, 0 }; /* a command complete event */ + ble_count_hci(4, evt); ble_count_hci(4, evt); + if (ble_cnt_hci != 2 || ble_hci_last != 0x040Eu) { puts("FAIL: HCI packets are not counted"); bad++; } + ble_adv_text(f, 0, 0, 0, 0, 0, 0, 0); + if (strncmp(f, "ADV i0 e2 ", 10)) { printf("FAIL hci in the line: \"%s\"\n", f); bad++; } + char w[16]; ble_marquee(f, 0, 11, w); + if (strncmp(w, "ADV i0 e2 ", 10)) { printf("FAIL: the numbers are not first on screen: \"%s\"\n", w); bad++; } } + /* the advertisement against the Core Specification (ble_adv.h) */ + { static const uint8_t uuid_le[16] = { 0x00,0xC7,0xC4,0x4E,0xE3,0x6C,0x51,0xA7,0x33,0x4B,0xE8,0xED,0x5A,0x0E,0xB8,0x03 }; + static const char name[] = "OpenPocket-BLE-TEST"; + unsigned n, i; int flags = 0, uuid = 0; + for (n = 0; n < sizeof ble_adv_data; ) { /* length-type-value structures, no padding, no overrun */ + unsigned l = ble_adv_data[n]; + if (!l || n + 1u + l > sizeof ble_adv_data) { puts("FAIL: advertising data is not whole AD structures"); bad++; break; } + if (ble_adv_data[n + 1] == 0x01 && l == 2 && ble_adv_data[n + 2] == 0x06) flags = 1; /* general discoverable, no BR/EDR */ + if (ble_adv_data[n + 1] == 0x07 && l == 17 && !memcmp(ble_adv_data + n + 2, uuid_le, 16)) uuid = 1; + n += 1u + l; + } + if (sizeof ble_adv_data > 31) { puts("FAIL: advertising data over 31 bytes"); bad++; } + if (!flags || !uuid) { puts("FAIL: flags or the BLE-MIDI UUID missing from the advertising data"); bad++; } + if (sizeof ble_scan_rsp > 31 || ble_scan_rsp[0] != sizeof ble_scan_rsp - 1u || ble_scan_rsp[1] != 0x09 || + ble_scan_rsp[0] != 1u + strlen(name) || memcmp(ble_scan_rsp + 2, name, strlen(name))) { puts("FAIL: scan response is not the complete local name"); bad++; } + for (i = 0, n = 0; i < 6; i++) n |= ble_adv_mac[i]; + if (!n || (ble_adv_mac[5] & 1u)) { puts("FAIL: address is zero or a group address"); bad++; } + if (BLE_ADV_SCAN_IND != 2 || BLE_ADV_INTERVAL < 0x00A0 || BLE_ADV_INTERVAL > 0x4000 || BLE_ADV_CHMAP != 7 || BLE_ADV_FILTER != 0) { + puts("FAIL: advertising parameters out of range"); bad++; } } + puts(bad ? "ble status: FAILED" : "ble status: ok"); + { /* the log channel's boot lines (ble_diag.h) */ + char l[224]; + ble_boot_t b = { 0 }; + ble_boot_text(l, 12345u, 1u, 7u, 0x44u, 1u); + if (strcmp(l, "UP 12345 SAFE 1 STAGE 7 RST 44 GUARD 1")) { printf("FAIL boot line: %s\n", l); bad++; } + ble_last_text(l, &b); + if (strcmp(l, "LAST OK")) { printf("FAIL last boot, fine: %s\n", l); bad++; } + b.failed = 1; b.stage = 7; b.blocks = 9; b.load = 42; b.peak = 61; b.c1 = 1; + ble_last_text(l, &b); + if (strcmp(l, "LAST FAILED STAGE 7 BLOCKS 9 LOAD 42 PEAK 61 NAN 0 CPU1 1 LOOPS 0")) { printf("FAIL last boot, failed: %s\n", l); bad++; } + b.crashed = 1; b.vec = 0x3Fu; b.pc = 0x02000DC8u; b.rets = 0x02001234u; b.emu = 8u; b.uptime = 4100u; + ble_last_text(l, &b); + if (strcmp(l, "LAST FAILED STAGE 7 BLOCKS 9 LOAD 42 PEAK 61 NAN 0 CPU1 1 LOOPS 0 CRASH VEC 3F PC 2000DC8 RETS 2001234 DBG 0 EMU 8 UP 4100")) { printf("FAIL last boot, crash: %s\n", l); bad++; } + b.blocks = b.load = b.peak = b.nans = b.c1 = b.loops = b.vec = b.pc = b.rets = b.dbg = b.emu = b.uptime = 0xFFFFFFFFu; b.stage = 0xFFFFFFFFu; + ble_last_text(l, &b); + if (strlen(l) > 211u) { printf("FAIL last boot: the widest line is %u long, ED_DIAG_LEN is 224\n", (unsigned)strlen(l)); bad++; } + } + return bad != 0; +} diff --git a/tests/test_flash.mjs b/tests/test_flash.mjs new file mode 100644 index 0000000..439b7b4 --- /dev/null +++ b/tests/test_flash.mjs @@ -0,0 +1,217 @@ +// 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 { Updater } from "../docs/pxota.js"; +import { logicalImage } from "../docs/pxpkg.js"; +import { nodeMidiAccess } from "../tools/midi-node.mjs"; +import { loopAccess, 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"); +} + +// 10. --rescue against a pedal that restarts in a loop (USB ports there for a moment, gone, there again) +async function rescueRun(argv, loop) { + const dump = join(TMP, `dump${Math.random().toString(36).slice(2)}.bin`); + const sim = simAccess([old, "--dump", dump]); + await sim.ready; + const lp = loopAccess(sim, loop); + const lines = []; + const t0 = Date.now(); + const code = await main(argv, { access: lp.access, say: (l) => lines.push(l), timing: FAST }); + const cycles = lp.cycles(); + await lp.end(); + return { code, out: lines.join("\n"), log: sim.log, dump: readFileSync(dump), cycles, secs: (Date.now() - t0) / 1000 }; +} +const rescueArgs = ["--rescue", "--confirm-flash", "--package", px, "--manifest", man]; +{ + const lines = []; + const code = await main(["--rescue", "--package", px, "--manifest", man], { access: notContacted, say: say(lines), timing: FAST }); + ok(code === 2 && /--confirm-flash/.test(lines.join("\n")), "rescue without --confirm-flash: refused, MIDI never touched"); + const bad = await main(["--rescue", "--timeout", "0", "--confirm-flash"], { access: notContacted, say: say(lines), timing: FAST }); + ok(bad === 2, "rescue with a timeout of 0: refused"); +} +{ + const r = await rescueRun([...rescueArgs, "--timeout", "30"], { answerOn: 3 }); + ok(r.code === 0 && r.cycles === 3 && /done: the pedal reports PocketX_924/.test(r.out), `rescue: the pedal answers on the 3rd restart and is installed (cycles ${r.cycles}, ${r.secs}s) ${r.code ? "\n" + r.out : ""}`); + ok(/still waiting|pedal answered/.test(r.out) && /pedal answered: PocketX_923/.test(r.out), "rescue: progress lines say when the pedal answered"); + const f = join(TMP, "rescued.bin"); writeFileSync(f, r.dump); + ok(appIn(f) === "PocketX_924 CRC-OK" && r.log.filter((l) => /step1/.test(l)).length === 1, "rescue: flash holds PocketX_924, every CRC right, one transfer"); +} +{ + const r = await rescueRun([...rescueArgs, "--timeout", "2"], { answerOn: 1e9 }); + ok(r.code === 1 && /no pedal answered in 2 s/.test(r.out) && /WL80UBOOT/.test(r.out) && untouched(r) && r.secs < 15, `rescue: a pedal that never answers times out, exit 1, nothing written (${r.secs}s)`); +} +{ + const r = await rescueRun(["--rescue", "--confirm-flash", "--package", bb, "--manifest", man, "--timeout", "10"], { answerOn: 2 }); + ok(r.code === 2 && /wrong model/.test(r.out) && untouched(r), "rescue: a BlackBox package on a Pocket X is refused, nothing sent"); +} +{ + const r = await rescueRun(["--rescue", "--confirm-flash", "--manifest", man, "--timeout", "30"], { answerOn: 2 }); + ok(r.code === 0 && /PocketX_924/.test(r.out) && !/BlackBox_924/.test(r.out.split("pedal answered")[1] || ""), "rescue: without --package the pedal's own model's package is picked"); +} +{ + const r = await rescueRun(["--rescue", "--dry-run", "--manifest", man, "--timeout", "30"], { answerOn: 2 }); + ok(r.code === 0 && /dry run ok/.test(r.out) && untouched(r), "rescue --dry-run: waits for the pedal, checks the model, writes nothing"); +} +{ + const r = await rescueRun([...rescueArgs, "--timeout", "30"], { answerOn: 1, vanishAfterSends: 3 }); + ok(r.code === 0 && /went away/.test(r.out) && /done: the pedal reports PocketX_924/.test(r.out), `rescue: the pedal vanishes mid-transfer, returns, and is installed ${r.code ? "\n" + r.out : ""}`); +} + +// 11. a fresh --rescue finds the pedal in the OpenPocket update loader an interrupted install left behind +async function loaderRun(argv) { + const dump = join(TMP, `dump${Math.random().toString(36).slice(2)}.bin`); + const sim = simAccess([old, "--dump", dump]); + await sim.ready; + const up = new Updater(sim.access, null, FAST); + try { await up.installOn(await up.find((id) => id.model === "PocketX"), logicalImage(readFileSync(px)), "PocketX_924", (s) => { if (s === "loader") throw new Error("interrupted"); }); } catch (_) { /* left in the loader */ } + 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 r = await loaderRun([...rescueArgs, "--timeout", "30"]); + const f = join(TMP, "loader_rescued.bin"); writeFileSync(f, r.dump); + ok(r.code === 0 && /done: the pedal reports PocketX_924/.test(r.out) && appIn(f) === "PocketX_924 CRC-OK", `rescue: a pedal left in its update loader is finished with the given package ${r.code ? "\n" + r.out : ""}`); +} +{ + const r = await loaderRun(["--rescue", "--confirm-flash", "--manifest", man, "--timeout", "2"]); + ok(r.code === 1 && /no pedal answered in 2 s/.test(r.out) && /--package/.test(r.out) && !r.log.some((l) => /step2/.test(l)), "rescue: a loader without --package is not guessed at, the timeout says to name the package"); +} + +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/tests/test_log.mjs b/tests/test_log.mjs new file mode 100644 index 0000000..19ea537 --- /dev/null +++ b/tests/test_log.mjs @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: GPL-3.0-only +// OpenPocket — `node tools/op.mjs log` (tools/log.mjs): the DIAG request against the pedal's real editor and status-text code +// (tests/edsim.c built with -DOP_BLE_TEST=1), the same against firmware without it, and the states where less can be read. +// cc -O1 -DOP_BLE_TEST=1 -o build/edsim_diag tests/edsim.c engine/px_dsp.c firmware/px_math.c -lm && node tests/test_log.mjs +import { spawn } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { BOOT_STAGES, RESET_CAUSES, judge, main, parseBle, parseBoot, parseLast } from "../tools/log.mjs"; +import { simAccess } from "./simpedal.mjs"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +let failed = 0; +const ok = (c, what) => { console.log(`${what.padEnd(72)} ${c ? "ok" : "FAIL"}`); if (!c) failed++; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// a simulated pedal on a fake Web MIDI access; ble() steers its Bluetooth state +function sim(bin) { + const child = spawn(join(ROOT, "build", bin), [], { stdio: ["pipe", "pipe", "inherit"] }); + const access = { inputs: new Map(), outputs: new Map() }; + let input = null, buf = "", readyFn; + const ready = new Promise((r) => { readyFn = r; }); + const states = []; + 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 ")) { + const name = ln.slice(6); + input = { name, state: "connected", onmidimessage: null, open: async () => {} }; + access.inputs.set(name, input); + access.outputs.set(name, { name, state: "connected", open: async () => {}, send: (b) => child.stdin.write(Buffer.from(b).toString("hex") + "\n") }); + readyFn(); + } else if (ln.startsWith("#state ")) states.push(ln); + else if (ln && input?.onmidimessage) input.onmidimessage({ data: Uint8Array.from(Buffer.from(ln, "hex")) }); + } + }); + const done = new Promise((r) => child.on("exit", r)); + return { access, ready, states, ble: (l) => child.stdin.write(`#ble ${l}\n`), dump: () => child.stdin.write("#dump\n"), end: () => { child.stdin.end(); return done; } }; +} + +async function run(access, argv, opts = {}) { + const out = []; + const code = await main(argv, { access, say: (l) => out.push(l), ...opts }); + return { code, text: out.join("\n"), last: argv.includes("--json") ? JSON.parse(out[out.length - 1]) : null }; +} + +// ---- the text formats ---- +const adv = "ADV i12 e5 r0 t340 s0 CON 2001 IEN 101 IPND 0 STAT 80 IC45 3 IC29 3 BEN 1"; +let b = parseBle(adv); +ok(b.state === "advertising" && b.i === 12 && b.t === 340 && b.CON === 0x2001 && b.IC45 === 3 && b.BEN === 1 && !b.vm, "advertising line, every field"); +b = parseBle("ADV i12 e5 r0 t340 s0 V 7 A3 12AB CON 2001 IEN 101 IPND 0 STAT 80 IC45 3 IC29 3 BEN 1"); +ok(b.vm?.found === 7 && b.vm.area === 0xA3 && b.vm.trim === 0x12AB && b.CON === 0x2001, "advertising line with the stored radio records"); +b = parseBle("FAILED 5 3 step timeout stage radio-init wait 01C08000"); +ok(b.state === "failed" && b.step === 5 && b.code === 3 && b.stage === "radio-init" && b.wait === "01C08000", "failure line"); +ok(parseBle("FAILED 2 -7 step btctrler_task_init stage init").code === -7, "failure line, negative code"); +ok(parseBle("STARTING 4").stage === 4 && parseBle("OFF").state === "off", "starting and off"); +ok(parseBle("ADVERTISING?").state === "unknown", "a line this tool does not know stays unknown"); +b = parseBoot("UP 12345 SAFE 1 STAGE 7 RST 44 GUARD 1"); +ok(b.clockMs === 12345 && b.safe && b.stageName === "FIRST 6 S" && b.resetBy.join() === "WATCHDOG,SOFT" && b.failedBoots === 1, "boot line"); +b = parseLast("LAST FAILED STAGE 7 BLOCKS 9 LOAD 42 PEAK 61 NAN 0 CPU1 1 LOOPS 0 CRASH VEC 3F PC 2000DC8 RETS 2001234 DBG 0 EMU 8 UP 4100"); +ok(b.failed && b.load === 42 && b.crash.vec === 0x3F && b.crash.pc === 0x2000DC8 && b.crash.emu === 8 && b.crash.clockMs === 4100, "last-boot line with a crash"); +ok(!parseLast("LAST OK").failed, "last boot fine"); + +const fw = readFileSync(join(ROOT, "firmware/openpocket.c"), "utf8"); +const strings = (s) => [...s.matchAll(/"([^"]*)"/g)].map((m) => m[1]); +ok(strings(/ST_NAME\[ST_N\] = \{([^}]*)\}/.exec(fw)[1]).join() === BOOT_STAGES.join(), "boot stage names match firmware/openpocket.c"); +ok(strings(/N\[7\] = \{([^}]*)\}/.exec(fw)[1]).join() === RESET_CAUSES.join(), "reset causes match firmware/openpocket.c"); + +// ---- the verdicts on the real firmware text ---- +const fast = ["--interval", "0.01", "--json"]; +const seen = { ok: true, seconds: 10, hits: 42, others: 9, name: "OpenPocket-BLE-TEST", rssi: -60, connectable: false }; +const nothing = { ok: true, seconds: 10, hits: 0, others: 9, name: null }; + +let s = sim("edsim_diag"); +await s.ready; +let r = await run(s.access, fast); +ok(r.code === 1 && /Bluetooth is off/.test(r.last.verdict.reason), "firmware text: OFF in a normal boot fails"); +ok(r.last.level === "full" && r.last.boot.clockMs === 30000 && r.last.lastBoot.failed === false, "DIAG reaches the parser through the real editor code"); + +s.ble("failed 5 3"); +r = await run(s.access, fast); +ok(r.code === 1 && /still starting after 5 s/.test(r.last.verdict.reason) && /code 3/.test(r.last.verdict.reason), `FAILED in words: ${r.last.verdict.reason}`); + +s.ble("adv 100 500"); +r = await run(s.access, [...fast, "--count", "1"]); +ok(r.code === 3 && r.last.verdict.result === "pending", "one reading of counters decides nothing"); +r = await run(s.access, fast); +ok(r.code === 1 && /no baseband events/i.test(r.last.verdict.reason), "counters that stand still fail"); + +let n = 100; +const rising = async (scan) => { + const t = setInterval(() => s.ble(`adv ${n += 10} ${n * 5}`), 5); + const out = await run(s.access, ["--interval", "0.1", "--json", ...(scan ? ["--scan", "5"] : [])], scan ? { scan: async () => scan } : {}); + clearInterval(t); + return out; +}; +r = await rising(); +ok(r.code === 0 && r.last.verdict.result === "running" && /not proof/.test(r.last.verdict.reason), "rising counters: running, and not called proof"); +let v = judge([{ level: "full", time: "2026-11-06T13:00:00Z", boot: { stage: 8, clockMs: 178000 }, ble: parseBle("ADV i10 e0 r0 t0 s2 V 0 0 0 CON 6001 IEN 101 IPND 0 STAT 0 IC45 5 IC29 5 BEN 1") }, + { level: "full", time: "2026-11-06T13:00:02Z", boot: { stage: 8, clockMs: 1000 }, ble: parseBle("ADV i30 e0 r0 t0 s2 V 0 0 0 CON 6001 IEN 101 IPND 0 STAT 0 IC45 5 IC29 5 BEN 1") }]); +ok(v.result === "running" && /10\.0 a second/.test(v.reason) && v.notes.length === 3, `events without timers still run, across a clock wrap, with notes: ${v.notes.length}`); +r = await rising(seen); +ok(r.code === 0 && r.last.verdict.result === "pass" && r.last.scan.hits === 42, "rising counters and a sighting: pass"); +r = await rising(nothing); +ok(r.code === 1 && /saw no advertisement/.test(r.last.verdict.reason), "rising counters and no sighting: fail"); +r = await rising({ ok: false, why: "the Bluetooth adapter is not usable" }); +ok(r.code === 3 && r.last.verdict.result === "unknown", "a scanner that could not run decides nothing"); + +s.ble("safe"); +r = await run(s.access, fast); +ok(r.code === 1 && /last boot failed/i.test(r.last.verdict.reason) && /FIRST 6 S/.test(r.last.verdict.reason) && /WATCHDOG/.test(r.last.verdict.reason) && /safe mode/.test(r.last.verdict.reason), `safe mode in words: ${r.last.verdict.reason}`); +r = await run(s.access, ["--interval", "0.01", "--count", "1"]); +ok(/last boot FAILED/.test(r.text) && /verdict FAIL/.test(r.text) && /SAFE MODE/.test(r.text), "the plain report says the same"); + +s.dump(); await sleep(50); +const before = s.states.at(-1); +await run(s.access, fast); +s.dump(); await sleep(50); +ok(s.states.at(-1) === before && before.split("|")[2] === "1", "reading changes nothing on the pedal"); +await s.end(); + +// ---- what less can be read ---- +s = sim("edsim"); +await s.ready; +r = await run(s.access, fast); +ok(r.code === 3 && r.last.level === "info" && r.last.ping.load === 42 && /no log channel/.test(r.last.verdict.reason), "firmware without DIAG: version and load, and says what is missing"); +await s.end(); + +const blank = join(mkdtempSync(join(tmpdir(), "oplog-")), "flash.bin"); +writeFileSync(blank, Buffer.alloc(0x100000, 0xFF)); +const dev = simAccess([blank, "--stock", "BlackBox_016"]); +await dev.ready; +r = await run(dev.access, fast); +ok(r.code === 3 && r.last.level === "identity" && /identity/.test(r.last.verdict.reason), `stock firmware: ${r.last.verdict.reason}`); +await dev.end(); + +const port = (name) => ({ name, state: "connected", onmidimessage: null, open: async () => {}, send: () => {} }); +const mute = { inputs: new Map([["OpenPocket", port("OpenPocket")]]), outputs: new Map([["OpenPocket", port("OpenPocket")]]) }; +r = await run(mute, fast); +ok(r.code === 3 && r.last.level === "silent" && /nothing answers/.test(r.last.verdict.reason), "a port that does not answer"); +r = await run({ inputs: new Map(), outputs: new Map() }, fast); +ok(r.code === 3 && r.last.level === "none" && /CRASH screen/.test(r.last.verdict.reason), "no port: names the screens this cannot read"); + +// --wait: the pedal shows up during the wait +s = sim("edsim_diag"); +await s.ready; +s.ble("failed 1 -1"); +const late = { inputs: new Map(), outputs: new Map() }; +setTimeout(() => { for (const k of ["inputs", "outputs"]) for (const [n, p] of s.access[k]) late[k].set(n, p); }, 300); +r = await run(late, ["--wait", "5", "--interval", "0.05", "--count", "1", "--json"]); +ok(r.code === 1 && r.last.ble.step === 1, "--wait reads the pedal once it appears"); +await s.end(); + +r = await run({ inputs: new Map(), outputs: new Map() }, ["--nonsense"]); +ok(r.code === 2 && /usage/.test(r.text), "a bad argument prints the usage"); +ok(judge([{ level: "none" }]).result === "unknown", "no reading is never a pass"); + +if (failed) { console.log(`log test: ${failed} FAILED`); process.exit(1); } +console.log("log test passed"); +process.exit(0); diff --git a/tests/test_op.mjs b/tests/test_op.mjs index 50598cf..20160a0 100644 --- a/tests/test_op.mjs +++ b/tests/test_op.mjs @@ -23,6 +23,7 @@ chmodSync(join(fakebin, "docker"), 0o755); const root = join(tmp, "work"); mkdirSync(join(root, "tools"), { recursive: true }); cpSync(join(repo, "tools/op.mjs"), join(root, "tools/op.mjs")); +cpSync(join(repo, "tools/sim.mjs"), join(root, "tools/sim.mjs")); function op(args, { env = {}, cwd = root, path = fakebin, script = join(root, "tools/op.mjs") } = {}) { rmSync(log, { force: true }); @@ -41,6 +42,9 @@ let r = op(["build"]); assert.equal(r.status, 0); assert.deepEqual(r.calls, [["info"], BUILD, base(["sh", "firmware/build.sh"])], "build"); +r = op(["ble"]); +assert.deepEqual(r.calls.at(-1), base(["sh", "-c", "OP_BLE=1 sh firmware/build.sh && sha256sum firmware/openpocket-ble.bin build/ble/*.fwsc"]), "ble: the test image is its own build"); + r = op(["test"]); assert.deepEqual(r.calls.at(-1), base(["sh", "tests/run_tests.sh"]), "test"); @@ -97,16 +101,25 @@ r = op(["build"], { path: join(tmp, "empty") }); assert.notEqual(r.status, 0); assert.match(r.err, /Docker is not installed or not running/, "no docker at all"); -for (const p of ["build/x", "firmware/build/y", "docs/firmware/PocketX_900.fwsc", "docs/firmware/manifest.json", "engine/px_render"]) touch(p); +for (const p of ["build/x", "firmware/build/y", "firmware/build-ble/z", "firmware/openpocket-ble.bin", "docs/firmware/PocketX_900.fwsc", "docs/firmware/manifest.json", "engine/px_render"]) touch(p); touch("docs/firmware/README.md"); touch("engine/px_dsp.c"); r = op(["clean"], { path: join(tmp, "empty") }); assert.equal(r.status, 0, "clean needs no docker"); -for (const p of ["build", "firmware/build", "firmware/openpocket.bin", "docs/firmware/PocketX_900.fwsc", "docs/firmware/manifest.json", "engine/px_render"]) { +for (const p of ["build", "firmware/build", "firmware/build-ble", "firmware/openpocket-ble.bin", "firmware/openpocket.bin", "docs/firmware/PocketX_900.fwsc", "docs/firmware/manifest.json", "engine/px_render"]) { assert.ok(!existsSync(join(root, p)), `${p} removed`); } for (const p of ["docs/firmware/README.md", "engine/px_dsp.c", "guitar.wav"]) assert.ok(existsSync(join(root, p)), `${p} kept`); +r = op(["sim", "--help"]); +assert.equal(r.status, 0, "sim is passed to tools/sim.mjs"); +assert.match(r.err, /usage: node tools\/op\.mjs sim/, "sim prints its own usage"); +assert.deepEqual(r.calls, [], "sim runs on the host: no Docker"); + +r = op(["wasm"]); +assert.equal(r.status, 0); +assert.deepEqual(r.calls.at(-1).slice(-2), ["sh", "engine/wasm.sh"], "wasm builds the engine in the container"); + const make = spawnSync("make", ["--version"], { encoding: "utf8" }); if (make.status === 0) { rmSync(log, { force: true }); diff --git a/tests/test_pedal_input.mjs b/tests/test_pedal_input.mjs new file mode 100644 index 0000000..61acbe7 --- /dev/null +++ b/tests/test_pedal_input.mjs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-3.0-only +// OpenPocket — the pedal skin's input mapping (tools/sim/pedal-input.js): keys, drags and the wheel in the simulator's +// terms, the MASTER knob against the firmware's volume, PARA's press or turn, the backlights. No browser. +// node tests/test_pedal_input.mjs +import assert from "node:assert/strict"; +import { + BLOCK, FAST_MS, FX_BLOCKS, MASTER_STEP, backlights, dragDetents, isFast, keyAction, masterAngle, masterFollow, masterFromDrag, paraStep, wheelNotches, +} from "../tools/sim/pedal-input.js"; + +const tests = { + "the page's keys press what they name"() { + const holds = { ArrowUp: "fs_up", ArrowDown: "fs_dn", Enter: "push", Escape: "home", h: "home", H: "home", f: "fx", a: "amp", m: "mod", d: "dly", r: "rev", c: "cab" }; + for (const [key, control] of Object.entries(holds)) assert.deepEqual(keyAction(key), { hold: control }, key); + assert.deepEqual(keyAction("ArrowRight"), { turn: 1 }); + assert.deepEqual(keyAction("ArrowLeft"), { turn: -1 }); + assert.deepEqual(keyAction("]"), { master: MASTER_STEP }); + assert.deepEqual(keyAction("["), { master: -MASTER_STEP }); + assert.equal(keyAction("x"), null); + assert.equal(keyAction("Tab"), null); + }, + "a focused control takes the keys that belong to it"() { + assert.deepEqual(keyAction("Enter", "amp"), { hold: "amp" }); + assert.deepEqual(keyAction(" ", "fs_up"), { hold: "fs_up" }); + assert.deepEqual(keyAction("ArrowUp", "master"), { master: MASTER_STEP }); + assert.deepEqual(keyAction("ArrowLeft", "master"), { master: -MASTER_STEP }); + assert.deepEqual(keyAction("Home", "master"), { masterTo: 0 }); + assert.deepEqual(keyAction("End", "master"), { masterTo: 100 }); + assert.deepEqual(keyAction("ArrowUp", "para"), { turn: 1 }); + assert.deepEqual(keyAction("ArrowDown", "para"), { turn: -1 }); + assert.deepEqual(keyAction(" ", "para"), { hold: "push" }); + assert.deepEqual(keyAction("Enter", "para"), { hold: "push" }); + }, + "a drag on PARA counts detents, up and right clockwise, whatever the knob's size"() { + assert.equal(dragDetents(0, -100, 200), 5); + assert.equal(dragDetents(100, 0, 200), 5); + assert.equal(dragDetents(0, 100, 200), -5); + assert.equal(dragDetents(19, 0, 200), 0); + assert.equal(dragDetents(50, 0, 100), dragDetents(100, 0, 200)); + }, + "the wheel carries what is short of a notch"() { + assert.deepEqual(wheelNotches(0, -100), { notches: 1, rest: 0 }); + assert.deepEqual(wheelNotches(0, 300), { notches: -3, rest: 0 }); + assert.deepEqual(wheelNotches(0, -40), { notches: 0, rest: 40 }); + assert.equal(wheelNotches(80, -40).notches, 1); + assert.equal(wheelNotches(0, -3, 1).notches, 0); + assert.equal(wheelNotches(0, -1, 2).notches, 1); + }, + "detents closer than the firmware's 45 ms are a fast turn"() { + assert.equal(isFast(1000, 1000 + FAST_MS - 1, 1), true); + assert.equal(isFast(1000, 1000 + FAST_MS, 1), false); + assert.equal(isFast(1000, 1100, -3), true); + }, + "MASTER stays in 0..100 and follows a drag"() { + assert.equal(masterFromDrag(50, 0, -100, 200), 70); + assert.equal(masterFromDrag(50, -100, 0, 200), 30); + assert.equal(masterFromDrag(50, 0, 1000, 200), 0); + assert.equal(masterFromDrag(50, 0, -1000, 200), 100); + assert.equal(masterAngle(0), -135); + assert.equal(masterAngle(100), 135); + }, + "MASTER has the firmware's hysteresis of 2, and always reaches its ends"() { + assert.equal(masterFollow(50, 51), 50); + assert.equal(masterFollow(50, 52), 52); + assert.equal(masterFollow(50, 48), 48); + assert.equal(masterFollow(1, 0), 0); + assert.equal(masterFollow(99, 100), 100); + assert.equal(masterFollow(100, 140), 100); + }, + "PARA: a tap is one PUSH, a turn is none, a still press is held"() { + const run = (...events) => { + let g = "idle"; + const sent = []; + for (const e of events) { const s = paraStep(g, e); g = s.to; sent.push(...s.send); } + return { g, sent }; + }; + assert.deepEqual(run("down", "up"), { g: "idle", sent: ["down", "up"] }); + assert.deepEqual(run("down", "far", "still", "up"), { g: "idle", sent: [] }); + assert.deepEqual(run("down", "still", "far", "up"), { g: "idle", sent: ["down", "up"] }); + }, + "the backlights follow the blocks, the page and the focus"() { + const on = Array(12).fill(0); + const state = (over) => ({ mode: "chain", block: BLOCK.amp, preset: { on }, ...over }); + assert.deepEqual(backlights(state()), { home: "focus", fx: "off", amp: "off", cab: "off", mod: "off", dly: "off", rev: "off" }); + const some = [...on]; + some[BLOCK.dly] = 1; some[FX_BLOCKS[3]] = 1; + let lit = backlights(state({ preset: { on: some } })); + assert.equal(lit.dly, "on"); assert.equal(lit.fx, "on"); assert.equal(lit.rev, "off"); + lit = backlights(state({ mode: "block", block: BLOCK.rev })); + assert.equal(lit.rev, "focus"); assert.equal(lit.home, "on"); + assert.equal(backlights(state({ mode: "edit", block: FX_BLOCKS[0] })).fx, "focus"); + assert.equal(backlights(state({ mode: "tuner", block: BLOCK.rev })).rev, "off"); + }, +}; + +let failed = 0; +for (const [what, run] of Object.entries(tests)) { + try { run(); console.log(`${what.padEnd(76)} ok`); } catch (e) { console.log(`${what.padEnd(76)} FAIL\n${e.message}`); failed++; } +} +console.log(failed ? `\n${failed} FAILED` : "\npedal input: ok"); +process.exit(failed ? 1 : 0); diff --git a/tests/test_pedal_page.mjs b/tests/test_pedal_page.mjs new file mode 100644 index 0000000..0736541 --- /dev/null +++ b/tests/test_pedal_page.mjs @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: GPL-3.0-only +// OpenPocket — the pedal skin (tools/sim/pedal.html) in a real browser (headless Chromium, Playwright) against the hosted +// simulator: its layout, every control by mouse, touch and keyboard, the knobs, the backlights, the live LCD. A screenshot +// of the skin goes to OUTDIR. +// node tests/test_pedal_page.mjs OUTDIR +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { startSim } from "../tools/sim.mjs"; +const require = createRequire(import.meta.url); +const { chromium } = require("playwright"); + +const OUT = process.argv[2] || mkdtempSync(join(tmpdir(), "oppedal-")); +mkdirSync(OUT, { recursive: true }); +const TMP = mkdtempSync(join(tmpdir(), "oppedalsim-")); +let failed = 0; +const ok = (c, what) => { console.log(`${what.padEnd(76)} ${c ? "ok" : "FAIL"}`); if (!c) failed++; }; + +const sim = await startSim({ port: 0, flash: join(TMP, "flash.bin") }); +const state = async () => (await fetch(sim.url + "/api/state")).json(); +const post = (path) => fetch(sim.url + path, { method: "POST" }); +const until = async (test, ms = 1500) => { + const end = Date.now() + ms; + for (;;) { const s = await state(); if (test(s)) return s; if (Date.now() > end) return s; await new Promise((r) => setTimeout(r, 15)); } +}; + +const browser = await chromium.launch(); +const open = async (options) => { + const context = await browser.newContext({ viewport: { width: 1280, height: 960 }, ...options }); + const page = await context.newPage(); + page.on("pageerror", (e) => ok(false, `page error: ${e.message}`)); + await page.goto(sim.url + "/"); + await page.waitForSelector("body[data-frame]"); + return page; +}; +const SCALE = 2, NOTCH = 100 * SCALE; // Playwright divides a wheel's delta by the device scale +const page = await open({ deviceScaleFactor: SCALE }); +const box = (selector) => page.locator(selector).boundingBox(); +const centre = (b) => ({ x: b.x + b.width / 2, y: b.y + b.height / 2 }); + +// ---- 1. the layout, against the photo's proportions ---- +const pedal = await box("#pedal"); +const rel = async (selector) => { const c = centre(await box(selector)); return { x: (c.x - pedal.x) / pedal.width, y: (c.y - pedal.y) / pedal.height }; }; +const near = (a, b) => Math.abs(a - b) < 0.015; +const expected = { "#master": [0.152, 0.228], "#para": [0.843, 0.228], "#lcd": [0.5, 0.301], "[data-control=home]": [0.138, 0.579], "[data-control=cab]": [0.863, 0.579], "[data-control=fs_dn]": [0.167, 0.796], "[data-control=fs_up]": [0.839, 0.796] }; +for (const [selector, [x, y]] of Object.entries(expected)) { const p = await rel(selector); ok(near(p.x, x) && near(p.y, y), `layout: ${selector} at (${p.x.toFixed(3)}, ${p.y.toFixed(3)})`); } +const keys = await page.locator(".key").all(); +const tops = await Promise.all(keys.map(async (k) => (await k.boundingBox()).y)); +ok(keys.length === 7 && new Set(tops).size === 1, "layout: seven buttons in one row"); +const svgText = await page.locator(".body").evaluate((e) => e.querySelectorAll("path").length); +ok(svgText > 5 && await page.locator("img").count() === 0, "layout: the panel is drawn in code, no images"); + +// ---- 2. the live screen ---- +const painted = () => page.evaluate(() => [...document.getElementById("lcd").getContext("2d").getImageData(0, 0, 240, 240).data]); +const same = async () => { + await page.waitForTimeout(250); + const raw = Buffer.from(await (await fetch(sim.url + "/api/screen.rgb565")).arrayBuffer()), px = await painted(); + for (let i = 0; i < 240 * 240; i++) { + const c = raw.readUInt16BE(i * 2); + if (Math.abs(px[i * 4] - ((c >> 11) & 31) * 255 / 31) > 1) return false; + } + return true; +}; +ok(await same(), "LCD: the canvas holds the simulator's frame buffer"); +await page.screenshot({ path: join(OUT, "pedal-skin.png") }); + +// ---- 3. every button, by mouse ---- +const press = async (control) => { + const before = (await state()).presses; + await page.locator(`[data-control=${control}]`).click(); + return until((s) => s.presses > before); +}; +for (const control of ["fs_up", "fs_dn", "fx", "mod", "amp", "cab", "dly", "rev", "home"]) { + const s = await press(control); + ok(s.last_press === control, `mouse: ${control}`); +} +let s = await state(); +ok(s.mode === "chain", "HOME: back on the chain page"); + +// ---- 4. holds, as the firmware times them ---- +const home = await box("[data-control=home]"); +await page.mouse.move(...Object.values(centre(home))); +await page.mouse.down(); +ok(await page.waitForSelector("[data-control=home].held", { timeout: 500 }).then(() => true, () => false), "HOME looks held while it is down"); +s = await until((x) => x.mode === "tuner"); +ok(s.mode === "tuner" && s.held.includes("home"), "HOME held by the mouse: the tuner"); +await page.mouse.up(); +s = await until((x) => !x.held.length); +ok(s.mode === "tuner", "released, the tuner stays"); +await page.locator("[data-control=fs_up]").click(); +s = await until((x) => x.mode === "chain"); +ok(s.mode === "chain", "a footswitch leaves it"); +await post("/api/press/fs_dn"); + +const para = centre(await box("#para")); +await page.mouse.move(para.x, para.y); +await page.mouse.down(); +s = await until((x) => x.mode === "save"); +ok(s.mode === "save", "PARA held: the save page"); +await page.mouse.up(); +await post("/api/press/home"); +await page.mouse.click(para.x, para.y); +s = await until((x) => x.mode === "block"); +ok(s.mode === "block", "PARA clicked: PUSH, the block page after the double-click wait"); +await post("/api/press/home"); +await until((x) => x.mode === "chain"); +await page.mouse.click(para.x, para.y); +await page.mouse.click(para.x, para.y); +s = await until((x) => x.mode === "move"); +ok(s.mode === "move", "PARA clicked twice: MOVE"); +await post("/api/press/home"); + +// ---- 5. both footswitches: two presses, as the firmware does ---- +s = await state(); +await page.evaluate(async () => { await Promise.all([pedal.down("fs_dn"), pedal.down("fs_up")]); }); +await page.waitForTimeout(800); +const both = await state(); +await page.evaluate(async () => { await Promise.all([pedal.up("fs_dn"), pedal.up("fs_up")]); }); +ok(both.mode === "chain" && both.slot === s.slot && both.held.length === 2, "both footswitches held: no tuner in OpenPocket"); + +// ---- 6. the knobs ---- +await post("/api/volume/50"); +await page.waitForSelector('#master[aria-valuenow="50"]'); +const master = await box("#master"), mc = centre(master); +await page.mouse.move(mc.x, mc.y); +await page.mouse.down(); +await page.mouse.move(mc.x, mc.y - master.width / 4, { steps: 8 }); +await page.mouse.up(); +s = await until((x) => x.volume === 60); +ok(s.volume === 60, `MASTER dragged up a quarter of its width: 50 -> ${s.volume}`); +await page.waitForSelector('#master[aria-valuenow="60"]'); +const angle = await page.locator("#master").evaluate((e) => e.style.getPropertyValue("--angle")); +ok(Math.abs(parseFloat(angle) - 27) < 0.01, `MASTER points at ${angle}`); +await page.mouse.move(mc.x, mc.y); +await page.mouse.wheel(0, -NOTCH); +s = await until((x) => x.volume === 62); +ok(s.volume === 62, "MASTER wheel: one notch up, the hysteresis' 2"); +await page.locator("#master").focus(); +await page.keyboard.press("ArrowDown"); +s = await until((x) => x.volume === 60); +ok(s.volume === 60, "MASTER in focus: arrow down"); +await page.keyboard.press("End"); +s = await until((x) => x.volume === 100); +ok(s.volume === 100, "MASTER in focus: End is 100"); +await page.locator("#master").blur(); + +await post("/api/press/amp"); +await post("/api/knob/2"); +await post("/api/press/push"); +s = await until((x) => x.mode === "edit"); +const gain = s.preset.p[5][0], k0 = s.knob, pb = await box("#para"); +await page.mouse.move(para.x, para.y); +await page.mouse.down(); +for (let i = 1; i <= 3; i++) { await page.mouse.move(para.x, para.y - (i + 0.2) * pb.width / 10); await page.waitForTimeout(70); } +await page.mouse.up(); +s = await until((x) => x.knob === k0 + 3); +ok(s.preset.p[5][0] === gain + 3 && s.mode === "edit", `PARA dragged three detents, slowly: GAIN ${gain} -> ${s.preset.p[5][0]}, and no PUSH`); +await page.mouse.wheel(0, NOTCH); +s = await until((x) => x.knob === k0 + 2); +ok(s.preset.p[5][0] === gain + 2, "PARA wheel: one detent back"); +await page.waitForTimeout(60); // closer than 45 ms it would be a fast turn +await page.keyboard.press("ArrowRight"); +s = await until((x) => x.knob === k0 + 3); +ok(s.preset.p[5][0] === gain + 3, "the right arrow: one detent"); +const turned = await page.locator("#para").evaluate((e) => e.style.getPropertyValue("--angle")); +await post("/api/knob/1"); +await until((x) => x.knob === k0 + 4); +await page.waitForFunction((a) => document.getElementById("para").style.getPropertyValue("--angle") !== a, turned); +ok(true, "PARA follows a turn made over HTTP"); +await post("/api/press/home"); + +// ---- 7. the keyboard ---- +const key = async (name, control) => { + const before = (await state()).presses; + await page.keyboard.press(name); + const s = await until((x) => x.presses > before); + ok(s.last_press === control, `key ${name}: ${control}`); +}; +await key("ArrowUp", "fs_up"); +await key("ArrowDown", "fs_dn"); +for (const [k, c] of [["f", "fx"], ["a", "amp"], ["m", "mod"], ["d", "dly"], ["r", "rev"], ["c", "cab"], ["h", "home"]]) await key(k, c); +await key("Escape", "home"); +await page.keyboard.down("Escape"); +s = await until((x) => x.mode === "tuner"); +ok(s.mode === "tuner", "Escape held: the tuner"); +await page.keyboard.up("Escape"); +await key("Enter", "push"); +s = await state(); +await page.keyboard.press("Enter"); +s = await until((x) => x.mode === "block"); +ok(s.mode === "block", "Enter on the chain: PUSH"); +await post("/api/press/home"); +await page.keyboard.down("Shift"); +await page.keyboard.down("H"); +await until((x) => x.held.includes("home")); +await page.keyboard.up("Shift"); +await page.keyboard.up("h"); +await new Promise((r) => setTimeout(r, 900)); +s = await state(); +ok(s.mode !== "tuner", "Shift+H released Shift first: HOME is let go, no stray long press"); +await post("/api/press/home"); +await page.locator("[data-control=dly]").focus(); +await key("Enter", "dly"); +await key(" ", "dly"); +await page.locator("[data-control=dly]").blur(); +await post("/api/press/home"); + +// ---- 8. state lights the buttons ---- +const litBecomes = (name, level) => page.waitForSelector(`[data-control=${name}][data-lit=${level}]`, { timeout: 2000 }).then(() => true, () => false); +ok(await litBecomes("home", "focus"), "HOME is lit brightest on the chain"); +await post("/api/press/dly"); +ok(await litBecomes("dly", "focus") && await litBecomes("home", "on"), "the DLY page: DLY brightest, HOME back to lit"); +const sysex = async (bytes) => fetch(sim.url + "/api/sysex", { method: "POST", body: JSON.stringify([0xF0, 0x7D, 0x4F, 0x50, ...bytes, 0xF7]) }); +await post("/api/press/home"); +await sysex([6, 9, 1, 0, 1]); ok(await litBecomes("dly", "on"), "a block switched on over MIDI: its button lights"); +await sysex([6, 9, 1, 0, 0]); ok(await litBecomes("dly", "off"), "and off: it goes dark"); + +// ---- 9. scripts, and a small touch screen ---- +await page.evaluate(async () => { await pedal.tap("fs_up"); }); +ok((await state()).last_press === "fs_up", "window.pedal.tap presses a button"); +await page.evaluate(() => pedal.master(70)); +ok((await until((x) => x.volume === 70)).volume === 70, "window.pedal.master sets the volume"); +ok(await page.waitForFunction(() => pedal.state().volume === 70, null, { timeout: 1000 }).then(() => true, () => false), "window.pedal.state reads the state"); +ok(await same(), "LCD: still the simulator's frame buffer after all that"); + +const phone = await open({ viewport: { width: 390, height: 780 }, hasTouch: true, isMobile: true }); +const before = await state(); +await phone.tap("[data-control=amp]"); +s = await until((x) => x.presses > before.presses); +ok(s.last_press === "amp", "touch: AMP"); +const wide = await phone.locator("#pedal").boundingBox(); +ok(wide.width <= 390 && wide.width > 300 && Math.abs(wide.height / wide.width - 1.046) < 0.01, `a phone: the pedal fits, ${Math.round(wide.width)} px wide, in proportion`); +await phone.screenshot({ path: join(OUT, "pedal-skin-phone.png") }); +const cdp = await phone.context().newCDPSession(phone); +const touch = (type, points) => cdp.send("Input.dispatchTouchEvent", { type, touchPoints: points }); +await post("/api/press/home"); +const homeKey = centre(await phone.locator("[data-control=home]").boundingBox()); +await touch("touchStart", [homeKey]); +s = await until((x) => x.mode === "tuner"); +ok(s.mode === "tuner", "touch: HOME held, the tuner"); +await touch("touchEnd", []); +await post("/api/press/home"); +await post("/api/volume/50"); +await phone.waitForSelector('#master[aria-valuenow="50"]'); +const knobBox = await phone.locator("#master").boundingBox(), knobAt = centre(knobBox); +await touch("touchStart", [knobAt]); +await touch("touchMove", [{ x: knobAt.x, y: knobAt.y - knobBox.width / 2 }]); +await touch("touchEnd", []); +s = await until((x) => x.volume === 70); +ok(s.volume === 70, "touch: MASTER dragged up half its width, 50 -> 70"); +await touch("touchStart", [centre(await phone.locator("[data-control=fs_dn]").boundingBox()), centre(await phone.locator("[data-control=fs_up]").boundingBox())]); +s = await until((x) => x.held.length === 2); +ok(s.held.length === 2, "touch: both footswitches down at once, two fingers"); +await touch("touchEnd", []); +await until((x) => !x.held.length); + +const plain = await browser.newPage(); +await plain.goto(sim.url + "/plain"); +ok(await plain.locator("#blocks").count() === 1, "/plain: the plain control page"); + +await browser.close(); +await sim.close(); +rmSync(TMP, { recursive: true, force: true }); +console.log(failed ? `\n${failed} FAILED` : `\npedal page: ok (screenshots in ${OUT})`); +process.exit(failed ? 1 : 0); diff --git a/tests/test_sim.mjs b/tests/test_sim.mjs new file mode 100644 index 0000000..6fdb0d9 --- /dev/null +++ b/tests/test_sim.mjs @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: GPL-3.0-only +// OpenPocket — the hosted simulator (tools/sim.mjs, docs/SIMULATOR.md) driven over its HTTP API: footswitches, knobs, +// the screen, the state, SysEx, the flash file that survives a restart, and the virtual Web MIDI shim carrying the real +// editor (docs/pxedit.js) and installer (docs/pxota.js) code. No browser, no hardware; needs a C compiler. +// node tests/test_sim.mjs +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createContext, runInContext } from "node:vm"; +import { fileURLToPath } from "node:url"; +import { startSim } from "../tools/sim.mjs"; +import { Editor } from "../docs/pxedit.js"; +import { Updater } from "../docs/pxota.js"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const TMP = mkdtempSync(join(tmpdir(), "opsim-")); +let failed = 0; +const ok = (c, what) => { console.log(`${what.padEnd(76)} ${c ? "ok" : "FAIL"}`); if (!c) failed++; }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +globalThis.localStorage = { getItem: () => null, setItem: () => {} }; + +const flash = join(TMP, "flash.bin"); +let sim = await startSim({ port: 0, flash }); +const get = async (path) => (await fetch(sim.url + path)).json(); +const post = async (path, body) => fetch(sim.url + path, { method: "POST", body }); +const act = async (path, body) => { const r = await post(path, body); assert.equal(r.status, 200, `${path}: ${await r.clone().text()}`); return r.json(); }; +const state = () => get("/api/state"); +const bad = async (path, status, what) => ok((await post(path)).status === status, what); +const png = async (path = "/api/screen.png") => Buffer.from(await (await fetch(sim.url + path)).arrayBuffer()); + +// ---- 1. the pedal: a port, a state, the real catalogue ---- +let info = await get("/api/info"); +ok(info.kind === "pedal" && info.port === "OpenPocket" && info.controls.includes("fs_up"), `info: ${info.kind} on port "${info.port}"`); +let s = await state(); +ok(s.slot === 0 && s.name === "TWIN CLEAN" && s.mode === "chain" && s.saved === 0 && s.dirty === 0, `state: slot 1 "${s.name}", chain page, factory sound`); +ok(s.preset.p.length === 12 && s.preset.order.length === 12 && s.dsp.load === 42 && s.writes_ok === 1, "state: 12 blocks, the DSP load, flash writes allowed"); + +// ---- 2. footswitches and the screen ---- +const shot0 = await png("/api/screen.rgb565"), f0 = s.frame; +ok(shot0.length === 240 * 240 * 2, "screen.rgb565: 240 x 240 x 2 bytes"); +await act("/api/press/fs_up"); +s = await state(); +const shot1 = await png("/api/screen.rgb565"); +ok(s.slot === 1 && s.name === "CHORUS CLEAN" && s.last_press === "fs_up" && s.presses === 1, `footswitch up: slot 2 "${s.name}"`); +ok(s.frame !== f0 && !shot0.equals(shot1), "the screen was redrawn (frame counter and pixels changed)"); +const p = await png("/api/screen.png?scale=2"); +ok(p.subarray(1, 4).toString() === "PNG" && p.readUInt32BE(16) === 480 && p.readUInt32BE(20) === 480, "screen.png?scale=2: a 480 x 480 PNG"); +await act("/api/press/fs_dn"); +ok((await state()).slot === 0, "footswitch down: back to slot 1"); + +// ---- 3. block buttons, the knob, the pages ---- +await act("/api/press/amp"); +s = await state(); +ok(s.mode === "block" && s.block === 5, "AMP button: the AMP page"); +await act("/api/knob/2"); +await act("/api/press/push"); +s = await state(); +const gain = s.preset.p[5][0]; +ok(s.mode === "edit", "knob + PUSH: editing the AMP's first setting"); +await act("/api/knob/8"); +s = await state(); +ok(s.preset.p[5][0] === Math.min(100, gain + 8) && s.dirty === 1, `knob x8: GAIN ${gain} -> ${s.preset.p[5][0]}, the sound is edited`); +await act("/api/knob/-3?fast"); +ok((await state()).preset.p[5][0] === Math.max(0, s.preset.p[5][0] - 15), "knob counterclockwise, fast: steps of 5"); +await act("/api/press/home"); +ok((await state()).mode === "chain", "HOME: back to the chain"); +await act("/api/press/dpush"); +ok((await state()).mode === "move", "double click PUSH: MOVE"); +await act("/api/press/home"); +await act("/api/press/home_long"); +await act("/api/tuner/110"); +await sleep(50); +s = await state(); +ok(s.mode === "tuner" && s.tuner_hz === 110, "hold HOME: the tuner, hearing 110 Hz"); +await act("/api/press/home"); +await act("/api/volume/55"); +await act("/api/battery/3640/0"); +s = await state(); +ok(s.mode === "chain" && s.volume === 55 && s.battery_mv === 3640, "any control leaves the tuner; volume and battery are set"); + +// ---- 3b. buttons held down, timed as the firmware times them ---- +const held = async () => (await state()).held; +await act("/api/down/fs_up"); +s = await state(); +ok(s.slot === 1 && s.held.join() === "fs_up", "a footswitch acts when it goes down, and is reported held"); +await act("/api/up/fs_up"); +ok((await state()).slot === 1 && (await held()).length === 0, "its release does nothing more"); +await act("/api/down/fs_dn"); await act("/api/down/fs_up"); +s = await state(); +ok(s.slot === 1 && s.mode === "chain" && s.held.length === 2, "both footswitches: two presses, no tuner (as openpocket.c)"); +await act("/api/up/fs_dn"); await act("/api/up/fs_up"); +await act("/api/press/amp"); +await act("/api/down/home"); +ok((await state()).mode === "block", "HOME does nothing yet while it is down"); +await act("/api/up/home"); +ok((await state()).mode === "chain", "HOME acts at a short release"); +await act("/api/down/home"); +await sleep(760); +ok((await state()).mode === "tuner", "HOME held 0.7 s: the tuner, before the release"); +await act("/api/up/home"); +s = await state(); +ok(s.mode === "tuner" && s.held.length === 0, "the release of that hold leaves the tuner open"); +await act("/api/down/home"); +await sleep(760); +await act("/api/up/home"); +ok((await state()).mode === "tuner", "HOME held again does nothing on the tuner (as openpocket.c)"); +await act("/api/down/fx"); +s = await state(); +ok(s.mode === "chain", "any button leaves the tuner"); +await act("/api/up/fx"); +await act("/api/down/push"); +await sleep(860); +ok((await state()).mode === "save", "PUSH held 0.8 s: the save page"); +await act("/api/up/push"); +ok((await state()).mode === "save", "the release of that hold stays on the save page"); +await act("/api/press/home"); +const click = async () => { await act("/api/down/push"); await act("/api/up/push"); }; +await click(); +ok((await state()).mode === "chain", "one click of PUSH on the chain waits for a second one"); +await sleep(400); +ok((await state()).mode === "block", "after 350 ms it was a click: the block page"); +await act("/api/press/home"); +await click(); await click(); +await sleep(400); +ok((await state()).mode === "move", "two clicks within 350 ms: MOVE"); +await act("/api/press/home"); +const k0 = (await state()).knob; +await act("/api/knob/3"); +ok((await state()).knob === k0 + 3, "the state counts the knob's detents"); +await bad("/api/down/push_long", 400, "only a button goes down: a gesture is refused"); +await bad("/api/up/bogus", 400, "an unknown button is refused"); +await act("/api/up/fs_up"); +ok(true, "the release of a button that is not down is harmless"); +await act("/api/press/fs_dn"); + +// ---- 4. save: the flash file, then a new session on it ---- +await act("/api/press/push_long"); +ok((await state()).mode === "save", "hold PUSH: the save page"); +await act("/api/knob/4"); +await act("/api/press/push"); +s = await state(); +ok(s.mode === "chain" && s.slot === 4 && s.saved === 1 && s.dirty === 0, "PUSH: saved into slot 5, now the current slot"); +await sleep(100); +const dumped = Buffer.from(await (await fetch(sim.url + "/api/flash")).arrayBuffer()); +ok(dumped.length === 0x100000 && existsSync(flash) && readFileSync(flash).equals(dumped), "the flash file holds the saved preset (GET /api/flash is the same)"); +const saved = s.preset; +await sim.close(); +sim = await startSim({ port: 0, flash }); +s = await state(); +ok(s.slot === 4 && s.saved === 1 && JSON.stringify(s.preset) === JSON.stringify(saved), "a restart on the same flash file: the session is as it was"); +await act("/api/reset"); +s = await state(); +ok(s.slot === 0 && s.saved === 0 && !existsSync(flash), "reset: a blank flash, the factory presets, the file gone"); + +// ---- 5. bad requests ---- +await bad("/api/press/bogus", 400, "an unknown control is refused"); +await bad("/api/volume/500", 400, "a volume out of range is refused"); +await bad("/api/knob/x", 400, "a knob step that is not a number is refused"); +ok((await fetch(sim.url + "/api/press/fs_up")).status === 405, "a control needs POST"); +ok((await fetch(sim.url + "/site/../tools/sim.mjs")).status === 404 && (await fetch(sim.url + "/site/%2e%2e/Makefile")).status === 404, "/site/ serves docs/ only"); + +const served = async (path, type) => { const r = await fetch(sim.url + path); return r.status === 200 && r.headers.get("content-type") === type; }; +ok(await served("/", "text/html; charset=utf-8") && await served("/plain", "text/html; charset=utf-8") && await served("/sim/pedal-input.js", "text/javascript; charset=utf-8"), "/ is the pedal, /plain the plain control page, and the input module is served"); + +// ---- 6. SysEx over HTTP (docs/EDITOR-PROTOCOL.md) ---- +let r = await act("/api/sysex", "f0 7d 4f 50 01 f7"); +const b = Buffer.from(r.reply, "hex"); +ok(b[4] === 1 && b.toString("latin1").includes("v36") && b.toString("latin1").includes("PocketX_936"), "sysex INFO: the editor protocol answers (v36, PocketX_936)"); +r = await act("/api/sysex?timeout=100", "f0 7d 4f 50 7e f7"); +ok(r.reply === null, "sysex: a request the pedal does not understand gets no reply"); +r = await act("/api/sysex", JSON.stringify([0xF0, 0x7D, 0x4F, 0x50, 0x06, 5, 0, 0, 91, 0xF7])); // SET AMP parameter 0 = 91 +s = await state(); +ok(s.preset.p[5][0] === 91 && s.dirty === 1, "sysex SET (a JSON array body): AMP's first setting = 91, seen in /api/state"); + +// ---- 7. the virtual Web MIDI: the shim, then the real editor code on it ---- +async function openShim(url) { + const sandbox = { + document: { currentScript: { src: `${url}/sim/webmidi-shim.js` } }, navigator: {}, fetch, console, Uint8Array, DOMException, URL, + EventSource: class { // the browser's EventSource, over fetch (Node has none before 22) + constructor(u) { + this.l = new Map(); this.ctl = new AbortController(); + fetch(u, { signal: this.ctl.signal }).then(async (res) => { + let buf = ""; + for await (const c of res.body) { + buf += Buffer.from(c).toString("utf8"); + for (let i; (i = buf.indexOf("\n\n")) >= 0;) { + const blk = buf.slice(0, i); buf = buf.slice(i + 2); + const ev = /^event: (.*)$/m.exec(blk), data = /^data: (.*)$/m.exec(blk); + if (ev) for (const f of this.l.get(ev[1]) || []) f({ data: data ? data[1] : "" }); + } + } + }).catch(() => { }); + } + addEventListener(n, f, o) { const fn = o && o.once ? (e) => { this.l.get(n).delete(fn); f(e); } : f; (this.l.get(n) || this.l.set(n, new Set()).get(n)).add(fn); } + close() { this.ctl.abort(); } + }, + }; + createContext(sandbox); + runInContext(readFileSync(join(ROOT, "tools/sim/webmidi-shim.js"), "utf8"), sandbox); + return sandbox; +} +let shim = await openShim(sim.url); +let access = await shim.navigator.requestMIDIAccess({ sysex: true }); +ok(access.inputs.size === 1 && access.outputs.size === 1 && [...access.inputs.values()][0].name === "OpenPocket", "shim: requestMIDIAccess gives one input and one output, \"OpenPocket\""); +const ed = new Editor(access); +const inf = await ed.connect(); +ok(inf && inf.version === "v36" && inf.identity === "PocketX_936", "editor (pxedit.js) over the shim: INFO"); +const cat = await ed.catalog(); +ok(cat.blocks.length === 12 && cat.blocks[5].label === "AMP", "editor: the catalogue"); +await ed.setParam(5, 1, 33); +s = await state(); +ok(s.preset.p[5][1] === 33, "editor: SET reaches the simulated pedal (seen in /api/state)"); +await act("/api/press/fs_up"); +const st = await ed.state(); +ok(st.slot === s.slot + 1, "editor: STATE follows a footswitch pressed over HTTP"); +ed.close(); + +// ---- 8. the update simulator: the installer's identify over the shim ---- +execFileSync("python3", [join(ROOT, "tests", "fixtures.py"), TMP], { stdio: "ignore" }); +await sim.close(); +sim = await startSim({ port: 0, update: join(TMP, "flash_old.bin") }); +info = await get("/api/info"); +ok(info.kind === "update" && info.port === "OpenPocket" && info.log.some((l) => /PocketX_923/.test(l)), "--update: devsim runs the update code on the flash image (OpenPocket 923)"); +ok((await fetch(sim.url + "/api/state")).status === 409, "--update: no screen or controls"); +shim = await openShim(sim.url); +access = await shim.navigator.requestMIDIAccess({ sysex: true }); +const up = new Updater(access, () => { }); +const dev = await up.identify(); +ok(dev && dev.id && dev.id.text === "PocketX_923", `installer (pxota.js) over the shim: found ${dev && dev.id && dev.id.text}`); +await sim.close(); + +rmSync(TMP, { recursive: true, force: true }); +console.log(failed ? `\n${failed} FAILED` : "\nsimulator: ok"); +process.exit(failed ? 1 : 0); diff --git a/tests/uisim.c b/tests/uisim.c index e2675bf..804e453 100644 --- a/tests/uisim.c +++ b/tests/uisim.c @@ -12,29 +12,9 @@ #include #include "px_dsp.h" -enum { RED = 0xF800, GRN = 0x07E0, BLU = 0x001F, WHT = 0xFFFF, BLK = 0x0000, CYN = 0x07FF, YEL = 0xFFE0, ORG = 0xFD20 }; -enum { UI_BG = 0x0841, UI_PANEL = 0x18E3, UI_SEL = 0xFB00, UI_ACC = 0xFD20, UI_TXT = 0xEF7D, UI_DIM = 0x8410, UI_OK = 0x4E6B, UI_OFF = 0x2124 }; +#include "simlcd.h" #define POISON 0xF81F -static uint16_t fb[240 * 240]; -static uint32_t wx, wy, ww, wh, wi; -static unsigned long px_sent; -static void lcd_win(uint32_t x, uint32_t y, uint32_t w, uint32_t h) { wx = x; wy = y; ww = w; wh = h; wi = 0; } -static void lcd_px(uint16_t c) -{ - uint32_t x = wx + wi % ww, y = wy + wi / ww; - if (x < 240u && y < 240u) fb[y * 240u + x] = c; - wi++; px_sent++; -} -static void lcd_end(void) { } -static void lcd_rect(uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint16_t c) -{ - if (w == 0u || h == 0u || x >= 240u || y >= 240u) return; - if (x + w > 240u) w = 240u - x; - if (y + h > 240u) h = 240u - y; - for (uint32_t j = 0; j < h; j++) for (uint32_t i = 0; i < w; i++) fb[(y + j) * 240u + x + i] = c; - px_sent += w * h; -} static void fm1_wdt_feed(void) { } static void fm1_delay_ms(int ms) { (void)ms; } diff --git a/third_party/jieli-ble/LICENSE-JieLi-Apache-2.0 b/third_party/jieli-ble/LICENSE-JieLi-Apache-2.0 new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/third_party/jieli-ble/LICENSE-JieLi-Apache-2.0 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/jieli-ble/MEMBERS b/third_party/jieli-ble/MEMBERS new file mode 100644 index 0000000..df16c62 --- /dev/null +++ b/third_party/jieli-ble/MEMBERS @@ -0,0 +1,37 @@ +# archivemember -> file in this directory (read by extract.sh) +btctrler.a RF.c.o btctrler/RF.c.bc +btctrler.a RF_ble.c.o btctrler/RF_ble.c.bc +btctrler.a aes128.c.o btctrler/aes128.c.bc +btctrler.a analog.c.o btctrler/analog.c.bc +btctrler.a analog_ble.c.o btctrler/analog_ble.c.bc +btctrler.a btagc.c.o btctrler/btagc.c.bc +btctrler.a btcontroller_heap.c.o btctrler/btcontroller_heap.c.bc +btctrler.a btcontroller_util.c.o btctrler/btcontroller_util.c.bc +btctrler.a btctrler_task.c.o btctrler/btctrler_task.c.bc +btctrler.a device_manager.c.o btctrler/device_manager.c.bc +btctrler.a h4_controller_interface.c.o btctrler/h4_controller_interface.c.bc +btctrler.a hci_controller.c.o btctrler/hci_controller.c.bc +btctrler.a hci_ll.c.o btctrler/hci_ll.c.bc +btctrler.a hci_ll_cmds.c.o btctrler/hci_ll_cmds.c.bc +btctrler.a hw_dma.c.o btctrler/hw_dma.c.bc +btctrler.a lbuf.c.o btctrler/lbuf.c.bc +btctrler.a le_low_power.c.o btctrler/le_low_power.c.bc +btctrler.a link_layer.c.o btctrler/link_layer.c.bc +btctrler.a link_task.c.o btctrler/link_task.c.bc +btctrler.a ll_adv.c.o btctrler/ll_adv.c.bc +btctrler.a ll_afh.c.o btctrler/ll_afh.c.bc +btctrler.a ll_events.c.o btctrler/ll_events.c.bc +btctrler.a ll_init.c.o btctrler/ll_init.c.bc +btctrler.a ll_keys.c.o btctrler/ll_keys.c.bc +btctrler.a ll_resolve_list.c.o btctrler/ll_resolve_list.c.bc +btctrler.a ll_scan.c.o btctrler/ll_scan.c.bc +btctrler.a ll_slave.c.o btctrler/ll_slave.c.bc +btctrler.a ll_vendor.c.o btctrler/ll_vendor.c.bc +btctrler.a ll_white_list.c.o btctrler/ll_white_list.c.bc +btctrler.a misc_hw.c.o btctrler/misc_hw.c.bc +btctrler.a sys_timer.c.o btctrler/sys_timer.c.bc +btctrler.a thread.c.o btctrler/thread.c.bc +btctrler.a vsprintf.c.o btctrler/vsprintf.c.bc +cpu.a delay.c.o cpu/delay.c.bc +cpu.a crc16.c.o cpu/crc16.c.bc +cpu.a wlc.c.o cpu/wlc.c.bc diff --git a/third_party/jieli-ble/README.md b/third_party/jieli-ble/README.md new file mode 100644 index 0000000..095ba28 --- /dev/null +++ b/third_party/jieli-ble/README.md @@ -0,0 +1,36 @@ +# third_party/jieli-ble: JieLi's Bluetooth controller (Apache-2.0) + +This directory holds JieLi's own Bluetooth LE controller code, **as published, unmodified**, for the advertise-only +Bluetooth test image (`make ble`, [docs/BLE-TEST.md](../../docs/BLE-TEST.md)). Nothing here is part of the normal +OpenPocket firmware or of the USB MIDI image; only the `ble` build target links it. + +| | | +| --- | --- | +| Source | JieLi `fw-AC79_AIoT_SDK`, branch `release/AC79NN_SDK_V1.2.0`, commit `0617f4cc09685332aaf1104893718291c54ee5bb` | +| URL | https://gitee.com/Jieli-Tech/fw-AC79_AIoT_SDK (files under `cpu/wl82/liba/`) | +| Licence | Apache-2.0, the SDK's `LICENSE`, kept here as `LICENSE-JieLi-Apache-2.0` (identical to `firmware/sdk/LICENSE-JieLi-Apache-2.0`) | +| Copyright | Zhuhai Jieli Technology Co., Ltd. | +| Ruling | The project owner ruled that linking the SDK's Apache-2.0 binary libraries is acceptable (the same reading as for `firmware/sdk/iis.bc`, `docs/AUDIO-SDK.md`). The libraries are shipped as LLVM bitcode, not as source. | + +## What is here + +Individual objects (LLVM bitcode, clang 4.0.1, the same compiler as our toolchain), taken as they are from the +archives; `build.sh` compiles each with `clang -target pi32v2 -x ir`, as it does for `firmware/sdk/iis.bc`. + +| Directory | From | Objects | +| --- | --- | --- | +| `btctrler/` | `btctrler.a` (sha256 `d98f2ad9d483cd2c103cab56c2760162edf92cc451a04e5668af02a590c95cb3`) | the LE controller: HCI front end, link layer, advertising, slave link, the LE and BT radio drivers, interrupt and DMA helpers, their utilities (33 of the archive's 106 bitcode members) | +| `cpu/` | `cpu.a` (sha256 `3d3e0876e21afe2c9cb7de713378d3802b1d0f544a1787f07e982269a0e7e67d`) | `delay`, `crc16`, `wlc` (small helpers the controller calls) | + +Not used: the classic Bluetooth, TWS, LE 5 extended/ISO members of `btctrler.a`, and all of `btstack.a` (the host +stack: this image sends its advertisement through the controller's own HCI-level functions). + +`extract.sh` rebuilds this directory from an SDK checkout and checks the archive hashes above: +`sh third_party/jieli-ble/extract.sh /path/to/fw-AC79_AIoT_SDK`. + +## What OpenPocket supplies + +`firmware/ble/` is OpenPocket's own code that these objects need: a small cooperative scheduler with the operating +system calls they make (tasks, queues, semaphores, timers, heap, interrupt registration), the configuration +(`ble_cfg.c`, derived from the SDK's `apps/common/config/log_config/lib_btctrler_config.c`, Apache-2.0), empty stand-ins +for the parts that are not built (`ble_stubs.c`), and the radio start-up replay (`ble_rf.c`). diff --git a/third_party/jieli-ble/btctrler/RF.c.bc b/third_party/jieli-ble/btctrler/RF.c.bc new file mode 100644 index 0000000..ef79d11 Binary files /dev/null and b/third_party/jieli-ble/btctrler/RF.c.bc differ diff --git a/third_party/jieli-ble/btctrler/RF_ble.c.bc b/third_party/jieli-ble/btctrler/RF_ble.c.bc new file mode 100644 index 0000000..a3dd046 Binary files /dev/null and b/third_party/jieli-ble/btctrler/RF_ble.c.bc differ diff --git a/third_party/jieli-ble/btctrler/aes128.c.bc b/third_party/jieli-ble/btctrler/aes128.c.bc new file mode 100644 index 0000000..fcf5462 Binary files /dev/null and b/third_party/jieli-ble/btctrler/aes128.c.bc differ diff --git a/third_party/jieli-ble/btctrler/analog.c.bc b/third_party/jieli-ble/btctrler/analog.c.bc new file mode 100644 index 0000000..5a767ac Binary files /dev/null and b/third_party/jieli-ble/btctrler/analog.c.bc differ diff --git a/third_party/jieli-ble/btctrler/analog_ble.c.bc b/third_party/jieli-ble/btctrler/analog_ble.c.bc new file mode 100644 index 0000000..7a91870 Binary files /dev/null and b/third_party/jieli-ble/btctrler/analog_ble.c.bc differ diff --git a/third_party/jieli-ble/btctrler/btagc.c.bc b/third_party/jieli-ble/btctrler/btagc.c.bc new file mode 100644 index 0000000..2a20f04 Binary files /dev/null and b/third_party/jieli-ble/btctrler/btagc.c.bc differ diff --git a/third_party/jieli-ble/btctrler/btcontroller_heap.c.bc b/third_party/jieli-ble/btctrler/btcontroller_heap.c.bc new file mode 100644 index 0000000..c6897a5 Binary files /dev/null and b/third_party/jieli-ble/btctrler/btcontroller_heap.c.bc differ diff --git a/third_party/jieli-ble/btctrler/btcontroller_util.c.bc b/third_party/jieli-ble/btctrler/btcontroller_util.c.bc new file mode 100644 index 0000000..756cfaf Binary files /dev/null and b/third_party/jieli-ble/btctrler/btcontroller_util.c.bc differ diff --git a/third_party/jieli-ble/btctrler/btctrler_task.c.bc b/third_party/jieli-ble/btctrler/btctrler_task.c.bc new file mode 100644 index 0000000..302b132 Binary files /dev/null and b/third_party/jieli-ble/btctrler/btctrler_task.c.bc differ diff --git a/third_party/jieli-ble/btctrler/device_manager.c.bc b/third_party/jieli-ble/btctrler/device_manager.c.bc new file mode 100644 index 0000000..94ddd6b Binary files /dev/null and b/third_party/jieli-ble/btctrler/device_manager.c.bc differ diff --git a/third_party/jieli-ble/btctrler/h4_controller_interface.c.bc b/third_party/jieli-ble/btctrler/h4_controller_interface.c.bc new file mode 100644 index 0000000..1575fe1 Binary files /dev/null and b/third_party/jieli-ble/btctrler/h4_controller_interface.c.bc differ diff --git a/third_party/jieli-ble/btctrler/hci_controller.c.bc b/third_party/jieli-ble/btctrler/hci_controller.c.bc new file mode 100644 index 0000000..1426709 Binary files /dev/null and b/third_party/jieli-ble/btctrler/hci_controller.c.bc differ diff --git a/third_party/jieli-ble/btctrler/hci_ll.c.bc b/third_party/jieli-ble/btctrler/hci_ll.c.bc new file mode 100644 index 0000000..e0024e9 Binary files /dev/null and b/third_party/jieli-ble/btctrler/hci_ll.c.bc differ diff --git a/third_party/jieli-ble/btctrler/hci_ll_cmds.c.bc b/third_party/jieli-ble/btctrler/hci_ll_cmds.c.bc new file mode 100644 index 0000000..5a4c18f Binary files /dev/null and b/third_party/jieli-ble/btctrler/hci_ll_cmds.c.bc differ diff --git a/third_party/jieli-ble/btctrler/hw_dma.c.bc b/third_party/jieli-ble/btctrler/hw_dma.c.bc new file mode 100644 index 0000000..1b2250d Binary files /dev/null and b/third_party/jieli-ble/btctrler/hw_dma.c.bc differ diff --git a/third_party/jieli-ble/btctrler/lbuf.c.bc b/third_party/jieli-ble/btctrler/lbuf.c.bc new file mode 100644 index 0000000..bcde8e2 Binary files /dev/null and b/third_party/jieli-ble/btctrler/lbuf.c.bc differ diff --git a/third_party/jieli-ble/btctrler/le_low_power.c.bc b/third_party/jieli-ble/btctrler/le_low_power.c.bc new file mode 100644 index 0000000..175e219 Binary files /dev/null and b/third_party/jieli-ble/btctrler/le_low_power.c.bc differ diff --git a/third_party/jieli-ble/btctrler/link_layer.c.bc b/third_party/jieli-ble/btctrler/link_layer.c.bc new file mode 100644 index 0000000..e163b4e Binary files /dev/null and b/third_party/jieli-ble/btctrler/link_layer.c.bc differ diff --git a/third_party/jieli-ble/btctrler/link_task.c.bc b/third_party/jieli-ble/btctrler/link_task.c.bc new file mode 100644 index 0000000..cea85e2 Binary files /dev/null and b/third_party/jieli-ble/btctrler/link_task.c.bc differ diff --git a/third_party/jieli-ble/btctrler/ll_adv.c.bc b/third_party/jieli-ble/btctrler/ll_adv.c.bc new file mode 100644 index 0000000..f08a552 Binary files /dev/null and b/third_party/jieli-ble/btctrler/ll_adv.c.bc differ diff --git a/third_party/jieli-ble/btctrler/ll_afh.c.bc b/third_party/jieli-ble/btctrler/ll_afh.c.bc new file mode 100644 index 0000000..70a2054 Binary files /dev/null and b/third_party/jieli-ble/btctrler/ll_afh.c.bc differ diff --git a/third_party/jieli-ble/btctrler/ll_events.c.bc b/third_party/jieli-ble/btctrler/ll_events.c.bc new file mode 100644 index 0000000..87e004d Binary files /dev/null and b/third_party/jieli-ble/btctrler/ll_events.c.bc differ diff --git a/third_party/jieli-ble/btctrler/ll_init.c.bc b/third_party/jieli-ble/btctrler/ll_init.c.bc new file mode 100644 index 0000000..374989c Binary files /dev/null and b/third_party/jieli-ble/btctrler/ll_init.c.bc differ diff --git a/third_party/jieli-ble/btctrler/ll_keys.c.bc b/third_party/jieli-ble/btctrler/ll_keys.c.bc new file mode 100644 index 0000000..263d056 Binary files /dev/null and b/third_party/jieli-ble/btctrler/ll_keys.c.bc differ diff --git a/third_party/jieli-ble/btctrler/ll_resolve_list.c.bc b/third_party/jieli-ble/btctrler/ll_resolve_list.c.bc new file mode 100644 index 0000000..86dc1e0 Binary files /dev/null and b/third_party/jieli-ble/btctrler/ll_resolve_list.c.bc differ diff --git a/third_party/jieli-ble/btctrler/ll_scan.c.bc b/third_party/jieli-ble/btctrler/ll_scan.c.bc new file mode 100644 index 0000000..0bc64c3 Binary files /dev/null and b/third_party/jieli-ble/btctrler/ll_scan.c.bc differ diff --git a/third_party/jieli-ble/btctrler/ll_slave.c.bc b/third_party/jieli-ble/btctrler/ll_slave.c.bc new file mode 100644 index 0000000..7d0adae Binary files /dev/null and b/third_party/jieli-ble/btctrler/ll_slave.c.bc differ diff --git a/third_party/jieli-ble/btctrler/ll_vendor.c.bc b/third_party/jieli-ble/btctrler/ll_vendor.c.bc new file mode 100644 index 0000000..2f05b19 Binary files /dev/null and b/third_party/jieli-ble/btctrler/ll_vendor.c.bc differ diff --git a/third_party/jieli-ble/btctrler/ll_white_list.c.bc b/third_party/jieli-ble/btctrler/ll_white_list.c.bc new file mode 100644 index 0000000..8b90f41 Binary files /dev/null and b/third_party/jieli-ble/btctrler/ll_white_list.c.bc differ diff --git a/third_party/jieli-ble/btctrler/misc_hw.c.bc b/third_party/jieli-ble/btctrler/misc_hw.c.bc new file mode 100644 index 0000000..e7d2fb7 Binary files /dev/null and b/third_party/jieli-ble/btctrler/misc_hw.c.bc differ diff --git a/third_party/jieli-ble/btctrler/sys_timer.c.bc b/third_party/jieli-ble/btctrler/sys_timer.c.bc new file mode 100644 index 0000000..e47d054 Binary files /dev/null and b/third_party/jieli-ble/btctrler/sys_timer.c.bc differ diff --git a/third_party/jieli-ble/btctrler/thread.c.bc b/third_party/jieli-ble/btctrler/thread.c.bc new file mode 100644 index 0000000..d0c271e Binary files /dev/null and b/third_party/jieli-ble/btctrler/thread.c.bc differ diff --git a/third_party/jieli-ble/btctrler/vsprintf.c.bc b/third_party/jieli-ble/btctrler/vsprintf.c.bc new file mode 100644 index 0000000..0f3efcd Binary files /dev/null and b/third_party/jieli-ble/btctrler/vsprintf.c.bc differ diff --git a/third_party/jieli-ble/cpu/crc16.c.bc b/third_party/jieli-ble/cpu/crc16.c.bc new file mode 100644 index 0000000..504e9d0 Binary files /dev/null and b/third_party/jieli-ble/cpu/crc16.c.bc differ diff --git a/third_party/jieli-ble/cpu/delay.c.bc b/third_party/jieli-ble/cpu/delay.c.bc new file mode 100644 index 0000000..69f2742 Binary files /dev/null and b/third_party/jieli-ble/cpu/delay.c.bc differ diff --git a/third_party/jieli-ble/cpu/wlc.c.bc b/third_party/jieli-ble/cpu/wlc.c.bc new file mode 100644 index 0000000..cf0b3a7 Binary files /dev/null and b/third_party/jieli-ble/cpu/wlc.c.bc differ diff --git a/third_party/jieli-ble/extract.sh b/third_party/jieli-ble/extract.sh new file mode 100755 index 0000000..5d65106 --- /dev/null +++ b/third_party/jieli-ble/extract.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Rebuild third_party/jieli-ble/ from a checkout of JieLi's fw-AC79_AIoT_SDK (release/AC79NN_SDK_V1.2.0, commit +# 0617f4cc09685332aaf1104893718291c54ee5bb): sh third_party/jieli-ble/extract.sh /path/to/fw-AC79_AIoT_SDK +# The archives are checked against the hashes in README.md first; the objects are copied out unchanged. +set -e +SDK="${1:?usage: extract.sh /path/to/fw-AC79_AIoT_SDK}" +HERE="$(cd "$(dirname "$0")" && pwd)" +LIB="$SDK/cpu/wl82/liba" +sum() { sha256sum "$1" 2>/dev/null | cut -d' ' -f1 || shasum -a 256 "$1" | cut -d' ' -f1; } +[ "$(sum "$LIB/btctrler.a")" = d98f2ad9d483cd2c103cab56c2760162edf92cc451a04e5668af02a590c95cb3 ] || { echo "btctrler.a is not the pinned file"; exit 1; } +[ "$(sum "$LIB/cpu.a")" = 3d3e0876e21afe2c9cb7de713378d3802b1d0f544a1787f07e982269a0e7e67d ] || { echo "cpu.a is not the pinned file"; exit 1; } +grep -v '^#' "$HERE/MEMBERS" | while IFS="$(printf '\t')" read -r arch member dest; do + mkdir -p "$HERE/$(dirname "$dest")" + ar p "$LIB/$arch" "$member" > "$HERE/$dest" +done +cp "$SDK/LICENSE" "$HERE/LICENSE-JieLi-Apache-2.0" +echo "extracted $(grep -vc '^#' "$HERE/MEMBERS") objects" diff --git a/tools/ble-scan.swift b/tools/ble-scan.swift new file mode 100644 index 0000000..307432f --- /dev/null +++ b/tools/ble-scan.swift @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-only +// The Mac side of `node tools/op.mjs log --scan`: listens for the Bluetooth test image's advertisement and never connects. +// ble-scan SECONDS [SERVICE-UUID] +// One JSON object per line: every sighting of the target, then {"done":...} with the adapter state and the other devices seen. +import CoreBluetooth +import Foundation + +let seconds = Double(CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "20") ?? 20 +let filter = CommandLine.arguments.count > 2 ? [CBUUID(string: CommandLine.arguments[2])] : nil +let midi = "03B80E5A-EDE8-4B33-A751-6CE34EC4C700" + +func emit(_ o: [String: Any]) { + print(String(data: try! JSONSerialization.data(withJSONObject: o), encoding: .utf8)!) +} + +final class Scanner: NSObject, CBCentralManagerDelegate { + var central: CBCentralManager! + var others = Set() + func finish() { + emit(["done": true, "auth": CBCentralManager.authorization.rawValue, "state": central.state.rawValue, "others": others.count]) + exit(0) + } + func centralManagerDidUpdateState(_ c: CBCentralManager) { + switch c.state { + case .poweredOn: c.scanForPeripherals(withServices: filter, options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]) + case .unknown, .resetting: break + default: finish() // off, unauthorized or unsupported: the scan cannot run + } + } + func centralManager(_ c: CBCentralManager, didDiscover p: CBPeripheral, advertisementData ad: [String: Any], rssi: NSNumber) { + let name = (ad[CBAdvertisementDataLocalNameKey] as? String) ?? p.name ?? "" + let services = (ad[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])?.map { $0.uuidString } ?? [] + if name.contains("OpenPocket") || services.contains(midi) { + emit(["id": p.identifier.uuidString, "name": name, "rssi": rssi.intValue, "services": services, + "connectable": (ad[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? false]) + } else { others.insert(p.identifier) } + } +} + +let scanner = Scanner() +scanner.central = CBCentralManager(delegate: scanner, queue: nil) +DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { scanner.finish() } +RunLoop.main.run() diff --git a/tools/flash.mjs b/tools/flash.mjs new file mode 100644 index 0000000..7de9eed --- /dev/null +++ b/tools/flash.mjs @@ -0,0 +1,203 @@ +// 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] [--rescue [--timeout SECONDS]] [--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, OUR_LOADER, MODELS, TIMING } 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] + node tools/op.mjs flash --rescue --confirm-flash [--timeout SECONDS] [--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) + --rescue for a pedal that restarts in a loop: wait for it to answer (plug it in, or let it restart), stop its + sound processing the moment it does, and install. Only a pedal whose firmware answers the MIDI + identity query for a moment is caught; the chip's own boot loader (WL80UBOOT) is not reachable. + --timeout SECONDS how long --rescue waits for the pedal (default 180); an install that has started is never cut short + --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).`; + +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, rescue: false, timeout: 180, 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 === "--rescue") a.rescue = true; + else if (v === "--timeout") { + const n = Number(argv[++i]); + if (!(n > 0)) throw refuse("--timeout needs a number of seconds above 0"); + a.timeout = n; + } 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 scan(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); + } + return { found, seen: new Updater(access, null, timing).seen() }; +} + +async function detect(access, timing) { + const { found, seen } = await scan(access, timing); + 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", + timeout: "the pedal never answered", pkg: "no package for this pedal", + badreq: "the pedal asked for data outside the package", foreign: "the pedal is in another firmware's update mode", +}; + +const describe = (p) => `${basename(p.path)}: ${p.product}, SHA-256 ${p.sha256.slice(0, 16)}… matches the manifest`; + +// --rescue: the pedal restarts in a loop, so it is waited for (the installer page's rescue mode, Updater.rescueWithin): +// the moment it answers the identity query its sound processing is stopped and the package of its model is installed. +// The states reached are the page's: the running firmware's MIDI port, and an OpenPocket update loader left half-done. +async function rescue(args, given, access, t, say) { + const pkgs = {}; + if (given) pkgs[given.model] = given; + else for (const m of MODELS) { try { pkgs[m] = await loadPackage(args.manifest, { model: m }); } catch (_) { /* no package for this model */ } } + if (!Object.keys(pkgs).length) throw refuse(`${args.manifest} lists no package for ${MODELS.join(" or ")}`); + if (!given) for (const p of Object.values(pkgs)) say(`package ${describe(p)}`); + const secs = (ms) => Math.ceil(ms / 1000); + say(`rescue: waiting up to ${args.timeout} s for the pedal to answer; plug it in over USB or let it restart. Ctrl-C stops.`); + const end = Date.now() + args.timeout * 1000; + + if (args.dryRun) { // read-only: wait until one pedal answers, then check the model + for (;;) { + const { found } = await scan(access, t); + if (found.length > 1) throw refuse(`${found.length} pedals answered. Connect only one.`); + if (found.length) { + const dev = found[0]; + say(`pedal answered: ${dev.id.text} on "${dev.name}"`); + const want = pkgs[dev.id.model]; + if (IS_OTA(dev.id)) { + if (!given) throw refuse(`the pedal is in update mode (${dev.id.text}), which does not tell its model: add --package FILE to finish the update. Nothing was sent.`); + if (!OUR_LOADER(dev.id)) throw refuse(`the pedal is in the update mode of another firmware (${dev.id.text}); a rescue only finishes an OpenPocket update. Nothing was sent.`); + say(`it is in update mode (${dev.id.text}); a real rescue would finish the update with ${given.product}.`); + } else if (!want) throw refuse(`wrong model: the pedal is a ${dev.id.text}, no package given for it. Nothing was sent.`); + say(`dry run ok: a rescue would install ${want ? want.product : given.product} on ${dev.id.text}. Nothing was written.`); + return 0; + } + if (Date.now() >= end) throw Object.assign(new Error(`no pedal answered in ${args.timeout} s`), { code: "timeout" }); + await new Promise((r) => setTimeout(r, t.rescuePoll)); + } + } + + let lastLeft = 0; + const step = (s, n) => { + if (s === "start") say(`pedal answered: ${n}: stopping its sound processing and installing; do not unplug the pedal`); + else if (s === "verify" || s === "write") { if (n % 250 === 0) say(` ${s}: ${n} requests`); } + else say(` ${s}`); + }; + const onEvent = (kind, x) => { + if (kind === "retry") say(` the pedal went away before the update was complete (${x.message}); waiting for it again`); + else if (lastLeft - x >= 5000 || lastLeft === 0) { lastLeft = x; say(` still waiting for the pedal: ${secs(x)} s left`); } + }; + let reported; + try { + reported = await new Updater(access, null, t).rescueWithin((m) => (pkgs[m] ? { image: pkgs[m].image, product: pkgs[m].product } : null), + step, { timeoutMs: Math.max(0, end - Date.now()), onEvent, loader: given ? { image: given.image, product: given.product } : null }); + } catch (e) { + if (e.code === "pkg") throw refuse(`wrong model: the pedal is a ${e.detail}, the package is ${Object.values(pkgs).map((p) => p.product).join(", ")}. Nothing was sent.`); + throw e; + } + if (reported === null) throw Object.assign(new Error(`no pedal answered in ${args.timeout} s. A pedal that never answers the MIDI identity query (a crash before USB is up, or the chip's boot loader WL80UBOOT) cannot be reached this way. A pedal left in update mode by an interrupted install is only finished when --package names its file`), { code: "timeout" }); + const want = Object.values(pkgs).map((p) => p.product); + if (!want.includes(reported)) throw new Error(`the pedal reports ${reported}, expected ${want.join(" or ")}`); + say(`done: the pedal reports ${reported}.`); + return 0; +} + +// 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" : args.rescue ? "FLASH, rescue mode" : "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 ${describe(pkg)}`); + + 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 }; + if (args.rescue) return await rescue(args, pkg, access, t, say); + 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 ${describe(pkg)}`); } + 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/log.mjs b/tools/log.mjs new file mode 100644 index 0000000..d7c6ee1 --- /dev/null +++ b/tools/log.mjs @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: GPL-3.0-only +// node tools/op.mjs log: read the pedal's state over USB MIDI instead of from its screen, with what each field means and a +// verdict. The request is DIAG (docs/EDITOR-PROTOCOL.md), read only; docs/pxedit.js and docs/pxota.js do the talking. +// log [--follow] [--json] [--wait SEC] [--interval SEC] [--count N] [--scan SEC] +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { C, Editor } from "../docs/pxedit.js"; +import { IS_OTA, PORT_RE, Updater } from "../docs/pxota.js"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const USAGE = `usage: node tools/op.mjs log [--follow] [--json] [--wait SEC] [--interval SEC] [--count N] [--scan SEC] + --wait SEC keep trying for this long until the pedal has finished starting (use it right after a flash) + --interval SEC time between two readings (default 2): the counters must rise between them + --count N how many readings (default 2; with --follow, no limit) + --follow keep reading until stopped + --json one JSON object per reading + --scan SEC then listen for the advertisement on this Mac: SEC seconds on its service UUID, SEC seconds on everything +Exit: 0 pass or running, 1 fail, 2 bad arguments, 3 undecided (still starting, the scanner could not run, no log channel).`; +const BLE_MIDI = "03B80E5A-EDE8-4B33-A751-6CE34EC4C700"; + +// firmware/openpocket.c: ST_NAME, rst_names (tests/test_log.mjs compares them) +export const BOOT_STAGES = ["?", "INPUTS", "PRESET", "ENGINE INIT", "ENGINE SET", "I2S START", "UI DRAW", "FIRST 6 S", "OK"]; +export const RESET_CAUSES = ["POWER-ON", "VDDIO LOW", "WATCHDOG", "VCM", "LONG PRESS", "1.2V", "SOFT"]; +const STAGE_OK = BOOT_STAGES.indexOf("OK"); +const FAIL_STEPS = ["none", "task_create failed", "btctrler_task_init failed", "the controller task did not answer (btctrler_task_ready)", "ll_hci_adv_enable failed", "still starting after 5 s"]; + +// docs/BLE-TEST.md, "The advertising line" +const MEANING = { + i: "baseband event interrupts (IRQ 45): rising means the radio runs", + e: "HCI packets the controller returned", + r: "receive interrupts (IRQ 29): above 0 means a scanner's request was heard", + t: "library software timers that fired: rising means the controller's tick runs", + s: "slot-timer stand-in calls: above 0 means the link scheduler wants a timer this image lacks", + vm: "stored radio records: found mask (7 = VM 106, 107, 108), area / 4096, first two bytes of VM 106", + CON: "baseband control 0x28000", IEN: "baseband interrupt enables 0x28028", IPND: "baseband pending 0x28030", STAT: "baseband busy flags 0x28038", + IC45: "IRQ 45 configuration: bit 0 enabled, bits 3:1 priority, 0 = off", IC29: "IRQ 29 configuration: 0 = off", BEN: "BLE block enable 0x2FC80: bit 0 set = on", +}; + +const number = (line, key, radix = 10) => { + const m = new RegExp(`\\b${key} ?(-?[0-9A-F]+)\\b`).exec(line); + return m ? parseInt(m[1], radix) : null; +}; + +// the status line, exactly as the screen scrolls it +export function parseBle(raw) { + let m; + if (raw.startsWith("ADV ")) { + const f = { state: "advertising", raw }; + for (const k of ["i", "e", "r", "t", "s"]) f[k] = number(raw, k); + for (const k of ["CON", "IEN", "IPND", "STAT", "IC45", "IC29", "BEN"]) f[k] = number(raw, k, 16); + if ((m = / V ([0-9A-F]+) ([0-9A-F]+) ([0-9A-F]+)\b/.exec(raw))) f.vm = { found: parseInt(m[1], 16), area: parseInt(m[2], 16), trim: parseInt(m[3], 16) }; + return f; + } + if ((m = /^FAILED (\d+) (-?\d+)(?: step (\S+) stage (\S+))?(?: wait ([0-9A-F]{8}))?/.exec(raw))) { + return { state: "failed", raw, step: +m[1], code: +m[2], stepName: m[3] || null, stage: m[4] || null, wait: m[5] || null }; + } + if ((m = /^STARTING (\d+)/.exec(raw))) return { state: "starting", raw, stage: +m[1] }; + return { state: raw.startsWith("OFF") ? "off" : "unknown", raw }; +} + +export function parseBoot(raw) { + const rst = number(raw, "RST", 16) ?? 0; + const stage = number(raw, "STAGE") ?? 0; + return { raw, clockMs: number(raw, "UP"), safe: number(raw, "SAFE") === 1, stage, stageName: BOOT_STAGES[stage] ?? "?", resetBits: rst, + resetBy: RESET_CAUSES.filter((_, i) => rst >> i & 1), failedBoots: number(raw, "GUARD") }; +} + +export function parseLast(raw) { + if (!raw.startsWith("LAST FAILED")) return { raw, failed: false }; + const stage = number(raw, "STAGE") ?? 0; + const out = { raw, failed: true, stage, stageName: BOOT_STAGES[stage] ?? "?", blocks: number(raw, "BLOCKS"), load: number(raw, "LOAD"), peak: number(raw, "PEAK"), + nans: number(raw, "NAN"), cpu1: number(raw, "CPU1"), loops: number(raw, "LOOPS"), crash: null }; + const c = raw.indexOf(" CRASH "); + if (c >= 0) { + const t = raw.slice(c); + out.crash = { vec: number(t, "VEC", 16), pc: number(t, "PC", 16), rets: number(t, "RETS", 16), dbg: number(t, "DBG", 16), emu: number(t, "EMU", 16), clockMs: number(t, "UP") }; + } + return out; +} + +const hex = (v) => (v ?? 0).toString(16).toUpperCase().padStart(8, "0"); + +function lastBootWords(reading) { + const l = reading.lastBoot; + if (l.crash) return `it crashed at PC ${hex(l.crash.pc)} (RETS ${hex(l.crash.rets)}, vector ${l.crash.vec.toString(16).toUpperCase()}, pedal clock ${l.crash.clockMs} ms; look the addresses up in firmware/build-ble/symbols.txt)`; + return `it stopped at ${l.stageName}, reset by ${reading.boot.resetBy.join(" + ") || "nothing recorded"}`; +} + +// What cannot be read at each level, and what is left: the verdict's reason says it in words. +const SCREEN = "Look at the pedal's screen or ask for a photo: nothing else can show"; +const NOT_READ = { + none: `No pedal-like MIDI port. The pedal is unplugged, off, on the red CRASH screen, showing the 12 s LAST BOOT FAILED report (USB starts after it), or in its boot loader. ${SCREEN} the crash registers; they can be read here only after the next boot, in safe mode.`, + silent: `A pedal-like MIDI port is there but nothing answers: the main loop is stuck or the firmware failed before USB service. ${SCREEN} what it is doing. If the pedal then resets by itself, read again; a pedal stuck like this needs a power cycle or the UBOOT rescue.`, +}; + +// { result: pass | running | fail | pending | unknown, reason } for the readings so far and a scan, if one ran +export function judge(readings, scan) { + const now = readings[readings.length - 1]; + if (now.level === "none" || now.level === "silent") return { result: "unknown", reason: NOT_READ[now.level] }; + if (now.level === "identity") { + return { result: "unknown", reason: IS_OTA(now.id) + ? `The pedal is in update mode (${now.id.text}), not running firmware. Finish or restart the update; there is no status to read.` + : `${now.id.text} answers only the identity query: it is not OpenPocket firmware. There is no status to read.` }; + } + if (now.level === "info") { + return { result: "unknown", reason: `${now.info.identity} ${now.info.version} runs and answers MIDI (DSP load ${now.ping.load}%), but it has no log channel: only the Bluetooth test image built with DIAG has one. Bluetooth state, counters and last-boot report cannot be read; use the screen.` }; + } + const { boot, lastBoot, ble } = now; + if (boot.safe) return { result: "fail", reason: `The last boot failed: ${lastBootWords(now)}. This boot is safe mode, so Bluetooth stayed off; the boot after this one tries again.` }; + if (ble.state === "failed") { + const what = FAIL_STEPS[ble.step] ?? `step ${ble.step}`; + return { result: "fail", reason: `Bluetooth start-up failed: ${what} (code ${ble.code}${ble.stage ? `, stage ${ble.stage}` : ""}${ble.wait ? `, controller waiting on ${ble.wait}` : ""}).` }; + } + if (ble.state === "starting" || boot.stage !== STAGE_OK) return { result: "pending", reason: `Still starting: boot stage ${boot.stageName}, Bluetooth ${ble.raw}.` }; + if (ble.state !== "advertising") return { result: "fail", reason: ble.state === "off" ? "Bluetooth is off although the boot was normal." : `The status line is not one this tool knows: ${ble.raw}` }; + if (ble.BEN !== null && !(ble.BEN & 1)) return { result: "fail", reason: "The controller says it advertises, but the BLE block is off (BEN bit 0 clear)." }; + + const first = readings.find((r) => r.level === "full" && r.ble.state === "advertising"); + if (first === now) return { result: "pending", reason: "One reading only: the counters must be seen rising in a second reading." }; + if (ble.i <= first.ble.i) { + return { result: "fail", reason: ble.IC45 === 0 ? "No baseband events: interrupt 45 was never enabled (IC45 0)." + : ble.IEN === 0 ? "No baseband events: interrupt 45 is on, but the baseband never enabled its event (IEN 0)." + : "No baseband events: the counter i does not rise, so the radio is not running advertising events." }; + } + const seconds = (Date.parse(now.time) - Date.parse(first.time)) / 1000; // this computer's clock: the pedal's wraps + const rate = `Baseband events run at ${((ble.i - first.ble.i) / seconds).toFixed(1)} a second (the advertising interval gives about 10)`; + const notes = []; + if (ble.t <= first.ble.t) notes.push("The library's software timers did not fire (t stands still)."); + if (ble.s > 0) notes.push(`The link scheduler asked for a slot timer this image lacks (s ${ble.s}).`); + if (ble.vm && ble.vm.found !== 7) notes.push(`Not all stored radio records were found (V ${ble.vm.found.toString(16)}, 7 expected): the radio has zeros for the missing trims.`); + const judged = (result, reason) => ({ result, reason: [reason, ...notes].join(" "), notes }); + if (!scan) return judged("running", `${rate}. That is not proof of packets on the air: add --scan.`); + if (!scan.ok) return judged("unknown", `${rate}, but the scanner could not run: ${scan.why}.`); + if (scan.hits) return judged("pass", `This Mac saw the advertisement ${scan.hits} times: name "${scan.name}", RSSI ${scan.rssi} dBm, connectable ${scan.connectable}.`); + return judged("fail", `${rate} and the controller reports advertising, but this Mac saw no advertisement in ${scan.seconds} s (${scan.others} other devices seen, so the scanner works).`); +} + +// one reading, at the deepest level the pedal answers +async function read(access, timing) { + const time = new Date().toISOString(); + const ports = [...access.inputs.values()].filter((p) => p.state !== "disconnected" && PORT_RE.test(p.name || "")); + if (!ports.length) return { time, level: "none" }; + const ed = new Editor(access); + const info = await ed.connect(); + if (!info) { + const dev = await new Updater(access, null, timing).identify(); + return dev ? { time, level: "identity", id: dev.id, port: dev.name } : { time, level: "silent", port: ports[0].name }; + } + try { + const [boot, last, ble] = await ed.diag(); + return { time, level: "full", info, boot: parseBoot(boot), lastBoot: parseLast(last), ble: parseBle(ble) }; + } catch (e) { + if (e.code !== "timeout") throw e; + const r = await ed.request(C.PING).catch(() => null); + if (!r) return { time, level: "silent", port: info.port }; // it answered INFO, then went away: restarting + return { time, level: "info", info, ping: { load: r[0], bypass: !!(r[1] & 1), secondCore: !!(r[1] & 2) } }; + } finally { ed.close(); } +} + +const started = (r) => r.level === "info" || r.level === "identity" || (r.level === "full" && (r.boot.safe || (r.boot.stage === STAGE_OK && r.ble.state !== "starting"))); + +function show(r, prev, say) { + if (r.level !== "full") { + say(r.level === "info" ? `pedal ${r.info.identity} ${r.info.version} on "${r.info.port}": answers MIDI, no log channel` + : r.level === "identity" ? `pedal ${r.id.text} on "${r.port}": identity only` : r.level === "silent" ? `pedal "${r.port}" is there, nothing answers` : "pedal no MIDI port"); + return; + } + const { boot, lastBoot: l, ble } = r; + say(`pedal ${r.info.identity} ${r.info.version} on "${r.info.port}"`); + say(`this boot pedal clock ${(boot.clockMs / 1000).toFixed(1)} s (wraps every 179 s), stage ${boot.stageName}, ${boot.safe ? "SAFE MODE" : "normal"}, reset by ${boot.resetBy.join(" + ") || "?"}, failed boots counted ${boot.failedBoots}`); + say(`last boot ${l.failed ? `FAILED: ${lastBootWords(r)}; blocks ${l.blocks}, load ${l.load}%, peak ${l.peak}%, NaN ${l.nans}, CPU1 ${l.cpu1}, loops ${l.loops}` : "finished normally"}`); + say(`bluetooth ${ble.raw}`); + if (ble.state !== "advertising") return; + const before = prev?.level === "full" && prev.ble.state === "advertising" ? prev.ble : null; + for (const k of ["i", "e", "r", "t", "s"]) say(` ${k.padEnd(5)}${String(ble[k]).padEnd(10)}${(before ? `+${ble[k] - before[k]}` : "").padEnd(9)}${MEANING[k]}`); + if (ble.vm) say(` ${"V".padEnd(5)}${`${ble.vm.found.toString(16)} ${ble.vm.area.toString(16)} ${ble.vm.trim.toString(16)}`.toUpperCase().padEnd(19)}${MEANING.vm}`); + for (const k of ["CON", "IEN", "IPND", "STAT", "IC45", "IC29", "BEN"]) say(` ${k.padEnd(5)}${(ble[k] ?? 0).toString(16).toUpperCase().padEnd(19)}${MEANING[k]}`); +} + +function compileScanner() { + const bin = resolve(ROOT, "build/ble-scan"), src = resolve(ROOT, "tools/ble-scan.swift"), plist = `${bin}.plist`; + if (existsSync(bin) && statSync(bin).mtimeMs > statSync(src).mtimeMs) return bin; + mkdirSync(dirname(bin), { recursive: true }); + writeFileSync(plist, `CFBundleIdentifierorg.openpocket.ble-scanNSBluetoothAlwaysUsageDescriptionListens for the OpenPocket test advertisement.`); + const r = spawnSync("swiftc", ["-O", src, "-o", bin, "-Xlinker", "-sectcreate", "-Xlinker", "__TEXT", "-Xlinker", "__info_plist", "-Xlinker", plist], { encoding: "utf8" }); + if (r.error || r.status !== 0) throw new Error(`swiftc failed: ${r.error?.message || r.stderr}`); + return bin; +} + +function scanPass(bin, seconds, uuid) { + return new Promise((done) => { + const child = spawn(bin, [String(seconds), ...(uuid ? [uuid] : [])], { stdio: ["ignore", "pipe", "ignore"] }); + const killer = setTimeout(() => child.kill(), (seconds + 15) * 1000); + let out = ""; + child.stdout.on("data", (d) => { out += d; }); + child.on("close", () => { + clearTimeout(killer); + const lines = out.split("\n").filter(Boolean).map((l) => JSON.parse(l)); + done({ hits: lines.filter((l) => !l.done), end: lines.find((l) => l.done) }); + }); + }); +} + +// the service-UUID pass, then the open pass, or why they could not run +export async function macScan(seconds) { + if (process.platform !== "darwin") return { ok: false, why: "the scanner is for macOS only" }; + let bin; + try { bin = compileScanner(); } catch (e) { return { ok: false, why: e.message }; } + const passes = [await scanPass(bin, seconds, BLE_MIDI), await scanPass(bin, seconds, null)]; + const end = passes.map((p) => p.end).find((e) => e && e.state !== 5) || passes[1].end; + if (!end) return { ok: false, why: "the helper stopped without a result" }; + if (end.state !== 5) return { ok: false, why: `the Bluetooth adapter is not usable (state ${end.state}, authorization ${end.auth}; 5 and 3 are needed)` }; + const hits = passes.flatMap((p) => p.hits); + const named = hits.find((h) => h.name) || hits[0]; + return { ok: true, seconds: seconds * 2, hits: hits.length, others: passes[1].end.others, name: named?.name ?? null, id: named?.id ?? null, + rssi: hits.length ? Math.max(...hits.map((h) => h.rssi)) : null, connectable: named?.connectable ?? null, services: named?.services ?? [] }; +} + +function parse(argv) { + const a = { follow: false, json: false, wait: 0, interval: 2, count: 2, scan: 0 }; + for (let i = 0; i < argv.length; i++) { + const v = argv[i]; + if (v === "--follow") a.follow = true; + else if (v === "--json") a.json = true; + else if (["--wait", "--interval", "--count", "--scan"].includes(v)) { + const n = Number(argv[++i]); + if (!(n >= 0)) throw new Error(`${v} needs a number`); + a[v.slice(2)] = n; + } else throw new Error(`unknown argument ${v}`); + } + if (a.follow && a.scan) throw new Error("--scan needs an end: leave out --follow"); + return a; +} + +const EXIT = { pass: 0, running: 0, fail: 1, pending: 3, unknown: 3 }; + +// main(argv, { access, say, scan, timing }): the exit code. access defaults to the real USB MIDI ports. +export async function main(argv, { access, say = console.log, scan = macScan, timing } = {}) { + let args, node = null; + try { args = parse(argv); } catch (e) { say(`${e.message}\n${USAGE}`); return 2; } + const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000)); + try { + if (!access) { + const midi = await import("@julusian/midi").then((m) => m.default || m, () => null); + if (!midi) { say("the MIDI library is not installed: run `npm ci --prefix tools` once (docs/BUILDING.md)."); return 2; } + node = (await import("./midi-node.mjs")).nodeMidiAccess(midi); + access = node; + } + const readings = []; + const end = Date.now() + args.wait * 1000; + let r = await read(access, timing); + while (!started(r) && Date.now() < end) { await sleep(Math.min(1, args.interval || 1)); r = await read(access, timing); } + let verdict; + for (let n = 0; ; n++) { + readings.push(r); + verdict = judge(readings); + if (args.json) { if (!args.scan) say(JSON.stringify({ ...r, verdict })); } else { show(r, readings[readings.length - 2], say); say(`verdict ${verdict.result.toUpperCase()}: ${verdict.reason}`); } + if (!args.follow && n + 1 >= args.count) break; + await sleep(args.interval); + r = await read(access, timing); + } + if (args.scan) { + if (!args.json) say(`scanning ${args.scan} s on the BLE-MIDI service UUID, then ${args.scan} s on everything (never connects)`); + const result = await scan(args.scan); + readings.push(await read(access, timing)); + verdict = judge(readings, result); + if (args.json) say(JSON.stringify({ ...readings[readings.length - 1], scan: result, verdict })); + else { show(readings[readings.length - 1], readings[readings.length - 2], say); say(`verdict ${verdict.result.toUpperCase()}: ${verdict.reason}`); } + } + return EXIT[verdict.result]; + } 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..b538471 100644 --- a/tools/op.mjs +++ b/tools/op.mjs @@ -2,8 +2,12 @@ // SPDX-License-Identifier: GPL-3.0-only // 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 doctor | build | ble | test | package | clean | shell | image | serve [editor|build|ble] +// node tools/op.mjs flash --dry-run | --confirm-flash [--rescue] [--package FILE] (writes to a connected pedal: docs/BUILDING.md) +// node tools/op.mjs log [--follow] [--json] [--wait SEC] [--scan SEC] (reads a connected pedal's state: docs/BLE-TEST.md) // node tools/op.mjs render in.wav [out.wav] [preset] | node tools/op.mjs appimage [fm1-1.bin] +// node tools/op.mjs sim [--port 8766] [--flash FILE] [--reset] | --update FLASH.bin the hosted simulated pedal +// node tools/op.mjs wasm the engine as WebAssembly for the simulated pedal's sound (tools/sim/px_engine.wasm) // NATIVE=1 runs the commands on this machine (toolchain from firmware/setup.sh) instead of in the container. import { spawnSync } from "node:child_process"; import { existsSync, readdirSync, rmSync } from "node:fs"; @@ -67,15 +71,18 @@ const commands = { const img = spawnSync("docker", ["image", "inspect", IMAGE], { stdio: "ignore" }); console.log(img.status === 0 ? `ok image ${IMAGE} is built` : `note image ${IMAGE} is not built yet: the first build or \`image\` makes it (about 1 minute)`); } + const cc = spawnSync(process.env.CC || "cc", ["--version"], { stdio: "ignore" }); + console.log(cc.error || cc.status !== 0 ? "note no C compiler (cc): `sim`, the hosted simulated pedal, needs one (Xcode command line tools, build-essential)" : "ok C compiler for `sim`"); if (process.arch === "arm64") console.log("note ARM machine: the linux/amd64 toolchain runs under emulation (a `test` takes about 1 minute)"); return bad; }, image, build: () => inContainer(["sh", "firmware/build.sh"]), + ble: () => inContainer(["sh", "-c", "OP_BLE=1 sh firmware/build.sh && sha256sum firmware/openpocket-ble.bin build/ble/*.fwsc"]), // the advertise-only Bluetooth test image (docs/BLE-TEST.md) test: () => inContainer(["sh", "tests/run_tests.sh"]), package: () => inContainer(["sh", "-c", `sh firmware/build.sh && mkdir -p build && sha256sum ${OUTS} > build/SHA256SUMS && cat build/SHA256SUMS`]), clean() { - for (const p of ["build", "firmware/build", "firmware/openpocket.bin", "docs/firmware/manifest.json", "engine/px_render"]) { + for (const p of ["build", "firmware/build", "firmware/build-ble", "firmware/openpocket.bin", "firmware/openpocket-ble.bin", "docs/firmware/manifest.json", "engine/px_render"]) { rmSync(resolve(root, p), { recursive: true, force: true }); } const dir = resolve(root, "docs/firmware"); @@ -83,16 +90,25 @@ 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); + }, + async log(args) { // the same: reads the connected pedal, writes nothing + return (await import("./log.mjs")).main(args); + }, serve([arg]) { - if (arg === "build" || !existsSync(resolve(root, "docs/firmware/manifest.json"))) { - const built = commands.build(); + const ble = arg === "ble"; // the Bluetooth test packages (build/ble) instead of docs/firmware + if (ble ? !existsSync(resolve(root, "build/ble/manifest.json")) : arg === "build" || !existsSync(resolve(root, "docs/firmware/manifest.json"))) { + const built = ble ? commands.ble() : commands.build(); if (built !== 0) return built; } const py = process.platform === "win32" ? ["py", "-3"] : ["python3"]; - const r = spawnSync(py[0], [...py.slice(1), "tools/serve.py", arg === "editor" ? "editor.html" : "index.html"], { stdio: "inherit", cwd: root }); + const r = spawnSync(py[0], [...py.slice(1), "tools/serve.py", arg === "editor" ? "editor.html" : "index.html", ...(ble ? ["build/ble"] : [])], { stdio: "inherit", cwd: root }); if (r.error) fail("Python 3 is needed to serve the installer offline (python.org)."); return r.status ?? 0; }, + sim: (args) => exec(process.execPath, ["tools/sim.mjs", ...args]), // the host's own Node and C compiler: no container + wasm: () => inContainer(["sh", "engine/wasm.sh"]), render([input, output, preset]) { if (!input) fail("usage: render in.wav [out.wav] [preset 1..52]"); const rel = inside(input); @@ -110,4 +126,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" + } +} diff --git a/tools/serve.py b/tools/serve.py index aed2c8a..86a68a5 100755 --- a/tools/serve.py +++ b/tools/serve.py @@ -2,15 +2,24 @@ # SPDX-License-Identifier: GPL-3.0-only """OpenPocket offline installer: serves docs/ (installer, editor, packages) on this computer only, at http://localhost:8765/ (Web MIDI works on localhost). Chrome or Edge. Ctrl+C stops it. - tools/serve.py [index.html|editor.html]""" + tools/serve.py [index.html|editor.html] [firmware-dir] +firmware-dir (for example build/ble) replaces docs/firmware as the place the installer reads the packages from.""" import functools, http.server, os, sys, webbrowser page = sys.argv[1] if len(sys.argv) > 1 else "index.html" -root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "docs") +here = os.path.dirname(os.path.abspath(__file__)) +root = os.path.abspath(os.path.join(here, "..", "docs")) +fwdir = os.path.abspath(os.path.join(here, "..", sys.argv[2])) if len(sys.argv) > 2 else None class Handler(http.server.SimpleHTTPRequestHandler): extensions_map = {**http.server.SimpleHTTPRequestHandler.extensions_map, ".js": "text/javascript", ".mjs": "text/javascript", ".fwsc": "application/octet-stream"} + def translate_path(self, path): + p = super().translate_path(path) + prefix = os.path.join(root, "firmware") + if fwdir and (p == prefix or p.startswith(prefix + os.sep)): + return os.path.join(fwdir, os.path.relpath(p, prefix)) + return p def end_headers(self): self.send_header("Cache-Control", "no-store") super().end_headers() diff --git a/tools/sim.mjs b/tools/sim.mjs new file mode 100644 index 0000000..000620e --- /dev/null +++ b/tools/sim.mjs @@ -0,0 +1,320 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: GPL-3.0-only +// The hosted OpenPocket simulator: tests/edsim.c (the pedal's real editor, preset store and screen code, natively) behind +// a local HTTP interface, so a person or an agent can press its footswitches, turn its knobs, read its screen and talk +// SysEx to it. A virtual Web MIDI shim lets the installer and editor pages use it as a pedal. docs/SIMULATOR.md. +// node tools/op.mjs sim [--port 8766] [--flash build/sim/flash.bin] [--reset] +// node tools/op.mjs sim --update FLASH.bin [--stock IDENTITY] the update flow (tests/devsim.c) for the installer +import { spawn, spawnSync } from "node:child_process"; +import { createServer } from "node:http"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; +import { dirname, extname, join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { deflateSync } from "node:zlib"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const BUILD = join(ROOT, "build", "sim"); +const BIN = join(BUILD, `${process.platform}-${process.arch}`); // the container shares build/ with the host: one binary each +const SIM_FILES = join(ROOT, "tools", "sim"); +const SIM_SERVED = new Set(["webmidi-shim.js", "pedal-input.js", "pedal-audio.js", "pedal-worklet.js", "px-engine.js", "px_engine.wasm"]); +const LOOPS = join(ROOT, "assets", "loops"); + +// ---- the native simulators, built with the host's C compiler (no JieLi toolchain, no Docker) ---- +const PROGRAMS = { + pedal: { src: ["tests/edsim.c", "engine/px_dsp.c", "firmware/px_math.c"], libs: ["-lm"] }, + update: { src: ["tests/devsim.c"], libs: [] }, +}; +function newest(dirs) { + let t = 0; + for (const d of dirs) { + for (const f of readdirSync(join(ROOT, d))) if (/\.[ch]$/.test(f)) t = Math.max(t, statSync(join(ROOT, d, f)).mtimeMs); + } + return t; +} +function compile(kind) { + const bin = join(BIN, kind === "pedal" ? "edsim" : "devsim"); + mkdirSync(BIN, { recursive: true }); + const stale = !existsSync(bin) || statSync(bin).mtimeMs < newest(["tests", "engine", "firmware", "firmware/bsp/chip", "loader"]); + if (!stale) return bin; + const p = PROGRAMS[kind]; + const r = spawnSync(process.env.CC || "cc", ["-O1", "-Wall", "-Wno-unused-function", "-o", bin, ...p.src, ...p.libs], { cwd: ROOT, stdio: "inherit" }); + if (r.error || r.status !== 0) { + throw new Error(r.error ? `no C compiler (${r.error.message}): install one (Xcode command line tools, build-essential) or set CC` : "the simulator did not compile"); + } + return bin; +} + +// ---- one simulated pedal: the native program on stdin/stdout, one line per MIDI message or '#' command ---- +class Pedal { + constructor(kind, args) { + this.kind = kind; this.args = args; + this.port = null; this.log = []; + this.waiters = new Map(); // reply word -> FIFO of resolvers + this.listeners = new Set(); // (event, value): "midi" (Uint8Array), "port" (name | null) + this.chain = Promise.resolve(); // SysEx requests go one at a time, as the pedal takes them + } + emit(ev, v) { for (const l of this.listeners) l(ev, v); } + setPort(name) { if (name !== this.port) { this.port = name; this.emit("port", name); } } + start() { + const child = spawn(compile(this.kind), this.args, { stdio: ["pipe", "pipe", "inherit"] }); + this.child = child; + let buf = "", ready; + this.ready = new Promise((r) => { ready = r; }); + this.exited = new Promise((r) => child.on("exit", (code) => { this.setPort(null); this.failAll(); ready(); r(code); })); + child.stdin.on("error", () => { }); + 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); this.line(ln, ready); } + }); + return this.ready; + } + line(ln, ready) { + if (!ln) return; + if (ln.startsWith("#port ")) { const n = ln.slice(6); this.setPort(n === "none" ? null : n); ready(); return; } + if (ln.startsWith("#log ")) { this.log.push(ln.slice(5)); if (this.log.length > 200) this.log.shift(); return; } + if (ln.startsWith("#")) { + const sp = ln.indexOf(" "), word = sp < 0 ? ln.slice(1) : ln.slice(1, sp); + this.waiters.get(word)?.shift()?.(sp < 0 ? "" : ln.slice(sp + 1)); + return; + } + this.emit("midi", Uint8Array.from(Buffer.from(ln, "hex"))); + } + failAll() { for (const q of this.waiters.values()) for (const r of q.splice(0)) r(null); } + // a '#' command: resolves with the text after the reply word, null when the program ended or stayed silent + ask(cmd, word = cmd.slice(1).split(" ")[0], ms = 10000) { + return new Promise((resolve) => { + if (this.child.exitCode !== null) return resolve(null); + const q = this.waiters.get(word) || this.waiters.set(word, []).get(word); + const t = setTimeout(() => { const i = q.indexOf(res); if (i >= 0) q.splice(i, 1); resolve(null); }, ms); + const res = (v) => { clearTimeout(t); resolve(v); }; + q.push(res); + this.child.stdin.write(cmd + "\n"); + }); + } + midi(bytes) { this.child.stdin.write(Buffer.from(bytes).toString("hex") + "\n"); } + // a request and its reply: an editor request is answered by the frame with the same command byte + sysex(bytes, ms = 1000) { + const run = () => new Promise((resolve) => { + const editor = bytes[1] === 0x7D && bytes[2] === 0x4F && bytes[3] === 0x50; + const done = (v) => { clearTimeout(t); this.listeners.delete(l); resolve(v); }; + const l = (ev, m) => { if (ev === "midi" && (!editor || (m[1] === 0x7D && m[2] === 0x4F && m[3] === 0x50 && m[4] === bytes[4]))) done(m); }; + const t = setTimeout(() => done(null), ms); + this.listeners.add(l); + this.midi(bytes); + }); + const p = this.chain.then(run); + this.chain = p.catch(() => { }); + return p; + } + async stop() { + if (!this.child) return; + this.child.stdin.end(); + const t = setTimeout(() => this.child.kill(), 3000); + await this.exited; + clearTimeout(t); + } +} + +// ---- the screen as a PNG ---- +const CRC = Uint32Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; }); +function crc32(b) { let c = ~0; for (const x of b) c = CRC[(c ^ x) & 255] ^ (c >>> 8); return ~c >>> 0; } +function chunk(type, data) { + const out = Buffer.alloc(12 + data.length); + out.writeUInt32BE(data.length, 0); out.write(type, 4, "ascii"); data.copy(out, 8); + out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length); + return out; +} +function rgb565(be) { // 240x240 big-endian RGB565 -> RGB888 + const px = Buffer.alloc(240 * 240 * 3); + for (let i = 0; i < 240 * 240; i++) { + const c = be.readUInt16BE(i * 2); + px[i * 3] = ((c >> 11) & 31) * 255 / 31; px[i * 3 + 1] = ((c >> 5) & 63) * 255 / 63; px[i * 3 + 2] = (c & 31) * 255 / 31; + } + return px; +} +function png(be, scale = 1) { + const w = 240 * scale, rgb = rgb565(be), raw = Buffer.alloc((w * 3 + 1) * w); + for (let y = 0; y < w; y++) { + const o = y * (w * 3 + 1), sy = Math.floor(y / scale); + for (let x = 0; x < w; x++) rgb.copy(raw, o + 1 + x * 3, (sy * 240 + Math.floor(x / scale)) * 3, (sy * 240 + Math.floor(x / scale)) * 3 + 3); + } + const head = Buffer.alloc(13); + head.writeUInt32BE(w, 0); head.writeUInt32BE(w, 4); head[8] = 8; head[9] = 2; + return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", head), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]); +} + +// ---- HTTP ---- +const TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json", ".png": "image/png", ".css": "text/css", ".md": "text/plain; charset=utf-8", ".wasm": "application/wasm", ".wav": "audio/wav" }; +const BUTTONS = ["fs_up", "fs_dn", "fx", "home", "push", "mod", "amp", "cab", "dly", "rev"]; +const CONTROLS = [...BUTTONS, "push_long", "dpush", "home_long"]; +const SHIM_TAG = ''; + +class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } } +const hex = (s) => { const t = s.replace(/[^0-9a-fA-F]/g, ""); if (!t.length || t.length % 2) throw new HttpError(400, "body: hex bytes (or a JSON array of numbers)"); return Uint8Array.from(Buffer.from(t, "hex")); }; +const midiBytes = (body) => { + const t = body.toString("utf8").trim(); + const bytes = t.startsWith("[") ? Uint8Array.from(JSON.parse(t)) : t.startsWith("{") ? Uint8Array.from(JSON.parse(t).data) : hex(t); + if (!bytes.length || bytes.some((b) => !(b >= 0 && b < 256))) throw new HttpError(400, "body: bytes 0..255"); + return bytes; +}; +async function readBody(req, max = 2 << 20) { + const parts = []; let n = 0; + for await (const c of req) { n += c.length; if (n > max) throw new HttpError(413, "body too large"); parts.push(c); } + return Buffer.concat(parts); +} +const num = (s, lo, hi, what) => { const v = Number(s); if (!Number.isFinite(v) || v < lo || v > hi) throw new HttpError(400, `${what}: a number from ${lo} to ${hi}`); return v; }; + +export async function startSim(opts = {}) { + const update = opts.update ? resolve(opts.update) : null; + const flash = resolve(opts.flash || join(BUILD, "flash.bin")); + const kind = update ? "update" : "pedal"; + mkdirSync(dirname(flash), { recursive: true }); + const args = () => update ? [update, ...(opts.stock ? ["--stock", opts.stock] : []), "--dump", join(BUILD, "update-dump.bin")] : ["--persist", flash]; + if (!update && opts.reset) rmSync(flash, { force: true }); + let pedal = new Pedal(kind, args()); + const clients = new Set(); // SSE responses + const attach = (p) => p.listeners.add((ev, v) => { + const data = ev === "midi" ? Buffer.from(v).toString("hex") : v || ""; + for (const c of clients) c.write(`event: ${ev}\ndata: ${data}\n\n`); + }); + attach(pedal); + let busy = pedal.start(); + const restart = async (afterStop) => { + await busy; + await pedal.stop(); + afterStop?.(); + const next = new Pedal(kind, args()); + attach(next); + pedal = next; + busy = pedal.start(); + await busy; + }; + await busy; + if (pedal.child.exitCode !== null) throw new Error(`the simulator program ended at once (exit ${pedal.child.exitCode}): is ${update || flash} a 1 MiB flash image?`); + + const needPedal = () => { if (kind !== "pedal") throw new HttpError(409, "this is the update simulator (--update): it has no screen or controls"); }; + const reply = async (cmd) => { const r = await pedal.ask(cmd); if (r === null) throw new HttpError(503, "the simulated pedal did not answer"); return r; }; + const ok = async (cmd) => { const r = await reply(cmd); if (r !== "ok") throw new HttpError(400, r.replace(/^err ?/, "") || "refused"); return { ok: true }; }; + + async function api(req, res, path, query) { + const [, , what, a, b] = path.split("/"); + const post = req.method === "POST"; + if (what === "info") return json(res, { kind, port: pedal.port, flash: update || flash, controls: CONTROLS, log: pedal.log }); + if (what === "events") { + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive", ...cors }); + res.write(`event: port\ndata: ${pedal.port || ""}\n\n`); + clients.add(res); + const beat = setInterval(() => res.write(": \n\n"), 15000); + req.on("close", () => { clearInterval(beat); clients.delete(res); }); + return; + } + if (what === "midi" && post) { pedal.midi(midiBytes(await readBody(req))); res.writeHead(204, cors); return res.end(); } + if (what === "sysex" && post) { + const t0 = Date.now(), m = await pedal.sysex(midiBytes(await readBody(req)), num(query.get("timeout") ?? 1000, 1, 60000, "timeout")); + return json(res, { reply: m && Buffer.from(m).toString("hex"), ms: Date.now() - t0 }); + } + if (what === "reset" && post) { await restart(() => { if (kind === "pedal") rmSync(flash, { force: true }); }); return json(res, { ok: true }); } + if (what === "restart" && post) { await restart(); return json(res, { ok: true }); } + needPedal(); + await busy; + if (what === "state") { const s = await reply("#status"); return json(res, JSON.parse(s)); } + if (what === "screen.png" || what === "screen.rgb565") { + const h = await reply("#screen"); + const be = Buffer.from(h, "hex"); + if (what === "screen.rgb565") { res.writeHead(200, { "content-type": "application/octet-stream", "cache-control": "no-store", ...cors }); return res.end(be); } + res.writeHead(200, { "content-type": "image/png", "cache-control": "no-store", ...cors }); + return res.end(png(be, num(query.get("scale") ?? 1, 1, 8, "scale"))); + } + if (what === "flash") { + const tmp = join(BUILD, `flash-${process.pid}.tmp`); + await ok(`#flash ${tmp}`); + const data = readFileSync(tmp); rmSync(tmp, { force: true }); + res.writeHead(200, { "content-type": "application/octet-stream", ...cors }); + return res.end(data); + } + if (!post) throw new HttpError(405, "POST"); + if (what === "press") { if (!CONTROLS.includes(a)) throw new HttpError(400, `control: one of ${CONTROLS.join(" ")}`); return json(res, await ok(`#press ${a}`)); } + if (what === "down" || what === "up") { if (!BUTTONS.includes(a)) throw new HttpError(400, `button: one of ${BUTTONS.join(" ")}`); return json(res, await ok(`#${what} ${a}`)); } + if (what === "knob") return json(res, await ok(`#knob ${Math.trunc(num(a, -200, 200, "knob steps"))}${query.has("fast") ? " fast" : ""}`)); + if (what === "volume") return json(res, await ok(`#vol ${Math.trunc(num(a, 0, 100, "volume"))}`)); + if (what === "battery") return json(res, await ok(`#bat ${Math.trunc(num(a, 0, 6000, "battery mV"))} ${b === undefined ? "" : Math.trunc(num(b, 0, 6000, "USB mV"))}`)); + if (what === "tuner") return json(res, await ok(`#hz ${num(a, 0, 5000, "tuner Hz")}`)); + if (what === "load") return json(res, await ok(`#dsp load ${Math.trunc(num(a, 0, 999, "DSP load"))}`)); + if (what === "bypass") return json(res, await ok(`#dsp bypass ${Math.trunc(num(a, 0, 1, "bypass"))}`)); + throw new HttpError(404, "no such endpoint"); + } + + const cors = { "access-control-allow-origin": "*", "access-control-allow-private-network": "true", "access-control-allow-headers": "*", "access-control-allow-methods": "GET, POST, OPTIONS" }; + const json = (res, obj, status = 200) => { res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...cors }); res.end(JSON.stringify(obj)); }; + const file = (res, path, inject) => { + if (!existsSync(path) || !statSync(path).isFile()) throw new HttpError(404, "not found"); + let data = readFileSync(path); + if (inject) data = Buffer.from(data.toString("utf8").replace(/]*>/i, (m) => m + SHIM_TAG)); + res.writeHead(200, { "content-type": TYPES[extname(path)] || "application/octet-stream", "cache-control": "no-store", ...cors }); + res.end(data); + }; + const within = (dir, rel) => { const p = resolve(dir, "." + sep + decodeURIComponent(rel)); if (p !== dir && !p.startsWith(dir + sep)) throw new HttpError(404, "not found"); return p; }; + + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://localhost"); + const path = url.pathname; + if (req.method === "OPTIONS") { res.writeHead(204, cors); return res.end(); } + if (path.startsWith("/api/")) return await api(req, res, path, url.searchParams); + if (req.method !== "GET") throw new HttpError(405, "GET"); + if (path === "/") return file(res, join(SIM_FILES, "pedal.html")); + if (path === "/plain") return file(res, join(SIM_FILES, "page.html")); + if (path.startsWith("/sim/") && SIM_SERVED.has(path.slice(5))) return file(res, join(SIM_FILES, path.slice(5))); + if (path.startsWith("/loops/")) return file(res, within(LOOPS, path.slice(7))); + if (path.startsWith("/site/")) { const p = within(join(ROOT, "docs"), path.slice(6) || "index.html"); return file(res, p, extname(p) === ".html"); } + throw new HttpError(404, "not found"); + } catch (e) { + if (!res.headersSent) json(res, { error: e instanceof HttpError || e instanceof SyntaxError ? e.message : String(e) }, e.status || (e instanceof SyntaxError ? 400 : 500)); + else res.end(); + } + }); + try { + await new Promise((r, j) => { server.once("error", j); server.listen(opts.port ?? 8766, opts.host || "127.0.0.1", r); }); + } catch (e) { + await pedal.stop(); // the port is taken: do not leave the pedal program behind + throw e; + } + const url = `http://${server.address().address}:${server.address().port}`; + return { + url, kind, flash, + async close() { + for (const c of clients) c.end(); + server.close(); server.closeAllConnections?.(); + await busy; await pedal.stop(); + }, + }; +} + +// ---- command line ---- +const USAGE = `usage: node tools/op.mjs sim [--port 8766] [--host 127.0.0.1] [--flash FILE] [--reset] + node tools/op.mjs sim --update FLASH.bin [--stock IDENTITY] [--port N] + --flash FILE the pedal's flash, kept between runs (default build/sim/flash.bin); --reset starts from a blank one + --update FILE the update flow (the installer's stand-in) on this 1 MiB flash image (never written) instead of the pedal`; +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const argv = process.argv.slice(2), o = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i], val = () => { if (i + 1 >= argv.length) { console.error(USAGE); process.exit(2); } return argv[++i]; }; + if (a === "--port") o.port = Number(val()); + else if (a === "--host") o.host = val(); + else if (a === "--flash") o.flash = val(); + else if (a === "--reset") o.reset = true; + else if (a === "--update") o.update = val(); + else if (a === "--stock") o.stock = val(); + else { console.error(USAGE); process.exit(a === "--help" || a === "-h" ? 0 : 2); } + } + if (o.update && !existsSync(o.update)) { console.error(`${o.update} not found`); process.exit(2); } + try { + const sim = await startSim(o); + console.log(`OpenPocket simulator (${sim.kind}) listening on ${sim.url}/`); + console.log(` the pedal: ${sim.url}/ plain controls: ${sim.url}/plain installer: ${sim.url}/site/index.html editor: ${sim.url}/site/editor.html`); + if (sim.kind === "pedal") console.log(` flash: ${sim.flash}`); + const quit = async () => { await sim.close(); process.exit(0); }; + process.on("SIGINT", quit); process.on("SIGTERM", quit); + } catch (e) { console.error(`ERROR: ${e.message}`); process.exit(1); } +} diff --git a/tools/sim/package.json b/tools/sim/package.json new file mode 100644 index 0000000..5ffd980 --- /dev/null +++ b/tools/sim/package.json @@ -0,0 +1 @@ +{ "type": "module" } diff --git a/tools/sim/page.html b/tools/sim/page.html new file mode 100644 index 0000000..c8f8ad6 --- /dev/null +++ b/tools/sim/page.html @@ -0,0 +1,123 @@ + + + + + + +OpenPocket simulator + + + +
+

OpenPocket simulator

+ Installer + Editor + Download flash + + connecting… +
+
+
+

Screen

+ The pedal's screen +
+
+

Controls

+
+
+ + + +
+
+ + +
+
+ +
+
80
+
+
+
+

Keys: arrows = footswitches (up/down) and knob (left/right), Enter = PUSH, Escape = HOME.

+
+
+

State

+
+
+
+
+ + + diff --git a/tools/sim/pedal-audio.js b/tools/sim/pedal-audio.js new file mode 100644 index 0000000..b1c6440 --- /dev/null +++ b/tools/sim/pedal-audio.js @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: GPL-3.0-only +// The pedal's sound on the page: a source (a bundled loop, a dropped file, an input of the computer) -> the input level -> +// the engine in an AudioWorklet -> the device mute -> the output volume -> the speakers. The context runs at the engine's +// 44.1 kHz, so the browser does every sample-rate conversion. docs/SIMULATOR.md. +import { ENGINE_RATE, GUITAR_LEVEL, presetBytes } from "/sim/px-engine.js"; + +export const dbfs = (peak) => (peak > 0 ? 20 * Math.log10(peak) : -Infinity); +const tuning = (s) => ({ volume: s.volume, bypass: Boolean(s.dsp.bypass), tunerMute: s.mode === "tuner" }); + +export class PedalAudio { + constructor({ onMeter, onLoad, onChange }) { + Object.assign(this, { onMeter, onLoad, onChange }); + this.ctx = null; + this.node = null; // the engine + this.playing = null; // { stop(), device: boolean }: the source that sounds now + this.buffers = new Map(); + this.pedal = null; // the last pedal state seen + this.sentPreset = ""; + this.sent = {}; + this.levelPercent = 100; + this.volumePercent = 70; + this.heard = null; + this.deviceHeard = false; + this.repeat = true; + } + // must be called from a user gesture: browsers keep a context made outside of one suspended + async start() { + this.ctx ??= new AudioContext({ sampleRate: ENGINE_RATE, latencyHint: "interactive" }); + const resumed = this.ctx.resume(); + this.starting ??= this.build().catch((error) => { this.starting = null; throw error; }); + await this.starting; + await resumed; + } + async build() { + const ctx = this.ctx; + if (!ctx.audioWorklet) throw new Error("this browser gives audio worklets only to https or localhost pages"); + const [module] = await Promise.all([ + fetch("/sim/px_engine.wasm").then((r) => WebAssembly.compileStreaming(r)), + ctx.audioWorklet.addModule("/sim/pedal-worklet.js"), + ]); + this.level = new GainNode(ctx); + this.mute = new GainNode(ctx); + this.out = new GainNode(ctx); + this.node = new AudioWorkletNode(ctx, "pedal", { + numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2], channelCount: 1, channelCountMode: "explicit", + channelInterpretation: "speakers", processorOptions: { module }, + }); + this.node.port.onmessage = ({ data }) => { + this.heard = data.heard; + this.onMeter(data); + if (data.load !== this.load) { this.load = data.load; this.onLoad(data.load); } + }; + this.level.connect(this.node).connect(this.mute).connect(this.out).connect(ctx.destination); + this.setLevel(this.levelPercent); + this.setVolume(this.volumePercent); + if (this.pedal) this.follow(this.pedal); + } + // the sound follows the pedal: what `GET /api/state` says now, as audio_poll() in firmware/px_audio.h + follow(state) { + this.pedal = state; + if (!this.node) return; + const port = this.node.port, preset = JSON.stringify(state.preset); + if (preset !== this.sentPreset) { + this.sentPreset = preset; + port.postMessage({ preset: presetBytes(state.preset), name: state.preset.name }); + } + for (const [key, value] of Object.entries(tuning(state))) { + if (this.sent[key] !== value) { this.sent[key] = value; port.postMessage({ [key]: value }); } + } + } + setLevel(percent) { this.levelPercent = percent; if (this.level) this.level.gain.value = percent / 100 * GUITAR_LEVEL; } + setVolume(percent) { this.volumePercent = percent; if (this.out) this.out.gain.value = (percent / 100) ** 2; } + setRepeat(on) { this.repeat = on; if (this.playing?.buffer) this.playing.buffer.loop = on; } + hearDevice(on) { this.deviceHeard = on; this.applyMute(); } + applyMute() { if (this.mute) this.mute.gain.value = this.playing?.device && !this.deviceHeard ? 0 : 1; } + + async decode(key, bytes) { + if (!this.buffers.has(key)) this.buffers.set(key, await this.ctx.decodeAudioData(await bytes())); + return this.buffers.get(key); + } + async playBuffer(key, bytes) { + await this.start(); + const buffer = await this.decode(key, bytes); + this.stop(); + const node = new AudioBufferSourceNode(this.ctx, { buffer, loop: this.repeat }); + node.connect(this.level); + const playing = { buffer: node, device: false, stop: () => { node.onended = null; node.stop(); node.disconnect(); } }; + node.onended = () => { if (this.playing === playing) { this.playing = null; this.onChange(); } }; + node.start(); + this.playing = playing; + this.applyMute(); + this.onChange(); + } + async playDevice(deviceId) { + await this.start(); + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { deviceId: deviceId ? { exact: deviceId } : undefined, echoCancellation: false, noiseSuppression: false, autoGainControl: false }, + }); + const close = () => { for (const t of stream.getTracks()) t.stop(); }; + let node; + try { node = this.ctx.createMediaStreamSource(stream); } catch (e) { close(); throw e; } // Firefox: another sample rate + this.stop(); + node.connect(this.level); + this.playing = { device: true, stop: () => { node.disconnect(); close(); } }; + this.applyMute(); + this.onChange(); + } + stop() { + this.playing?.stop(); + this.playing = null; + this.onChange(); + } + state() { + return { + running: this.ctx?.state === "running", sampleRate: this.ctx?.sampleRate ?? null, playing: Boolean(this.playing), + device: Boolean(this.playing?.device), muted: this.mute ? this.mute.gain.value === 0 : false, heard: this.heard, + latencyMs: this.ctx ? Math.round((this.ctx.baseLatency + (this.ctx.outputLatency || 0)) * 1000) : null, + }; + } +} diff --git a/tools/sim/pedal-input.js b/tools/sim/pedal-input.js new file mode 100644 index 0000000..4166427 --- /dev/null +++ b/tools/sim/pedal-input.js @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-only +// OpenPocket — what a key, a drag or a wheel on the pedal skin (pedal.html) means to the simulated pedal, and what its +// state means to the backlights. No DOM, so tests/test_pedal_input.mjs runs it in Node. Hold timing is not here: +// tests/edsim.c times a held button as firmware/openpocket.c does. + +export const BUTTONS = ["home", "fx", "amp", "mod", "dly", "rev", "cab", "fs_dn", "fs_up"]; +export const BLOCK = { amp: 5, cab: 6, mod: 8, dly: 9, rev: 10 }; +export const FX_BLOCKS = [4, 3, 11, 2, 1, 0, 7]; // ui_fx_cycle: DRV SFX SYN PIT CMP GT EQ + +export const FAST_MS = 45; // openpocket.c: detents closer than this are a fast turn +export const MASTER_STEP = 2; // openpocket.c: the volume's hysteresis, the smallest move it follows +const MASTER_SWEEP = 270; // degrees from 0 to 100 +export const DETENT_DEGREES = 15; // 24 detents a turn: an assumption, the firmware only counts them +export const PRESS_MS = 120; // PARA: still for this long, a press and not a turn +export const SLOP_PX = 4; // PARA: moved this far, a turn and not a press + +const WHEEL_NOTCH_PX = 100; +const LINE_PX = 33; +const DETENTS_PER_KNOB_WIDTH = 10; // a drag as long as the knob is wide +const MASTER_PER_KNOB_WIDTH = 40; + +const hold = (control) => ({ hold: control }); +const turn = (detents) => ({ turn: detents }); +const master = (delta) => ({ master: delta }); + +const KEYS = { + ArrowUp: hold("fs_up"), ArrowDown: hold("fs_dn"), ArrowLeft: turn(-1), ArrowRight: turn(1), + Enter: hold("push"), Escape: hold("home"), + h: hold("home"), f: hold("fx"), a: hold("amp"), m: hold("mod"), d: hold("dly"), r: hold("rev"), c: hold("cab"), + "[": master(-MASTER_STEP), "]": master(MASTER_STEP), +}; +const FOCUSED_MASTER = { + ArrowUp: master(MASTER_STEP), ArrowRight: master(MASTER_STEP), ArrowDown: master(-MASTER_STEP), ArrowLeft: master(-MASTER_STEP), + PageUp: master(10), PageDown: master(-10), Home: { masterTo: 0 }, End: { masterTo: 100 }, +}; +const FOCUSED_PARA = { ArrowUp: turn(1), ArrowDown: turn(-1), " ": hold("push") }; + +// The action of a key, given the control that has the focus (null: none): {hold}, {turn}, {master}, {masterTo} or null +export function keyAction(key, focused = null) { + const k = key.length === 1 ? key.toLowerCase() : key; + if (focused === "master" && FOCUSED_MASTER[k]) return FOCUSED_MASTER[k]; + if (focused === "para" && FOCUSED_PARA[k]) return FOCUSED_PARA[k]; + if (BUTTONS.includes(focused) && (k === "Enter" || k === " ")) return hold(focused); + return KEYS[k] ?? null; +} + +// Detents a drag has reached so far on PARA: right and up turn it clockwise +export const dragDetents = (dx, dy, knobPx) => Math.trunc((dx - dy) * DETENTS_PER_KNOB_WIDTH / knobPx); +export function wheelNotches(rest, deltaY, deltaMode = 0) { + const travel = rest - deltaY * [1, LINE_PX, WHEEL_NOTCH_PX][deltaMode], notches = Math.trunc(travel / WHEEL_NOTCH_PX); + return { notches, rest: travel - notches * WHEEL_NOTCH_PX }; +} +export const isFast = (lastMs, nowMs, detents) => (nowMs - lastMs) / Math.abs(detents) < FAST_MS; + +const clampMaster = (v) => Math.min(100, Math.max(0, Math.round(v))); +export const masterFromDrag = (start, dx, dy, knobPx) => clampMaster(start + (dx - dy) * MASTER_PER_KNOB_WIDTH / knobPx); +// What the firmware's volume does with a new reading: it stays unless it moved by the hysteresis or reached an end +export function masterFollow(current, reading) { + const next = clampMaster(reading); + return Math.abs(next - current) >= MASTER_STEP || (next !== current && (next === 0 || next === 100)) ? next : current; +} +export const masterAngle = (volume) => (volume / 100 - 0.5) * MASTER_SWEEP; + +// PARA is both a knob and a button: a gesture starts undecided, and only a press reaches the pedal as a held button +const PARA_GESTURE = { + idle: { down: { to: "undecided", send: [] } }, + undecided: { + far: { to: "turning", send: [] }, still: { to: "pressed", send: ["down"] }, up: { to: "idle", send: ["down", "up"] }, + }, + pressed: { up: { to: "idle", send: ["up"] } }, + turning: { up: { to: "idle", send: [] } }, +}; +export const paraStep = (gesture, event) => PARA_GESTURE[gesture][event] ?? { to: gesture, send: [] }; + +// The firmware drives no LEDs, so this is the skin's own reading of the state: "off", "on" or "focus" for each button +export function backlights(state) { + const paged = state.mode === "block" || state.mode === "edit"; + const light = (blocks) => { + if (paged && blocks.includes(state.block)) return "focus"; + return blocks.some((b) => state.preset.on[b]) ? "on" : "off"; + }; + const lit = { home: state.mode === "chain" ? "focus" : "on", fx: light(FX_BLOCKS) }; + for (const [name, block] of Object.entries(BLOCK)) lit[name] = light([block]); + return lit; +} diff --git a/tools/sim/pedal-worklet.js b/tools/sim/pedal-worklet.js new file mode 100644 index 0000000..d153c98 --- /dev/null +++ b/tools/sim/pedal-worklet.js @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-only +// The pedal's sound thread: each 128 frames go through the engine as the firmware's I2S interrupt sends them. Mono in, the +// same signal on both outputs (a_dec in firmware/px_audio.h). docs/SIMULATOR.md. +import { BLOCK, ENGINE_RATE, PxEngine } from "/sim/px-engine.js"; + +const REPORT_BLOCKS = 16; // about 46 ms: the meters +const LOAD_BLOCKS = 172; // about 0.5 s, as often as the pedal's status text changes +const BLOCK_MS = BLOCK / ENGINE_RATE * 1000; +const now = () => globalThis.performance?.now() ?? Date.now(); +const peak = (a) => { let m = 0; for (const v of a) { const x = Math.abs(v); if (x > m) m = x; } return m; }; + +class PedalProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + this.engine = new PxEngine(options.processorOptions.module); + this.heard = null; + this.inPeak = this.outPeak = this.blocks = this.busyMs = this.busyBlocks = 0; + this.load = 0; + this.port.onmessage = ({ data }) => { + if (data.preset) { this.engine.preset(data.preset); this.heard = data.name; } + if (data.volume !== undefined) this.engine.volume(data.volume); + if (data.bypass !== undefined) this.engine.bypass(data.bypass); + if (data.tunerMute !== undefined) this.engine.tunerMute(data.tunerMute); + }; + } + process(inputs, outputs) { + const e = this.engine, input = inputs[0][0]; + if (input) e.input.set(input); else e.input.fill(0); + const t0 = now(); + e.process(); + this.busyMs += now() - t0; + for (const channel of outputs[0]) channel.set(e.output); + this.inPeak = Math.max(this.inPeak, peak(e.input)); + this.outPeak = Math.max(this.outPeak, peak(e.output)); + if (++this.busyBlocks === LOAD_BLOCKS) { + this.load = Math.round(this.busyMs / (LOAD_BLOCKS * BLOCK_MS) * 100); + this.busyMs = this.busyBlocks = 0; + } + if (++this.blocks === REPORT_BLOCKS) { + this.port.postMessage({ input: this.inPeak, output: this.outPeak, load: this.load, heard: this.heard }); + this.inPeak = this.outPeak = this.blocks = 0; + } + return true; + } +} +registerProcessor("pedal", PedalProcessor); diff --git a/tools/sim/pedal.html b/tools/sim/pedal.html new file mode 100644 index 0000000..79d123b --- /dev/null +++ b/tools/sim/pedal.html @@ -0,0 +1,493 @@ + + + + + + +OpenPocket simulator + + + +
+
+ + + + +
+
+ + + + + + + + + + +
+
+ + + + + + diff --git a/tools/sim/px-engine.js b/tools/sim/px-engine.js new file mode 100644 index 0000000..9705d1a --- /dev/null +++ b/tools/sim/px-engine.js @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-only +// The pedal's engine as WebAssembly (engine/px_wasm.c, tools/sim/px_engine.wasm), on arrays of 128 samples at 44.1 kHz. One +// class for the AudioWorklet (tools/sim/pedal-worklet.js) and the tests. docs/SIMULATOR.md. +export const ENGINE_RATE = 44100; +export const BLOCK = 128; +// tools/calib_levels.py: a guitar at the pedal's level, which a full-scale DI recording reaches at this gain +export const GUITAR_LEVEL = 0.15; +const NAME = 14, NBLK = 12, NPAR = 5; + +// `preset` is the sound as `GET /api/state` reports it; the bytes are px_preset_t (engine/px_dsp.h) +export function presetBytes(preset) { + const b = new Uint8Array(NAME + NBLK * 3 + NBLK * NPAR); + b.set([...preset.name].slice(0, NAME - 1).map((c) => c.charCodeAt(0))); + b.set(preset.on, NAME); + b.set(preset.type, NAME + NBLK); + b.set(preset.p.flat(), NAME + 2 * NBLK); + b.set(preset.order, NAME + 2 * NBLK + NBLK * NPAR); + return b; +} + +export class PxEngine { + constructor(module) { + this.x = new WebAssembly.Instance(module).exports; + const view = (ptr) => new Float32Array(this.x.memory.buffer, ptr, BLOCK); + this.input = view(this.x.px_input()); + this.output = view(this.x.px_output()); + this.x.px_factory(0); + this.x.px_reset(); + } + factory(index) { this.x.px_factory(index); this.x.px_apply(); } + preset(bytes) { new Uint8Array(this.x.memory.buffer, this.x.px_preset(), bytes.length).set(bytes); this.x.px_apply(); } + volume(master) { this.x.px_volume(master); } + bypass(on) { this.x.px_bypass(on ? 1 : 0); } + tunerMute(on) { this.x.px_tuner_mute(on ? 1 : 0); } + process() { this.x.px_process(); } +} diff --git a/tools/sim/px_engine.wasm b/tools/sim/px_engine.wasm new file mode 100644 index 0000000..b0c65fa Binary files /dev/null and b/tools/sim/px_engine.wasm differ diff --git a/tools/sim/px_engine.wasm.sources b/tools/sim/px_engine.wasm.sources new file mode 100644 index 0000000..3c570cd --- /dev/null +++ b/tools/sim/px_engine.wasm.sources @@ -0,0 +1 @@ +600785b08d5c194bfbc6bcba18fc605823d48b1b124d674e62659d4b61e0cbd6 diff --git a/tools/sim/render.mjs b/tools/sim/render.mjs new file mode 100644 index 0000000..309fd5a --- /dev/null +++ b/tools/sim/render.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: GPL-3.0-only +// The bundled loops (assets/loops) through the WebAssembly engine, to WAV files you can listen to: +// node tools/sim/render.mjs OUTDIR [preset 1..52] +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { BLOCK, ENGINE_RATE, GUITAR_LEVEL, PxEngine } from "./px-engine.js"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +export const engineModule = () => new WebAssembly.Module(readFileSync(join(ROOT, "tools/sim/px_engine.wasm"))); + +// a 16-bit PCM WAV -> { rate, channels, samples } (first channel only, floats) +export function readWav(buffer) { + let at = 12, format = null; + while (at + 8 <= buffer.length) { + const id = buffer.toString("latin1", at, at + 4), size = buffer.readUInt32LE(at + 4), body = at + 8; + if (id === "fmt ") format = { pcm: buffer.readUInt16LE(body), channels: buffer.readUInt16LE(body + 2), rate: buffer.readUInt32LE(body + 4), bits: buffer.readUInt16LE(body + 14) }; + if (id === "data") { + if (!format || format.pcm !== 1 || format.bits !== 16) throw new Error("only 16-bit PCM WAV"); + const frames = Math.floor(size / (2 * format.channels)), samples = new Float32Array(frames); + for (let i = 0; i < frames; i++) samples[i] = buffer.readInt16LE(body + i * 2 * format.channels) / 32768; + return { rate: format.rate, channels: format.channels, samples }; + } + at = body + size + (size & 1); + } + throw new Error("no audio in this WAV"); +} +export function wavBytes(samples, rate = ENGINE_RATE) { + const out = Buffer.alloc(44 + samples.length * 2); + out.write("RIFF", 0, "latin1"); out.writeUInt32LE(36 + samples.length * 2, 4); out.write("WAVEfmt ", 8, "latin1"); + out.writeUInt32LE(16, 16); out.writeUInt16LE(1, 20); out.writeUInt16LE(1, 22); out.writeUInt32LE(rate, 24); out.writeUInt32LE(rate * 2, 28); + out.writeUInt16LE(2, 32); out.writeUInt16LE(16, 34); out.write("data", 36, "latin1"); out.writeUInt32LE(samples.length * 2, 40); + samples.forEach((v, i) => out.writeInt16LE(Math.round(Math.max(-1, Math.min(1, v)) * 32767), 44 + i * 2)); + return out; +} +// samples through the engine, block by block; a last short block is padded with silence +export function run(engine, samples) { + const out = new Float32Array(Math.ceil(samples.length / BLOCK) * BLOCK); + for (let at = 0; at < samples.length; at += BLOCK) { + engine.input.fill(0); + engine.input.set(samples.subarray(at, at + BLOCK)); + engine.process(); + out.set(engine.output, at); + } + return out.subarray(0, samples.length); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [outdir, preset = "1"] = process.argv.slice(2); + if (!outdir) { console.error("usage: node tools/sim/render.mjs OUTDIR [preset 1..52]"); process.exit(2); } + mkdirSync(outdir, { recursive: true }); + const module = engineModule(); + const { loops } = JSON.parse(readFileSync(join(ROOT, "assets/loops/manifest.json"))); + for (const loop of loops) { + const { samples } = readWav(readFileSync(join(ROOT, "assets/loops", loop.file))); + const twice = new Float32Array(samples.length * 2); + twice.set(samples); twice.set(samples, samples.length); + const engine = new PxEngine(module); + engine.factory(Number(preset) - 1); + engine.volume(80); + const path = join(outdir, `${basename(loop.file, ".wav")}-preset${preset}.wav`); + writeFileSync(path, wavBytes(run(engine, twice.map((v) => v * GUITAR_LEVEL)))); + console.log(path); + } +} diff --git a/tools/sim/webmidi-shim.js b/tools/sim/webmidi-shim.js new file mode 100644 index 0000000..9cecd3a --- /dev/null +++ b/tools/sim/webmidi-shim.js @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-only +// A virtual Web MIDI for the OpenPocket simulator (tools/sim.mjs): after this script runs, navigator.requestMIDIAccess() +// returns the simulated pedal as one input and one output port, in any browser, with no driver. The installer and editor +// pages served at /site/ get it injected. For another page, run once in its console: +// document.head.append(Object.assign(document.createElement("script"), { src: "http://127.0.0.1:8766/sim/webmidi-shim.js" })) +(() => { + const base = new URL(document.currentScript.src).origin; + const access = { inputs: new Map(), outputs: new Map(), sysexEnabled: true, onstatechange: null, simulator: base }; + let input = null, output = null, queue = Promise.resolve(); + const post = (bytes) => { queue = queue.then(() => fetch(`${base}/api/midi`, { method: "POST", body: Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("") }).catch(() => { })); }; + const mkPort = (type, name) => ({ + id: `op-sim-${type}`, name, manufacturer: "OpenPocket simulator", version: "1", type, state: "connected", connection: "closed", + onstatechange: null, onmidimessage: null, + open() { this.connection = "open"; return Promise.resolve(this); }, + close() { this.connection = "closed"; return Promise.resolve(this); }, + addEventListener() { }, removeEventListener() { }, + }); + const change = (port) => { for (const f of [access.onstatechange, port.onstatechange]) if (f) try { f({ port, target: access }); } catch (e) { console.error(e); } }; + const setPort = (name) => { + for (const p of [input, output]) if (p) { p.state = "disconnected"; p.connection = "closed"; } + const old = [input, output].filter(Boolean); + access.inputs.clear(); access.outputs.clear(); + input = output = null; + if (name) { + input = mkPort("input", name); output = mkPort("output", name); + output.send = (data) => { if (output.state === "disconnected") throw new DOMException("the port is not connected", "InvalidStateError"); post(data); }; + access.inputs.set(input.id, input); access.outputs.set(output.id, output); + } + for (const p of [...old, input, output]) if (p) change(p); + }; + const events = new EventSource(`${base}/api/events`); + events.addEventListener("port", (e) => setPort(e.data)); + events.addEventListener("midi", (e) => { + if (input && input.onmidimessage) input.onmidimessage({ data: Uint8Array.from(e.data.match(/../g).map((h) => parseInt(h, 16))), target: input }); + }); + const first = new Promise((r) => events.addEventListener("port", () => r(), { once: true })); + Object.defineProperty(navigator, "requestMIDIAccess", { configurable: true, value: async () => { await first; return access; } }); +})();