Parallax Propeller2 USB driver - the MashUp

P2 USB Full-Speed Host with Filesystems (HID / CDC-ACM / MSC / UAC1 audio + exFAT / FAT32)
Login

P2 USB Full-Speed Host with Filesystems (HID / CDC-ACM / MSC / UAC1 audio + exFAT / FAT32)

A USB 1.1 full-speed host for the Parallax Propeller 2 that runs in one cog, with compile-time-selectable class drivers (HID keyboard/mouse, serial — CDC-ACM plus FTDI/CP210x/PL2303 USB-serial bridges, MSC mass storage, USB Audio Class 1 playback + capture over isochronous endpoints) and two filesystems layered over MSC: a greenfield exFAT driver (read/write, subdirectories) and an adapted FAT32 driver. Supports devices attached directly or behind a USB hub, including multiple devices at once.

⚠ Project status: living code, no promises. This project is in active flux and changing rapidly, with no fixed roadmap and no declared end state — my own instruments are the roadmap. I (Shannon Mackey) use this driver extensively in real projects and continue to flush out issues on real hardware; interfaces move, behavior gets re-derived whenever the silicon disagrees with the documentation, and anything here may change without notice. Extensive regression harnesses live in harness/ and everything claimed below was validated on the bench when written — but I make no claims and offer no warranties of any kind: the MIT license's AS-IS terms (LICENSE) are the entirety of the promise. If it breaks on your bench, a field report with reproduction steps (see docs/feedback/) is the most valuable thing you can send.

Everything documented here was validated on real hardware; see Validation status.


Hardware requirements

Requirement Value
System clock Keep above 180 MHz. Characterized full-stack floor is 165 MHz (160 MHz is marginal - real media start failing bring-up intermittently; ≤ 150 MHz fails). The TX path self-adjusts its smart-pin gap around a ~168 MHz breakpoint; 180 MHz gives margin over both. No runtime check. Developed at 200 MHz; characterized 165-300 MHz.
USB data pins D− and D+ on an adjacent even/odd pin pair, D− on the even pin (P_USB_PAIR smart-pin requirement).
VBUS 5 V to the device/hub. An active-high enable pin (load switch) is supported but optional.
Devices USB 1.1 full-speed (12 Mbit/s). Low-speed devices are reached through a hub's transaction translation only if the hub presents them as FS; not otherwise supported. An LS leaf behind a hub is now named explicitly in the log (leaf is LOW-SPEED, ticket 1210) instead of failing silently; LS host support is tracked as ticket 1190 (design: docs/design/low-speed-host.md).

The P2 Eval "USB serial host" accessory header maps as: base+0 = LED, base+1 = EN (VBUS enable), base+2 = D−, base+3 = D+.

Architecture

 top-level object (your code)
        │
   usb_app.spin2 ───────────── facade: open/close, class binding, kbd_* /
        │                      ser_* / disk_* APIs, fs_* filesystem facade
        │                      (format dispatch + exFAT/FAT32 auto-detect)
        ├── usb_hub_host.spin2  hub enumeration: addresses, port reset,
        │                      config-descriptor parsing for class endpoints;
        │                      one level of NESTED hub (1205) - combo
        │                      hub+reader units enumerate their subtree
        ├── usb_class_hid/cdc/msc.spin2   class protocol (reports, ACM, BOT/SCSI)
        ├── usb_class_ftdi.spin2   FTDI USB-serial bridges (FT232R/FT-X plus
        │                      H-series FT2232H/FT4232H/FT232H, multi-channel
        │                      aware); shares the ser_* API
        ├── usb_class_pl2303.spin2  Prolific PL2303 bridges (HX/HXD/TA/TB);
        │                      same ser_* API
        ├── usb_class_cp210x.spin2  SiLabs CP210x bridges; same ser_* API
        ├── usb_class_uac.spin2  USB Audio Class 1: descriptor parse (speaker
        │                      + mic ISO endpoints), SET_CUR rate, Feature-Unit
        │                      volume/mute; streaming itself is the PHY cog
        │                      servicing hub-RAM rings on every SOF (aud_* API)
        ├── usb_hid_report.spin2  HID report-descriptor parser (2150): turns
        │                      any gamepad/joystick's descriptor into a
        │                      control map at bind time - axes, hats, button
        │                      ranges with offsets/ranges (joy_* API)
        ├── usb_class_midi.spin2  USB-MIDI 1.0 host (2140): 32-bit event
        │                      packets over bulk endpoints, poll-once IN,
        │                      pack/decode helpers (midi_* API)
        ├── usb_ser_expect.spin2   expect-style scripted serial exchange
        │                      (send/expect with glob patterns + timeouts,
        │                      tokenizer) for instruments; binds any transport
        │                      via method pointers
        ├── usb_fat32_fs.spin2  FAT32 (adapted from IronSheep P2 SD driver, MIT);
        │                      cogless since 2088, 6 file handles
        ├── usb_fs_job.spin2   OPT-IN async job service: whole file operations
        │                      (write/copy/...) on one worker cog with
        │                      progress/cancel while your loop keeps running
        ├── usb_exfat_fs.spin2  exFAT (written for this project); no extra cog,
        │                      4 file handles
        └── usb_host.spin2 ──── the ONE USB cog: PHY (P_USB_PAIR smart pins),
                               bus reset, SOF, control/bulk/interrupt
                               transfers, CRC5/16, error recovery.
                               Commanded through a lock-guarded hub-RAM mailbox.

