# fox-bench — design

A browser workbench for small amateur-radio fox transmitters built on a
CircuitPython board and a NiceRF SA868 module. The page configures a fox,
writes its firmware and settings over USB, previews what it will transmit
(audio, SSTV image, QRSS waterfall, transmission timeline), and keeps a
log of every board that passes across the bench. Nothing to install;
Chrome or Edge (Web Serial).

This document is the contract between the four parts of the repo. Read it
before changing any of them.

```
  ┌───────────── browser (GitHub Pages) ─────────────┐
  │  js/ui       tabs: Connect · Board & Pins · Radio │
  │              Payload · Schedule · Deploy · Bench  │
  │              Fleet · Console                      │
  │  js/model    fox.json schema, boards, modules,    │
  │              bands, templates, profiles           │
  │  js/audio    the same event streams as foxlib,    │
  │              rendered with Web Audio / canvas     │
  │  js/device   Link → FileTarget → Flasher          │
  └───────────┬───────────────────────────────────────┘
              │ Web Serial (raw REPL)          esptool-js
  ┌───────────┴───────────────────────────────────────┐
  │  board root                                       │
  │    code.py        ← firmware/templates/<id>/      │
  │    fox.json       ← generated by the page         │
  │    foxlib/        ← firmware/foxlib/              │
  │    <payloads>     ← template-declared (images…)   │
  └───────────────────────────────────────────────────┘
```

## 1. Principles

1. **The page is schema-driven.** It knows three kinds of definition file
   (boards, modules, templates) and renders everything from them. Adding
   a board, a radio module or a firmware template is adding files, not
   editing the page.
2. **Firmware is static, configuration is generated.** `code.py` and
   `foxlib/` are committed files that pass CI. The only thing the page
   generates is `fox.json`. Nothing on the board is code the sim has not
   run.
3. **Defaults in the template, overrides in fox.json.** Every template
   ships a complete, valid configuration per board. `fox.json` is merged
   over it field by field with validation; a bad field falls back to its
   default and is named at boot. A broken config can never produce
   broken behaviour, only default behaviour.
4. **Everything the fox transmits is an event stream** of
   `(frequency_hz, duration_ms)`. Morse, melodies, SSTV, QRSS all reduce
   to it. The firmware has one transmitter; the page has one preview
   engine; the sim has one decoder.
5. **The page never special-cases a board.** Transport (`Link`), file
   access (`FileTarget`) and flashing (`Flasher`) are interfaces with
   per-board implementations selected by the board descriptor.
6. **Two independent checks of one contract.** `CONFIG.md` defines
   fox.json. The page validates before writing; `foxlib/cfg.py` validates
   after reading. Neither trusts the other.

## 2. Repository layout

```
index.html                 markup only
css/                       theme, layout, components
js/
  main.js                  app state, tab wiring
  device/
    link.js                Link interface (open/close/read/write)
    serial-link.js         Web Serial implementation
    demo-link.js           simulated board: REPL + fox, for use without hardware
    repl-link.js           raw-REPL protocol over a Link: interrupt, enter raw,
                           exec-and-capture, soft reboot, boot-log capture
    filetarget.js          FileTarget interface (list/read/write/delete/mkdir)
    fs-repl.js             FileTarget over the REPL (boards with no USB drive)
    fs-drive.js            FileTarget over File System Access API (CIRCUITPY) — stub in v1
    (flasher lives in /flasher — separate Apache-2.0 component, §9)
  model/
    schema.js              fox.json: defaults merge, validation, migrations
    boards.js  modules.js  bands.js  templates.js   loaders for the descriptors
    profiles.js            saved configs + fleet log (localStorage, import/export)
    airtime.js             computed keydown per transmission, duty check
  audio/
    events.js              shared event-stream helpers
    morse.js  melody.js  rtttl.js  sstv.js  qrss.js   JS twins of foxlib encoders
    player.js              Web Audio renderer for an event stream
    sstv-decode.js         event stream → canvas (what a hunter's app shows)
    waterfall.js           event stream → spectrogram (QRSS preview)
    schedule.js            LCG twin of foxlib/schedule.py → timeline
  ui/                      one module per tab
boards/      <board_id>.json          see §4
songs/       index.json + libraries   song libraries the Payload tab browses; quick:true = one-click add
modules/     <module_id>.json         see §4
bands.json                            amateur allocations by region
firmware/
  LICENSE                             GPL-3.0-or-later (see §9)
  foxlib/                             shared firmware library
  templates/<id>/
    code.py                           the behaviour file
    template.json                     manifest, see §5
    defaults/<board_id>.json          complete known-good config per board
    scenarios.json                    sim scenarios and expectations
  sim/                                desktop simulator (mocked hardware, virtual clock)
tools/
  batch_flash.py                      headless batch flasher (same CSV as the Fleet tab)
docs/
CONFIG.md                             fox.json schema — the contract
PROTOCOL.md                           REPL exchange, byte by byte
DESIGN.md                             this file
```