Key structural facts:

Resource usage

Resource Amount
Cogs 1 (USB host — including all isochronous audio streaming, which the PHY cog services on every SOF). Both filesystems are cogless (FAT32's legacy worker retired in 2088; -D SD_USE_WORKER_COG restores it). The optional async job service (usb_fs_job) adds 1 while started; a dedicated DSP cog for audio is the recommended application pattern, not a driver cost.
Locks 1 (host mailbox) + 1 per filesystem driver in use. Allocated with LOCKNEW, returned on stop. Audio ring operations are lock-free by design.
Hub RAM, host UH_TOTAL_BYTES (from usb_hub_ram.spin2, layout v7) for the mailbox and transfer buffers: 12,112 bytes with HID+MSC+CDC; USB_AUDIO adds 6,208 (ISO OUT ring 4 KB + 8×256 B IN slots + control) for 18,320 all-on; shrinks when classes are compiled out.
Hub RAM, exFAT ≈ 2.5 KB (three 512-byte sector caches, 608-byte entry-set buffer, handle table).
Hub RAM, FAT32 ≈ 5 KB (sector buffers, handle table, 1 KB worker-cog stack).
Flash/RAM, code See Memory footprint by build — 19 KB (host core alone) to 78 KB (everything), measured.
Smart pins The D−/D+ pair. EN/LED are plain pins.

Memory footprint by build

Total hub RAM occupied while running — code + all DAT (PHY engine, hub-RAM workspace, filesystem sector buffers) plus VAR — measured with pnut-ts -m (memory-map output) on a minimal one-object top (harness/top_footprint.spin2; pnut-ts compiles every method of an instantiated object, so nothing is stripped and these are honest maxima). The P2 has 512 KB of hub RAM; even the everything-enabled build uses ~16 %.

Build gates Code+DAT VAR Total running
(none — host core, enumeration, hot-plug only) 15,832 3,644 19,476
USB_HID 17,212 4,956 22,168
USB_HID + USB_JOY (gamepad parse + mapping) 19,484 5,352 24,836
USB_CDC (incl. FTDI/CP210x/PL2303 bridges) 19,060 6,804 25,864
USB_MSC 17,672 12,160 29,832
USB_AUDIO 17,628 10,156 27,784
USB_MIDI 16,532 3,948 20,480
USB_HID + USB_CDC + USB_MSC 22,328 16,632 38,960
USB_MSC + USB_EXFAT 27,304 12,168 39,472
USB_MSC + USB_FAT32 39,492 12,168 51,660
USB_MSC + USB_EXFAT + USB_FAT32 48,780 12,172 60,952
USB_HID + USB_CDC + USB_MSC + USB_EXFAT 31,952 16,640 48,592
USB_HID + USB_CDC + USB_MSC + USB_AUDIO 24,160 23,144 47,304
...+ USB_EXFAT 33,784 23,152 56,936
Everything (all 6 classes + both filesystems) 58,240 23,856 82,096

Bytes, pnut-ts v1.55.0, 2026-08-12 (after 2140 MIDI / 2150 gamepad / root hot-plug additions). Not included: your application code and stacks; the opt-in usb_ser_expect (≈ 2 KB) and usb_fs_job (≈ 3 KB incl. its 400-long worker stack) objects, which cost nothing unless instantiated. Compiling with -d adds only ~0.4–0.6 KB of code, but at runtime DEBUG reserves the top 16 KB of hub RAM for the debugger — budget for it in debug builds. Reproduce any row:

pnut-ts -q -m -I src -D USB_MSC -D USB_EXFAT harness/top_footprint.spin2
grep "Total Size" harness/top_footprint.map

Measured throughput through a hub (whole-transfer engine, ticket 1170 — the PHY cog loops bulk packets in-cog instead of interpreted Spin): ≈ 810–890 KB/s on multi-block raw reads, ≈ 940 KB/s raw writes (300 MHz), ≈ 600–645 KB/s through the exFAT API with 16 KB calls — ~65–75% of the full-speed bulk ceiling, ~14x the pre-1170 figures. Single-block latency ≈ 1 ms. Creating a directory (one cluster zero-fill on 128 KB-cluster media) ≈ 0.3 s. Full measured profile: docs/perf-1tb-ssd.md.

Building with pnut-ts

Class drivers and filesystems are selected with -D gates at compile time; code for disabled gates is not compiled in.

Gate Enables Requires
USB_HID keyboard/mouse APIs (kbd_*, mouse_*)
USB_JOY gamepad/joystick (joy_*): report-descriptor parse, control introspection, mapping wizard primitive (awaiting hardware validation, ticket 2150) USB_HID
USB_CDC serial APIs (ser_*): CDC-ACM gadgets and FTDI / CP210x / PL2303 USB-serial bridges, plus the expect layer
USB_MSC mass-storage APIs (disk_*)
USB_AUDIO UAC1 audio (aud_*): isochronous playback + capture, volume/mute
USB_MIDI USB-MIDI 1.0 host (midi_*): event read/write + decode helpers (awaiting hardware validation, ticket 2140)
USB_EXFAT exFAT filesystem (fs_*) — the primary/default filesystem USB_MSC
USB_FAT32 FAT32 filesystem (fs_*) USB_MSC

A filesystem gate without USB_MSC fails the build deliberately, with an undefined symbol whose name states the constraint (pnut-ts has no #error directive).

# typical filesystem build (exFAT only)
pnut-ts -d -I <path-to>/src -D USB_MSC -D USB_EXFAT your_top.spin2

# everything, with FAT32 fallback auto-detect
pnut-ts -d -I <path-to>/src -D USB_HID -D USB_CDC -D USB_MSC -D USB_EXFAT -D USB_FAT32 your_top.spin2

# load and watch debug() output
pnut-term-ts --headless -r your_top.bin -p <PORT> -b 2000000 --end-marker "YOUR_MARKER"

-d compiles the debug() statements in; omit it for a silent build. Your top file must re-export the gates to child objects:

#IFDEF USB_MSC
#PRAGMA EXPORTDEF USB_MSC
#ENDIF
' ...one block per gate you use

Do not default the gates on in your top file

A consumer of this driver lost a day to the obvious-looking version of that block:

' WRONG - this defeats every gate you have
#IFNDEF USB_MSC
#DEFINE USB_MSC
#ENDIF
#IFDEF USB_MSC
#PRAGMA EXPORTDEF USB_MSC
#ENDIF

The #IFNDEF/#DEFINE turns a gate the build left off back on, and the EXPORTDEF then pushes it down here — so -D selection silently does nothing and every combination produces a byte-identical image. It reads like a helpful default, and it is invisible: the build succeeds, the driver works, and the footprint never moves.

Two things make it hard to spot. It is easy to write the same block in more than one file, so removing it from one changes nothing and looks like proof that the gates are broken. And the symptom — a switch that has no effect — points at the driver rather than at the top file.

Export the gate, never define it. Let -D be the only source of truth, and check with a byte comparison rather than by eye:

pnut-ts -q -I src -D USB_MSC -D USB_HID your_top.spin2 && md5sum your_top.bin
pnut-ts -q -I src -D USB_MSC          your_top.spin2 && md5sum your_top.bin
# the two MUST differ

Integration: bring-up

One call does it (facade v2, ticket 0180):

OBJ
  usb : "usb_app"

PUB main()
  ifnot usb.begin(16)               ' serial-host header base pin
    debug(zstr_(usb.err_str()))
    return
  if usb.await_drive(15_000)        ' storage there? (samples hub ports while waiting)
    ...usb.fs_open_read(...)        ' filesystems mount on first use
  if usb.await_serial(3000)
    usb.ser_open(38400)             ' CDC-ACM or any vendor bridge - same API
  if usb.kbd_ready()
    ...usb.kbd_char()

begin() starts the host cog, runs a sampled reset→probe ladder (the bus answering GET_DESCRIPTOR at address 0 is the readiness signal — no fixed power dwells or boot allowances), keeps SOF running for the session, enumerates the whole bus, and binds every compiled class: storage, serial (first vendor bridge — FTDI, CP210x or PL2303 — if any, else first CDC-ACM), keyboard and mouse by HID boot protocol. await_drive/serial/kbd(ms) sample hub port status while waiting, so devices that assert connect seconds after power (SSD bridges: 6–9 s measured) still arrive; rescan() does one non-blocking pass for idle loops. err_str() explains failures in words.

Hot-plug (insert / remove / rearrange) is first-class: put usb.poll() in your idle loop (internally rate-limited — free between samples) and optionally bind usb.on_change(@handler) to be told about every EV_ATTACH/EV_DETACH after the driver has reacted — bindings dropped on removal (a mounted filesystem is abandoned with zero I/O to the dead device), new devices enumerated and bound on arrival. Coverage includes the root device of each bus (2026-08-12 field report): a yanked hub tears down its whole subtree, and a re-inserted root — hub or direct device — is recovered by poll() alone, without begin() and without touching the other bus. The full usage guide — handler rules, per-class detach/re-attach semantics, waiting styles, troubleshooting — is docs/hotplug.md; harness/top_hotplug.spin2 validates the whole cycle live, including a programmatic port-power detach of the mounted SSD and remount after restore; harness/top_rootplug.spin2 validates root death/recovery via EN-gated hub power.

Getting started, per class

One clean, copy-paste guide per feature — what to buy, the build line, the smallest complete program (every one compile-verified verbatim), what bring-up actually does step by step, and a troubleshooting table:

Guide You get Status
Gamepad / joystick any pad's buttons/sticks/hats discovered by descriptor, plus the press-the-control-you-want mapping wizard awaiting hw (2150)
USB-MIDI note/CC/bend events from any class-compliant controller, decoded; optional synth awaiting hw (2140)
Audio 48/44.1 kHz playback + microphone through a $10 UAC1 adapter, fed from a ring the USB cog services itself validated
Keyboard & mouse boot-protocol keys/ASCII and mouse deltas, composite receivers included validated
Serial one API over FTDI/PL2303/CP210x/CDC + the expect engine for instruments validated
Storage & files files on exFAT/FAT32 media, mount-on-first-use, hot-plug-safe validated
Recovering a wedged drive telling a hub-disabled port from a wedged device, the reset/re-bind ladder, opt-in auto-recovery, and proving a drive really came back validated

All of them share the same skeleton: usb.begin(16) → an await/poll for the class → use it → usb.poll() in the idle loop for hot-plug.

How bytes actually move — the buffer inventory, the consumer-pull model, and where backpressure is absorbed at every layer — is documented in docs/dataflow.md.

Every timing constant behind this — what it paces, its spec citation or measurement, and the former blind waits it replaced — is tabulated in the timing constants ledger of docs/api/usb-app-api.md, the full API manual. The pre-v2 manual sequence (open() / probe loops / parse_classes / bind_* / disk_open / fs_mount) remains available as the advanced layer for exotic topologies and is documented there too.

Filesystem API (usb_app facade)

Call Notes
fs_mount() : st 0 on success. Auto-detects format.
fs_unmount() : st Flushes and clears the volume-dirty flag.
fs_kind() : k 0 none, 1 FAT32, 2 exFAT.
fs_vol_label() : p Zero-terminated string pointer.
fs_open_read(path) : h fs_open_write(path) : h fs_create(path) : h Handle ≥ 0 or negative error. fs_open_write positions at end (append); seek to overwrite. fs_create errors if the file exists.
fs_read(h, buf, n) : n fs_write(h, buf, n) : n Bytes moved (0 at EOF), negative on error.
fs_seek(h, pos) : st fs_file_size(h) : n Seeking past EOF is refused (−115) on both filesystems — enforced at the facade (6170).
fs_close(h) : st Flushes directory metadata for written files.
fs_delete(path) : st Files, or empty directories (else −96).
fs_mkdir(path) : st Parent must exist.
fs_rename(old, new) : st Rename in place on both filesystems; the final component of new becomes the name in old's directory.
fs_move(path, destdir) : st Move a file (or directory) into another directory, both filesystems.
fs_max_handles() : n Concurrent open handles (files + directory iterations share the pool). Both drivers: 6.
drive_bind_second(da) : ok fs_mount_second_exfat() : st fs_unmount_second() Two volumes at once (6140): bind a second MSC drive and mount the exFAT driver on it while the facade keeps serving its primary (typically FAT32) volume. Drive the second volume through the exFAT driver object (it's a DAT singleton). One FAT32 + one exFAT is the supported pair; a BOT-operation lock makes two-cog I/O to both volumes safe.
fs_sync() : st Flush all open write handles.
fs_dir_open(path) : h fs_dir_next(h) : p fs_dir_close(h) Iterate a directory; after each fs_dir_next, read fs_entry_name()/fs_entry_size()/fs_entry_attr() (attr bit 4 = directory).
fs_time_source(mp) Bind an RTC; see below.

Paths use / separators on both filesystems, absolute or relative to the per-cog working directory (fs_chdir; root after mount). On exFAT the cwd lives at the facade (the driver resolves full paths); on FAT32 it is the adapted driver's own per-cog cwd, with 8.3 naming.

Serial instruments: the expect layer

Most lab instruments (mass-flow controllers, meters, pumps) speak short line-oriented ASCII over a USB-serial cable — and most of those cables are vendor-bridge silicon (FTDI, Prolific PL2303, SiLabs CP210x), not CDC-ACM. This stack handles all of them behind one API, and usb_ser_expect gives you tcl-expect-style scripted exchanges on top:

OBJ
  usb : "usb_app"
  exp : "usb_ser_expect"

  usb.begin(16)
  usb.await_serial(3000)                                ' any bridge or CDC - bound either way
  usb.ser_open(38400)
  exp.bind(@ser_wr, @ser_rd)                            ' one-line shims (manual §4)
  exp.bind_baud(@ser_baud)
  if exp.autobaud(string("A"), string("A*"), 0, 0)      ' condition-driven rate lock
    if exp.chat(string("A"), string("A*"), 0)           ' 0 = default timeout
      flow_milli := exp.token_milli(2)                  ' "+0.512" -> 512
      gas        := exp.token(6)                        ' "Air"

The engine is generic — it knows lines, glob patterns, timeouts and tokens; your application supplies the commands, so SCPI instruments (set_delims(string(",")), LF terminators), AT modems (expect_any over OK/ERROR* branches), and Alicat controllers all script the same way. Verbs: sendline / readline / expect / expect_any / chat / drain() (ends on a sampled quiet line, never a fixed delay) / autobaud (retries only on evidence of garbled traffic, never a blind repeat). Dissection: ntokens(), token(i), is_num(i), token_int(i), token_milli(i) (fixed-point → integer milli-units; no floats), unparsed() for partial replies in error reports. Full contract, dialect knobs and worked SCPI/AT shapes: docs/api/usb-app-api.md.

harness/top_alicat_expect.spin2 is the worked example, validated against an Alicat BASIS mass-flow controller: device discovery (FTDI vs CDC), autobaud probing, frame tokenization, a multi-line info dump, and a setpoint write/readback/zero cycle that identifies the setpoint column by diffing frames rather than trusting a hardcoded layout. top_serial_discover.spin2 dumps every device's interfaces/endpoints when you need to identify a new cable.

Bridge specifics handled for you — FTDI (usb_class_ftdi.spin2): vendor bring-up (reset/purge, 8N1, no flow control, DTR/RTS), divisor baud encoding with chip-generation detect from bcdDevice (48 MHz-base classic parts, 120 MHz-base H-series), 4 ms latency timer, the 2 status bytes FTDI prefixes to every bulk-IN packet, and multi-channel parts (FT2232C/H, FT4232H): channel A auto-binds, bind_ftdi_ch() reaches B..D. Prolific PL2303 (usb_class_pl2303.spin2): chip-type detect (HX/HXD/TA/TB, legacy type H, HXN degrade), the undocumented vendor init dance, literal-baud line coding with ser_device_baud() read-back, DTR/RTS. SiLabs CP210x (usb_class_cp210x.spin2): IFC_ENABLE bring-up, literal 32-bit baud with CP2101 divisor fallback, DTR/RTS, FIFO purge. Validated live: FT232R/FT-X, FT4232H (all 4 channels), PL2303HXD, PL2303TA.

The RTC time-source contract

Without a time source, files are stamped with a fixed build-time constant. To get real timestamps, bind a method pointer before writing files:

app.fs_time_source(@rtc_now)          ' from your top object

PRI rtc_now() : ts
  ' read your RTC here, then pack:
  ts := ((year - 1980) << 25) | (month << 21) | (day << 16) | (hour << 11) | (minute << 5) | (second >> 1)

Requirements on the method you provide:

Limits

Filesystem-independent:

Audio (USB_AUDIO, UAC1):

exFAT driver:

FAT32 driver (adapted third-party code):

Flash wear policy (both filesystems)

Both drivers allocate next-fit: a forward-marching hint that wraps at the end of the volume and is never rolled back on delete, so the whole volume cycles through before any freed cluster is rewritten — spreading erase load instead of hammering the low clusters under delete/create workloads (a rotating log is exactly that workload). Hardened 2026-08-12 after an SSD wear scare:

Managed media (SSD bridges, SD cards) also wear-level internally in their controllers; this policy keeps the filesystem from concentrating logical rewrites on top of that, and is what protects raw or cheaply-managed flash.

Validated live 2026-08-13 (harness/top_wearpolicy.spin2, 64 GB exFAT card): write 2 clusters → delete → write again — the new file took the next cluster (7), not the just-freed ones (5,6), and the remount scan re-derived the allocation front (resume-at-8) across sessions. FAT32 side validated the same day via the full acceptance suite.

Error codes (exFAT / facade)

Code Meaning
−80 not mounted / no filesystem owns the mount
−81 media is not exFAT (auto-detect falls through to FAT32 on this)
−82 unsupported geometry (sector size ≠ 512)
−83 boot-region checksum mismatch
−84 block-device I/O error
−85 not found
−86 out of handles
−87 bad handle
−88 broken cluster chain
−89 no block device bound
−92 already exists (fs_create, fs_mkdir)
−93 no free cluster (disk full)
−94 no room in directory
−95 empty or over-long name
−96 directory not empty (fs_delete)
−100 fs_mount called before disk_open succeeded
−110 not a file
−111 not a directory
−112 file is in use by an open handle
−113 file already open for writing
−114 not a valid handle of that kind (e.g. file op on a dir handle)
−115 invalid parameter
−116 end of file
−117 no contiguous space
−118 media is not the filesystem asked for
−119 a driver code with no facade meaning
−120 operation not supported by the mounted filesystem's driver (currently unreachable — kept for future operations)

The table above is the facade contract on both filesystems: exFAT's driver codes are these values natively, and FAT32's raw codes (0 to −64 range in usb_fat32_fs.spin2) are translated by the facade. The −110..−120 block covers conditions FAT32 can report that the original exFAT-derived table had no word for, placed outside both drivers' raw ranges so an untranslated leak is recognisable.

Examples

All in examples/, all on the v2 session API, each compilable standalone with just its own gate (build lines in each file's header):

File Shows
top_fs_example.spin2 begin() + await_drive(), time source, directory listing, file create/write/readback (filesystem mounts itself on first use).
top_msc_example.spin2 Raw block layer: capacity, sector reads, a throughput burst. Read-only by design.
top_cdc_example.spin2 Serial plane: one ser_* API over CDC-ACM or any vendor bridge, write + polled read.
top_hid_example.spin2 Keyboard + mouse (bound to separate boot-protocol interfaces automatically), report polling.
top_hotplug_example.spin2 Hotplug-ready application skeleton — a resilient logger implementing the docs/hotplug.md patterns: poll() in the idle loop, a minimal on_change handler, ready-checks before every use, reopen-by-path logging that survives detach, re-autobaud on serial re-attach.
top_tone_keyboard_example.spin2 Audio + HID together (USB_AUDIO + USB_HID): keys 1–9 on a USB keyboard play a C-major scale through a USB audio adapter — phase-accumulator synthesis fed to aud_write, poll() + stream-health stats on the side. The first-hardware audio demo (nine clean tones, 169k frames at a sustained 1000/s).
top_audio_loop_example.spin2 Audio playback + full-duplex: 3 s of synthesized tone, then a dedicated DSP cog running mic → biquad low-pass → speaker live (coefficients derived in-file).
top_audio_dsp_example.spin2 The cross-cog audio pattern: a DSP cog owns the capture stream (aud_mic_open + aud_read, per-10 ms RMS/peak + 1 kHz Goertzel detect) while the main cog keeps poll() and hot-plug.
top_midi_example.spin2 USB-MIDI host (USB_MIDI, ticket 2140 — awaiting hardware): with USB_AUDIO, a monophonic velocity-sensitive synth with ±2-semitone pitch bend (exact equal-temperament note table, derivations in-file); without, a decoded MIDI event monitor.
top_joy_example.spin2 Gamepad/joystick (USB_JOY, ticket 2150 — awaiting hardware): discover (every axis/button/hat introspected by HID usage, no hardcoded layout), map (interactive press-the-control-you-want wizard via joy_mark()/joy_changed()), play (mapped controls read back through joy_control()).

The FS, hotplug, serial and HID examples were each run on the bench before this README claimed they work — against the 1 TB exFAT SSD behind the hub (arriving via the late-attach path) with the Alicat MFC answering on the serial plane. On the audio plane, top_tone_keyboard_example is the path's first-hardware run (nine clean tones by ear); the loop and DSP examples encode the patterns the acceptance harness (harness/top_audio_accept.spin2) validated — capture rates, Goertzel detection, the cross-cog ring contract — as compile-verified worked examples.

Filesystem regression coverage

Two gates, both green on real hardware. Full detail — including what is not covered — in docs/regression-report.md.

gate what it covers result
tests/regression/run_gate.sh 17 RT_ suites, FAT32 driver, one drive 346 / 346, 0 recoveries
tests/regression/run_fs_gate.sh 11 FS_ suites through the fs_* facade, both filesystems, the XS_ exFAT structural + malformed-media synthetic suites, the MD_ multi-drive suite (dual mounts + two-cog concurrency), and the HP_ hot-plug suite (real detaches via hub port power, ring-audited abandon honesty, sticky re-bind, churn) FAT32 223/223, exFAT 219/219, structural 27/27, malformed 20/20, multi-drive 13/13, hot-plug 11/11

Both refuse to call a run green on a technicality: run_gate.sh fails on any missing result and on any BOT recovery event — a suite that passed after a recovery is not the same result as one that never needed one — and run_fs_gate.sh fails on any deviation from expected counts in either direction, so a new failure cannot hide behind a fixed one.

FAT32 geometry matrix

Three volumes formatted to different geometries, each pinned by serial:

drive cluster size data-area alignment 16 non-defrag suites RT_defrag_tests
A 32 KB 0 332 pass, 0 fail, 0 recoveries 14 pass
B 4 KB 0 332 pass, 0 fail, 0 recoveries wedges the drive
C 4 KB 5 332 pass, 0 fail, 0 recoveries wedges the drive

All three produce the identical 332. Drive C's non-zero cluster alignment is the only geometry reaching the entryOffsetInCluster sign fix, so that path is now exercised on live media — and alignment is thereby exonerated in the defrag wedge: B and C differ only in alignment and behave identically both ways, so the variable they share is the 4 KB cluster size.

Both filesystems, through the application facade

The FS_ suites talk to usb_app's fs_* facade — the interface applications use — so one source runs on both filesystems:

suite FAT32 exFAT
FS_seek_tests 38 / 38 38 / 38
FS_cogcwd_tests 5 / 5 5 / 5
FS_stress_tests 4 / 4 4 / 4
FS_fatchain_tests 2 / 2 2 / 2
FS_delete_guard_tests 8 / 8 8 / 8
FS_multihandle_tests 22 / 22 22 / 22
FS_read_write_tests 49 / 49 49 / 49
FS_dirhandle_tests 25 / 25 25 / 25
FS_subdir_ops_tests 18 / 18 18 / 18
FS_error_handling_tests 19 / 19 18 / 18 †
FS_directory_tests 33 / 33 30 / 30 ‡
total 223 / 223 219 / 219

† the white-box MBR-preservation test is FAT32-only by design and compiles out on exFAT (18 there). ‡ the cluster-zero raw-patch group likewise (30 there).

Each FAT32 column matches its RT_ original exactly — the control proving the ports are faithful. The exFAT column is at FULL PARITY (ticket 6170, closed 2026-08-28): the 27-item shortfall ledger the ladder accumulated — seek-past-EOF clamping, chdir silent success, handle-limit differences, error-code splits, missing rename/move, the error write-on-exit gap — cleared by five facade/driver normalizations, a facade-level per-cog cwd for exFAT, and real rename/moveFile in the exFAT driver (entry-set rewrite with independent checksum/hash verification in the structural suite). The only exFAT-absent tests are the white-box-by-design ones above.

Product build matrix

run_fs_gate.sh --build-only compiles every shippable configuration with no hardware, so it belongs in a build pipeline:

build size
USB_MSC USB_EXFAT 58,700 B
USB_MSC USB_FAT32 71,869 B
USB_MSC USB_EXFAT USB_FAT32 81,876 B

The three different footprints are the evidence the compile-time gating works — each build carries only what it asked for, and a disabled filesystem leaves no remnant.

What testing through the facade found

Pointing the same suites at both drivers surfaced defects neither driver-bound ladder could reach, since each only ever ran against its own driver:

Known gaps, stated plainly

RT_defrag_tests has never passed on 4 KB media. Seven of the seventeen suites still cannot run on exFAT — the old error-constant wall fell with the facade error contract; what remains is the date API, driver lifecycle, and moveFile/setVolumeLabel, plus a group that is white-box by design (raw-sector and debugGet* access) and rightly stays FAT32-only. exFAT's structural side (bitmap, entry sets, NoFatChain, VolumeDirty, boot region) has its own 27-test synthetic-volume suite — which found and now guards a real stray-FAT-write defect in the NoFatChain conversion path. Multi-drive is covered (6140): dual FAT32+exFAT mounts, two-cog concurrent I/O with per-volume content verification, selection churn, and the bystander invariant asserted by witness hashes. Malformed media is covered (6190): hostile synthetic volumes for both filesystems — FAT chain loops, cycles and off-end chains, cross-linked files, lying geometry and FSInfo, truncated exFAT entry sets, out-of-range system clusters — with fail-clean asserted mechanically (bounded reads, defined codes, a shim auditing zero out-of-range writes). Not exercised at all: hot-plug/surprise removal.

These numbers measure one drive at a time, well-formed media, no removals. A floor, not a finish line.

Validation status

All on real hardware (P2 Eval, serial-host header, 200 MHz): a 4-port hub (VID $05E3) carrying a composite CDC serial gadget ($303A:$1001), a keyboard/mouse receiver ($046D:$C52B), and a USB SD-card reader ($14CD:$1212) — enumerated and exercised concurrently.

Development history, decisions, and per-feature evidence live in the Fossil ticket ledger in this repository (fossil ticket show 1 from the checkout).

License

MIT — see LICENSE. © 2026 Shannon Mackey (Refaqtory, LLC). Third-party components keep their original MIT copyrights: the FAT32 driver derives from Chris Gadd's and Stephen M Moraco's P2 microSD driver, and the bench uses Jon McPhalen's I²C/DS3231 objects — each file carries its own notice.

This project stands on real shoulders — Chip Gracey's silicon, Ada Gottensträter's pioneering USBnew host, Iron Sheep's toolchain, and a community's worth of prior art. The full appreciation, by name and by debt, is in ACKNOWLEDGMENTS.md.