## 3. Transport layers

**`Link`** — a byte stream. `serial-link.js` (Web Serial) and
`demo-link.js` (simulated). Nothing above this layer knows which is in
use.

**`ReplLink`** — the CircuitPython raw-REPL protocol on top of a Link.
It is what Thonny does, made explicit:

| Step | Bytes | Purpose |
|---|---|---|
| interrupt | `\x03\x03` | stop the running fox (PTT is released by `foxlib` in a `finally`) |
| enter raw | `\x01` | prompt `raw REPL; CTRL-B to exit\r\n>` |
| exec | code + `\x04` | reply `OK<stdout>\x04<stderr>\x04>` |
| exit raw | `\x02` | back to the friendly REPL |
| soft reboot | `\x04` at the friendly prompt | runs `code.py`; the page captures the boot log |

`exec-and-capture` is the primitive everything else is built on: reading
`fox.json`, writing files in chunks, `board.board_id`,
`microcontroller.cpu.uid`, and the bench commands (§7).

**`FileTarget`** — list/read/write/delete on the board. `fs-repl.js`
implements it with `open().write()` through the REPL, base64-chunked,
with a size-and-hash readback after each file. `fs-drive.js` will use the
File System Access API for boards that mount `CIRCUITPY`, where the REPL
filesystem is read-only; it is a declared interface with a stub in v1 so
nothing above it assumes the REPL path. The board descriptor's
`has_usb_drive` selects the implementation.

**`Flasher`** (`flasher/flasher.js`, its own Apache-2.0 component, §9) —
esptool-js vendored in `flasher/vendor/`, loaded the first time a flash
is requested, with the same version on jsDelivr as a fallback. Works
offline.
Erase, write CircuitPython at the offset the board descriptor gives
(MD5-verified, `flasher/md5.js`), hard-reset, and read chip facts (type,
revision, features, crystal, MAC, flash size) into the fleet log. On
ESP32 the MAC is also `microcontroller.cpu.uid`, so flash rows and deploy
rows for one board share the `uid` key. "Try without hardware" swaps in a
simulated esptool so the whole flow runs in the page tests. Boards with
UF2 bootloaders get a different implementation later; the interface is
the same.

## 4. Descriptors

**`boards/<board_id>.json`** — the microcontroller board.

```json
{
  "id": "seeed_xiao_esp32c3",
  "name": "Seeed Studio XIAO ESP32-C3",
  "circuitpython": { "board_id": "seeed_xiao_esp32c3",
                     "download": "https://circuitpython.org/board/seeed_xiao_esp32c3/" },
  "flash": { "method": "esptool", "chip": "esp32c3", "offset": "0x0",
             "bootloader_instructions": "Hold B while plugging in USB, or hold B and tap R." },
  "has_usb_drive": false,
  "pins": [
    { "name": "D0", "gpio": 2,  "adc": true,  "pwm": true },
    { "name": "D1", "gpio": 3,  "adc": true,  "pwm": true },
    { "name": "D2", "gpio": 4,  "adc": true,  "pwm": true, "aliases": ["A2"] },
    { "name": "D3", "gpio": 5,  "adc": true,  "pwm": true },
    { "name": "D4", "gpio": 6,  "pwm": true },
    { "name": "D5", "gpio": 7,  "pwm": true },
    { "name": "D6", "gpio": 21, "pwm": true, "uart_tx": true },
    { "name": "D7", "gpio": 20, "pwm": true, "uart_rx": true },
    { "name": "D8", "gpio": 8,  "pwm": true },
    { "name": "D9", "gpio": 9,  "pwm": true, "note": "BOOT button; avoid" },
    { "name": "D10","gpio": 10, "pwm": true }
  ],
  "roles": {
    "ptt": "D3", "pd": "D4", "hl": "D5", "uart_tx": "D6", "uart_rx": "D7",
    "audio": "D1", "led": "D10", "batt_sense": null
  },
  "battery": { "charger": true, "divider_note": "BAT+ → 200k → ADC → 200k → GND; A2 on the HRV layout, A0 per the Seeed wiki" },
  "silkscreen": "boards/seeed_xiao_esp32c3.svg"
}
```

`roles` is the board's default pin map; a template's `defaults/` may
override it (a template for a different carrier PCB), and `fox.json`
overrides both. Role requirements per pin: `audio` must be `pwm`,
`batt_sense` must be `adc` or `null`, `uart_tx`/`uart_rx` must carry the
matching flag, no two roles share a pin.

**`modules/<module_id>.json`** — the radio module. Separate from the
board because the board does not know what is soldered next to it.

```json
{
  "id": "sa868_vhf",
  "name": "NiceRF SA868 (VHF)",
  "family": "dmo",
  "model": "SA868",
  "ranges_mhz": [[134.0, 174.0]],
  "step_khz": [12.5, 25.0],
  "power_dbm": { "low": 25, "high": 33 },
  "uart": { "baud": 9600 },
  "notes": "Module answers AT+DMOSETGROUP with :1 for a frequency outside its synthesizer range. The page's Probe button uses this to identify the fitted band."
}
```

`family` names the control protocol, not the part: `dmo` is the NiceRF
AT dialect shared by the SA818, SA818S, SA868, SA868S and DRA818, which
differ only in band and power (descriptor data). `sa868_uhf`, `sa868_dual`,
`sa818_vhf`, `sa818_uhf` ship alongside. The user picks the module or runs
**Probe** (§7). A module with a different protocol adds a descriptor with a
new `family` and a driver class in `foxlib/radio.py`; nothing else changes.

**`bands.json`** — amateur allocations by region, used only for
validation and explanation:

```json
{ "US": { "2m": [144.0, 148.0], "70cm": [420.0, 450.0] },
  "EU": { "2m": [144.0, 146.0], "70cm": [430.0, 440.0] } }
```

A frequency is accepted when it lies inside a module range **and** inside
a band for the chosen region. The page says which test failed.

## 5. Templates and foxlib

Six foxes were written before this repo; lined up, they share roughly 80%
of their code and differ along three independent axes:

| Axis | Values | fox.json section |
|---|---|---|
| payload — what follows the ID | `songs` · `sstv` · `none` (QRSS is Morse at a different speed) · reserved: `wifi-beacon` | `payload` |
| schedule — when the next cycle starts | `fixed` · `popcorn` · `resurrection` | `schedule` |
| plan — where | one frequency · rotating list, with or without a `QSY` announcement | `plan` |

`foxlib` implements all three axes once. A template is a short `code.py`
that chooses from them, plus a manifest and per-board defaults.

```
firmware/foxlib/
  __init__.py     Fox: loads config, builds radio/battery/led, exposes cycles()
  cfg.py          defaults → fox.json override → validation → boot banner
  radio.py        module drivers by family: `dmo` (SA818/SA818S/SA868/SA868S/DRA818 AT
                  dialect); parses replies (DMOSETGROUP :0/:1, RSSI?, VERSION). New
                  module protocol = new class + descriptor, nothing else changes
  tones.py        one PWM transmitter: play(events), deadline-scheduled
  morse.py        text → events; table + encode(); WPM/tone/dit override (QRSS)
  melody.py       song dict → events; articulation; playlist rotation
  sstv.py         image → events (Robot 36 grayscale); test card
  battery.py      divider monitor, averaging, calibration, telemetry format
  schedule.py     seeded LCG + fixed/popcorn/resurrection schedulers
  led.py          patterns: alive · low · critical · unconfigured · keydown-limit
  cycle.py        the per-cycle prologue every fox repeats (see below)
  VERSION         single line, e.g. 1.0.0
```

`cycle.py` owns what every one of the six main loops did identically:
read battery → critical lockout with hysteresis → LED pattern → assemble
the ID (with optional `BAT 3V4` telemetry) → key PTT inside `try/finally`
→ hand control to the template → unkey → let the schedule decide the wait.
A template body is therefore only what is distinctive about that fox:

```python
# templates/morse-songs/code.py
import foxlib
fox = foxlib.Fox("fox.json")
for tx in fox.cycles():
    tx.morse(tx.id_message)
    if not tx.low_battery:
        tx.play(fox.playlist.next_events())
```

Templates in v1:

| id | payload | schedule | plan | notes |
|---|---|---|---|---|
| `morse-songs` | songs | fixed | single | the baseline fox |
| `morse-sstv` | sstv | fixed | single | ~37 s keydown per image |
| `morse-qrss` | none, slow Morse | fixed | single | multi-minute keydown by design |
| `popcorn` | songs | popcorn | single | seeded clusters and dark periods |
| `freq-rotator` | songs | fixed | rotating | optional `QSY` courtesy announcement |
| `resurrection` | songs | resurrection | single | dead-phase dit now goes through the Morse path |
| `composite` | any | any | any | **experimental** — reads all three axes from fox.json |

**`template.json`**:

```json
{
  "id": "morse-sstv",
  "name": "Morse ID + SSTV image",
  "version": "1.0.0",
  "status": "stable",
  "foxlib": ">=1.0.0 <2.0.0",
  "sections": ["id", "radio", "plan", "payload.sstv", "schedule.fixed", "battery", "limits"],
  "pins": { "required": ["ptt", "pd", "hl", "uart_tx", "uart_rx", "audio"],
            "optional": ["led", "batt_sense"] },
  "payloads": [ { "file": "images.bin", "kind": "sstv-images" } ],
  "capabilities": ["keytest", "rssi", "batt"],
  "typical_keydown_s": 40,
  "boards_tested": ["seeed_xiao_esp32c3"]
}
```

`sections` drives the form: the page shows only the sections a template
reads. `capabilities` gates the bench tabs. `payloads` lists files
beyond `fox.json` that the page generates (images for SSTV). The
composite template lists every section and `"status": "experimental"`,
which the page shows as a badge.

**Versioning.** `foxlib/VERSION` is read off the board before deploy;
only files whose version differs are rewritten. A template whose
`foxlib` range does not match the library in the repo is refused rather
than half-written.

## 6. Configuration

`fox.json` is specified in `CONFIG.md`. Summary of the rules:

- Written in full by the page: a fox's file tells you everything about it.
- Merged over the template's `defaults/<board_id>.json` by `foxlib/cfg.py`.
  Field-level validation; failures fall back to the default and are
  named in the boot banner.
- `schema` integer for migrations. `fleet` block is bookkeeping the
  firmware ignores.
- `id.tx_without_callsign` defaults to `false`: a fox whose callmessage
  still contains the placeholder boots, prints `id=unset`, never keys PTT,
  and shows the `unconfigured` LED pattern. The page refuses to deploy
  such a config regardless.
- `limits.max_keydown_s` is `null` by default. No template imposes a
  keydown limit of its own; the QRSS fox keys for as long as its message
  takes. When the user sets a limit in the UI, the firmware unkeys at
  that point and logs it, and the page shows computed airtime per
  transmission against the limit.

**Boot banner** — one machine-readable line, parsed by the page after
every reboot:

```
FOX template=morse-songs/1.0.0 foxlib=1.0.0 board=seeed_xiao_esp32c3 uid=3c2a… cfg=ok radio=ok id=set freq=146.565
FOX template=composite/0.3.0 … cfg=partial(radio.freq_mhz,pins.ptt) radio=err(DMOSETGROUP:1) id=unset
```

## 7. Tabs

| Tab | Purpose |
|---|---|
| **Connect** | Pick port (or Demo). Detect board (`board.board_id`), read `foxlib/VERSION`, `fox.json`, boot banner. Shows what is on the board before anything is changed. |
| **Board & Pins** | Board dropdown; role → pin table with the board's defaults; validation per §4; silkscreen SVG highlighting chosen pins. |
| **Radio** | Module dropdown and **Probe** (retunes in receive at 146.5 and 433.5 and reports which the module accepted — nothing is transmitted). Region. Power high/low. Bandwidth, squelch, volume with the receive-side note. |
| **Plan** | Frequencies: one, or a rotating list with the `QSY` option. Each entry validated against module ∩ band. |
| **Payload** | Depends on template. Songs: built-ins, RTTTL paste, note editor, playlist order. SSTV: drop a PNG or type a card → 40×240 grayscale → decoded preview on canvas. QRSS: dit length, waterfall preview. **Preview** plays the full transmission (ID then payload) through Web Audio from the same event stream the fox will use. |
| **Schedule** | Fixed / popcorn / resurrection parameters. **Timeline**: the LCG run from the seed, drawn across the day, so an organizer sees every cluster and phase change in advance. |
| **Deploy** | Diff between board and form; airtime per transmission vs `limits`; "antenna or dummy load attached?" gate; writes `fox.json`, payloads, and any out-of-date firmware files (size + CRC32 verified); reboots; parses the banner; pass/fail. Export the same set as a zip. |
| **Bench** | Capability-gated: **Key test** (key for N seconds, confirm `radio=ok`), **RSSI** (module receiver on the fox frequency), **Battery** (live voltage when `batt_sense` is set). Every action is appended to a timestamped event log that any external test procedure can line up against. |
| **Fleet** | Profiles (save/load/export a config set) and the fleet log: board UID, MAC, chip info, which profile went on when. Batch flashing: plug → click → erase → flash CircuitPython → log → next, same CSV columns as `tools/batch_flash.py`. (The single-board **Flash CircuitPython** card, with the image source and options, is on the Board tab next to the bootloader instructions.) |
| **Console** | Raw exchange, history, log download. |

Bench actions run through `exec-and-capture` after the fox is interrupted.
CircuitPython starts a fresh VM for the REPL, so `code.py`'s objects are
gone; the page builds a new `foxlib.Fox(..., quiet=True)` (no banner, nothing
keyed) and calls `fox.radio.probe()`, `fox.radio.key_for(3)`,
`fox.radio.rssi()`, `fox.batt.read()`. The firmware's functions are the
bench API; no second protocol.

## 8. Simulator and CI

`firmware/sim/` is the desktop harness from the earlier foxes, generalised:
mocked `board`/`busio`/`digitalio`/`pwmio`/`analogio`, a virtual clock,
and decoders for the event stream (Morse back to text, SSTV back to
pixels, schedule back to a timeline). Each template's `scenarios.json`
lists battery schedules and expected behaviour. CI runs every template
through every scenario on every push, checks that each template's deploy
set contains exactly the manifest's files, and checks that the JS
encoders in `js/audio/` and the Python encoders in `foxlib/` produce
identical event streams for a fixed corpus.

## 9. Licensing

- Repository root: **GPL-2.0** — the page (`js/`, `css/`, `index.html`),
  `tools/`, docs, descriptors.
- `flasher/`: **Apache-2.0** (`flasher/LICENSE`, `flasher/NOTICE`). A
  separate component that bundles Espressif's esptool-js (Apache-2.0,
  which cannot ship inside a GPL-2.0-only tree). The page imports it as
  an ES module across that boundary; it depends on nothing outside its
  directory. Treated as its own entity for now; the split may be
  revisited when licensing is streamlined.
- `firmware/`: **GPL-3.0-or-later** (`firmware/LICENSE`). The template
  runners descend from earlier GPL-3.0 fox firmware and keep their
  lineage notices. Independent modules (`morse.py`, `sstv.py`,
  `battery.py`, `schedule.py`, the simulator) carry the author's own
  copyright under the same licence.
- Every file carries an `SPDX-License-Identifier` line.

The page ships the firmware as data it writes to a board; it does not
link against it. The two halves are an aggregation, not one program.

## 10. Device families

Everything above describes the **foxlib family**: a CircuitPython board,
a `dmo`-dialect radio module, files written through the REPL, behaviour
from `foxlib`. Some foxes are built differently and still belong on the
bench. A *device family* is the unit that varies:

| | foxlib (today) | drive-configured (planned) |
|---|---|---|
| example | XIAO ESP32-C3 + SA868 | RP2040 + SI5351 designs such as the PicoFox |
| firmware | ours, deployed by the page | the vendor's, left alone |
| transport | `fs-repl.js` (raw REPL) | `fs-drive.js` (File System Access API on the USB drive) |
| config contract | `CONFIG.md` → `fox.json` | the vendor's file, e.g. `settings.txt` keys |
| payload | event stream played by `foxlib/tones.py` | an audio file the page **renders from the same event stream** (Morse ID + song → PCM WAV) |
| bench | `exec-and-capture` | none (no REPL); timeline and airtime still apply |

The board descriptor names its family; the page shows the tabs and
sections that family has a meaning for. Rendering the audio file is the
same preview code the Payload tab already runs, written to a buffer
instead of the speakers.

## 11. Out of scope for v1, kept possible

- `fs-drive.js` for boards that mount `CIRCUITPY` (ESP32-S2/S3, SAMD).
- UF2 flashing.
- `wifi-beacon` payload (ESP32 Wi-Fi SSID as the fox, 2.4 GHz, no SA868).
- The drive-configured device family (§10).
- Other radio module families (`modules/` descriptor + `foxlib` driver).
- Correlating the bench event log with external instruments; the log
  format is stable for that purpose, nothing else is assumed.
