Ticket 0180 (facade v2) · everything here is validated on real hardware; the Validation status section of the README records what ran where.
The API has two layers. The session layer is the default: one object, one bring-up call, class-level readiness checks, no USB knowledge required. The advanced layer underneath (manual binding, class objects, raw transfers) remains for exotic topologies and is documented at the end.
A design rule runs through the whole surface: no blind waits. Whenever the driver needs to know "is it ready yet?", it samples an observable — a probe answered, a hub port-status bit, an empty read — instead of sleeping a magic number. The constants that remain pace those samples or satisfy explicit USB spec recovery times, and every one of them is in the Timing constants ledger.
1. Quickstart
CON
_clkfreq = 200_000_000 ' keep >= 180 MHz (README hardware notes)
OBJ
usb : "usb_app" ' the only object most applications need
exp : "usb_ser_expect" ' add for instrument conversations
PUB main() | h, flow
ifnot usb.begin(16) ' serial-host header at base 16
debug(zstr_(usb.err_str()))
return
' -- a file on whatever storage is plugged in (mounts itself) --
if usb.await_drive(15_000)
h := usb.fs_open_read(string("/LOG.TXT"))
' -- a conversation with whatever instrument is plugged in --
if usb.await_serial(3000)
usb.ser_open(38400)
exp.bind(@wr, @rd) ' one-line shims, section 4
exp.bind_baud(@sb)
if exp.autobaud(string("A"), string("A*"), 0, 0)
if exp.chat(string("A"), string("A*"), 0)
flow := exp.token_milli(2)
' -- keys from a keyboard, if one is attached --
if usb.kbd_ready()
debug(udec(usb.kbd_char()))
Build gates select classes at compile time (unused classes cost nothing):
| Gate | Enables |
|---|---|
USB_HID |
keyboard / mouse (kbd_*, mouse_*) |
USB_CDC |
serial plane (ser_*): CDC-ACM plus FTDI, CP210x and PL2303 bridges, + expect layer |
USB_MSC |
storage (disk_*) |
USB_AUDIO |
UAC1 audio (aud_*): ISO streaming rings serviced by the PHY every SOF. Fully validated (tickets 1180/2110): playback by ear + loopback-measured exact rates both directions, 60 s zero-underrun under concurrent storage. |
USB_MIDI |
USB-MIDI 1.0 host (midi_*): event read/write + decode. Awaiting hardware validation (ticket 2140) — compile-verified, built on the proven bulk/poll-once machinery. |
USB_JOY |
gamepad/joystick (joy_*): report-descriptor parse + mapping. Requires USB_HID. Validated live (ticket 2150, DualShock 4 $054C:$09CC). |
USB_EXFAT / USB_FAT32 |
filesystems (fs_*); require USB_MSC |
pnut-ts -d -I <path>/src -D USB_CDC -D USB_MSC -D USB_EXFAT your_top.spin2
Your top file must re-export each gate (#PRAGMA EXPORTDEF USB_CDC etc.).
2. Session layer (usb_app)
Bring-up
| Call | Meaning |
|---|---|
begin(base_pin) : ok |
Full bring-up on a P2 Eval "USB serial host" header: base+0 LED (driven high), +1 EN, +2 D−, +3 D+. Host cog up, reset→probe ladder, SOF, whole-bus enumeration (hub or direct), every compiled class auto-bound. |
begin_pins(dm, en) : ok |
Same on explicit pins (en = -1 if VBUS is not switched). |
err_str() : p |
Human-readable last error ("no device answered (attach timeout)" …). Numeric codes stay in status() / fs_error(). |
set_attach_timeout(ms) |
Probe-ladder budget override (default 5 s; 0 restores it). |
power_cycle(dwell_ms) |
Explicit VBUS off/on. Not part of begin() — bus reset is USB's state reset. See ledger for why the dwell is a parameter. |
close() |
Stop the host cog (VBUS stays asserted so devices remain enumerable). |
How begin() decides things are ready — the reset→probe ladder: reset the
bus, then sample address 0 with GET_DESCRIPTOR(8) every 100 ms. A conforming
device answers once it has finished booting; a device that was still booting
when the first reset went by stays silent, so at half budget the ladder issues
one more reset and keeps sampling. The answer is the readiness signal. No
2-second power dwells, no 1.5-second "boot allowances" — those blind waits are
gone from this path (ledger, "removed" section).
Hot-plug: insert, remove, rearrange (ticket 0185)
| Call | Meaning |
|---|---|
poll() : n |
The idle-loop call. Internally rate-limited to the port-scan period, so calling it every loop iteration is free between samples. When due, runs one change scan and returns the number of attach/detach events handled. |
rescan() : n |
One full change scan now (poll() is its rate-limited wrapper). |
on_change(mp) |
Bind a reconfiguration handler (kind, port, addr) — EV_ATTACH / EV_DETACH — called once per change, after the driver has already reacted (bindings made or dropped). Keep it prompt; query ready-getters / the roster for detail; no blocking USB calls inside. 0 unbinds. |
await_drive(ms) / await_serial(ms) / await_kbd(ms) : ok |
Block up to ms for that class, scanning while waiting. |
dev_count(), dev_addr/port/vid/pid(i) |
The live device roster. |
dev_serial(i, buf, maxn) : n, dev_bcd(i) : b |
Per-unit identity: iSerialNumber (ASCII-folded; 0 = device has none — both bench PL2303s) and bcdDevice (chip revision). Flag a specific known-bad unit regardless of port; fall back to VID:PID + bcdDevice + port when no serial. harness/top_serid.spin2 dumps the identity roster. |
hub_ports(), hub_port_status(pn), hub_port_power(pn, on) |
Port introspection; per-port power (ganged-power hubs lawfully ignore it — verify via status). |
set_vbus_rescue(on) |
Enable/disable the automatic VBUS rescue of a silent root (default on at begin()). Turn it off when the application gates bus power itself (a harness driving EN, a deliberate power-down), so the session layer does not fight the application for the pin. Field 0530. |
What a change scan does:
- Detach — every rostered device's port is sampled; a cleared connect bit
or a pending connect-change bit means the device left (change + still
connected = it was swapped or bounced). The driver drops that address's
class bindings, abandons a mounted filesystem (zero I/O — flushing to a
dead device only burns error budgets; unflushed writes are lost and the
on-media VolumeDirty bit records the unclean release), compacts the
roster, acknowledges the change bit (so it can't re-fire), and fires
EV_DETACH. - Attach — connected ports with no rostered device are enumerated and
auto-bound (same selection policy as
begin());EV_ATTACHfires per device. Late attachers are the common case here: the bench SSD bridge asserts connect 6–9 s after power. - Rearrange is detach + attach falling out of the same scan.
- Direct-attached devices (no hub) have no port register; liveness is the device answering its address, with a two-consecutive-miss debounce against one transient control-transfer failure (per-bus counters).
- Enumeration backoff (field 0530, inspin bench 2026-08-19): a port
that reads connected but fails enumeration is not retried on every
scan — each failure doubles the retry delay (1 s → 8 s cap,
ENUM_BACKOFF_SCANS/ENUM_BACKOFF_CAP), a connect-change on the port (a real replug) clears the backoff at once, and at most one enumeration attempt runs per scan pass. Before this, a device that never finished enumerating cost its host ~1 s of every 250 ms scan — measured at 17 s per console command on the field bench. - VBUS rescue of a wedged root (field 0530, inspin bench 2026-08-23): a
root that reads J but answers nothing to
ROOT_DRY_RESCUEconsecutive paced reset+probes gets thebegin()ladder's final rung scoped to its bus — a real VBUS drop and restore (field report 2026-08-11: a wedged device recovers only on a real power drop). Non-blocking (the off dwell spans scans), armed only while nothing is rostered on that bus, and paced with a doubling gate (ROOT_DRY_CAP) so a genuinely empty J-floating bus is not power-cycled at full rate forever.set_vbus_rescue(0)opts out. Validated live:harness/top_rootplug.spin2P5. - Root coverage (field report 2026-08-12): the root device of each bus
is scanned too. The hub itself is probed with the same 2-miss debounce —
on death every leaf tears down (
EV_DETACHeach), then a port-0EV_DETACHreports the root. An empty root re-attaches frompoll()alone: line-state trigger (J/K two scans; SE0/SE1 = empty, free) → scoped reset+probe on that bus only → on a probe answer, the bring-up tail re-rosters the whole subtree withEV_ATTACHper device, port 0 first. The other bus is never disturbed; nobegin()required. Nested hubs (one level, 1205) participate: their leaves carry the parent hub in the roster, attach walks cover inner ports, and inner-hub death cascades through its children. On hardware whose dead bus floats J (this bench, proven 2026-08-09) the probe runs at a paced cadence instead — seeROOT_RETRY_SCANSin the ledger. Validated live:harness/top_rootplug.spin2(RESULT PASS).
Validated live end-to-end by harness/top_hotplug.spin2: SSD attach as a
poll() event, programmatic detach via hub_port_power() with the mounted
volume abandoned I/O-free, re-attach, remount, and file I/O — plus a
post-cycle check that every binding still points at a live address.
Full usage guide — handler rules, per-class detach/re-attach semantics, waiting styles, cog guidance, troubleshooting: docs/hotplug.md. Data movement — buffers, consumers, backpressure at every layer: docs/dataflow.md.
What gets bound to what (selection policy)
Deterministic, by hub-port order:
- Serial — the first vendor bridge (FTDI, CP210x or PL2303; port order) if any exists, else the first CDC-ACM. Instrument cables are bridge silicon; a native CDC gadget (e.g. a debug console on a composite board) would otherwise shadow them — observed live on this bench.
- Storage, HID — first found. HID binds keyboard and mouse separately by interface boot protocol, so composite receivers get both.
Multiple same-class devices: the advanced bind_* calls (section 6)
re-point any class after begin().
Readiness
| Call | Meaning |
|---|---|
drive_ready() : yn |
Storage bound and SCSI bring-up complete. First call may take seconds on a cold card reader — that is the media becoming ready. |
serial_ready() : yn, serial_kind() : k |
Serial bound; kind 1 = CDC-ACM, 2 = FTDI, 3 = CP210x, 4 = PL2303 (informational — the API is identical). Multi-channel FTDI parts (FT2232C/H, FT4232H) auto-bind channel A; bind_ftdi_ch() re-points at channels B..D. ser_device_baud() reads the configured baud back where the silicon echoes it (PL2303). |
kbd_ready() / mouse_ready() : yn |
HID interfaces bound. |
Second bus (ticket 1160)
One PHY cog can drive both channels of the Parallax USB Serial Host board (or any two P_USB_PAIR pin pairs). There is no second cog: the same cog emits each bus's SOF on its own 1 ms grid and runs one transaction at a time, selecting the pin pair per command (time-division at transaction granularity — full-speed USB transactions are ≤ ~350 µs, so neither bus's frame timing is disturbed; the frame guard defers any transaction that would straddle either bus's next SOF).
| Call | Meaning |
|---|---|
begin2(base_pin) : ok |
Bring up the second header after begin() succeeded. Same pin map, same reset→probe ladder, aimed at bus 1. |
begin2_pins(dm, en) : ok |
Explicit-pin variant. |
dev_bus(i) : b |
Which bus roster entry i lives on. The roster lists bus 0's devices first, then bus 1's. |
Rules the session layer keeps for you:
- Addresses identify buses. Bus 0 devices get addresses 1..7, bus 1
devices 8..15 (
set_addr_base), so every address-taking API (drive_select,ctrl_xfer, …) routes to the right pin pair by address alone — no bus parameter anywhere. - Data toggles are per-bus. The toggle tables carry a bus dimension
(
usb_hub_ram.tog_index(bus, addr, endp)), and a reset on one bus clears only that bus's half (clear_toggles_bus) — the other bus's in-flight toggle state survives. - Hot-plug covers both buses.
poll()/rescan()walk both hubs;on_changeevents carry the device address, sobusofis derivable and port numbers are per-bus. - One class instance = one bus at a time. Storage/serial re-aim at bind
time (
drive_selectacross buses is fine). The single HID instance serves one bus; a second HID device on the other bus stays unbound until youbind_hidit deliberately (auto_bind will never silently re-aim a live keyboard's client). UH_L_FRAMEis written by whichever bus emitted SOF last — treat it as a liveness counter, not a per-bus frame number.
Measured (bench, 300 MHz, hub+SSD+FTDI+HID on bus 0, hub+thumbdrive on bus 1): TX canary passes on both buses; 20/20 storage reads on bus 1 interleaved with 20/20 Alicat serial polls on bus 0; sequential-read throughput 59 KB/s (bus 1) vs 61 KB/s (bus 0 same session) — dual-SOF cost is in the noise. PHY cost: ~35 cog longs (≈25 remain free post-1150) + ~20 LUT longs.
3. Storage and filesystems
Raw block layer (after drive_ready() / await_drive()):
| Call | Meaning |
|---|---|
disk_blocks() / disk_block_size() |
Capacity (512-byte blocks only). |
disk_read(lba, n, buf) / disk_write(lba, n, buf) : ok |
READ(10)/WRITE(10). On failure the driver runs BOT reset recovery and retries once before reporting. |
Filesystem — mounts itself on first use (exFAT probed first, FAT32
fallback when both gates are compiled). fs_mount() remains available when
you want the status code up front.
| Call | Notes |
|---|---|
fs_open_read(path) / fs_open_write(path) / fs_create(path) : h |
Handle ≥ 0 or negative error. fs_open_write appends; fs_create fails on existing (−92). |
fs_read / fs_write(h, buf, n) : n |
Bytes moved; 0 at EOF. |
fs_seek(h, pos) / fs_file_size(h) / fs_close(h) |
Position clamps to size; close flushes metadata. |
fs_delete(path) |
Files, or empty directories (−96 otherwise). |
fs_mkdir(path) |
Parent must exist. Costs one cluster zero-fill (~3 s on 128 KB-cluster media). |
fs_dir_open/next/close, fs_entry_name/size/attr |
Iteration (attr bit 4 = directory). |
fs_sync(), fs_unmount(), fs_kind(), fs_vol_label(), fs_error() |
Housekeeping. |
fs_time_source(mp) |
Bind an RTC before writing; contract in the README. |
Error codes, limits (name lengths, handle counts, 2 GB clamp, cluster-bound directories) and the performance profile are in the README and docs/perf-1tb-ssd.md.
Recovery and reset (tickets 6030/6070/6080/6090)
Full guide with the decision procedure: docs/getting-started/recovery.md.
| Call | Meaning |
|---|---|
disk_bot_ok() : yn |
False = a transaction was abandoned and recovery is being attempted. Transient; ride it out. |
disk_wedged() : yn |
True = the MSC layer has given up; further commands fail immediately instead of stalling ~30 s. This is the one to branch on. Cleared by re-binding. |
disk_recover_detail() : rst, clro, clri |
Last recovery attempt's three outcomes (1 = succeeded). Read the port's ENABLE bit first — on a disabled port nothing can answer, so the triple alone does not identify the fault. |
hub_port_status(pn) : ps |
Bit 1 = ENABLE. $0103 enabled, $0101 not. The only discriminator between a hub-disabled port and a wedged device. |
hub_port_reset(pn) : ok |
Port reset, no power change. Separate from recycle on purpose: which rung works is the diagnosis. |
hub_port_recycle(pn, dwell_ms) : ok |
Power-cycle and reset a port, no binding involved — for a device that no longer enumerates. Returns 0 if the hub gangs power; check it, or "didn't help" is indistinguishable from "never happened". |
drive_recover(dwell_ms) : ok |
Recover the bound device by identity and re-bind the same physical media. Returns 0 on a root port or a ganged hub. Mount does not survive — fs_mount() again. |
disk_auto_recover(on, dwell_ms) : prev |
Opt-in, off by default. Lets disk_read/write escalate a wedged device to drive_recover(). Soak only — a power cycle loses unflushed writes and returns the device at a new address. |
fs_abandon() |
Release a mount with zero device I/O. fs_unmount() rewrites both FSInfo sectors and is not safe against an unreachable device. |
Two rules that cost real time when ignored:
- A device that re-enumerates returns at a NEW BUS ADDRESS. Re-bind by
serial (
dev_serial()+drive_select_sticky()), never by address. - Verify multi-cluster files by CONTENT, never by size. The signature failure is a perfect directory entry over a broken FAT chain — the right number of the wrong bytes.
Evidence ring (USB_EVTRING, ticket 6150)
Circular record of the last 256 BOT transactions, dumped only on demand, so an
armed green run costs only the RAM. Built without the gate every call is a
no-op. Lives in src/usb_evt_ring.spin2.
| Call | Meaning |
|---|---|
evt_arm(on) : ok |
Arm and clear. Two workloads must never share one dump. |
evt_armed() : yn |
Recording state. |
evt_mark(tag, a, b) |
Drop a context marker — which workload axis moved, which drive was selected. |
evt_note_port(ps) |
Push a port-status word for later records to carry. Not sampled per transaction (that would be a control transfer on the bus under test); records label it "last known". |
evt_dump(p_why) |
Print the ring oldest-first. Dump at the first anomaly, then stop. |
evt_count() / evt_failures() |
Records written since arm; how many failed. |
evt_overwritten() |
Records already discarded. A dump that lost the precipitating transaction looks exactly like one that captured it. |
evt_peek(back, fld), evt_peek_addr/opcode/kind(back) |
Read a held record back (0 = newest) so a harness can assert on what was recorded rather than eyeball a dump. |
Each record carries opcode, LBA, length, elapsed time, NAK/retry count, CSW status, SOF frame at start and end, and the device address it was addressed to — a recovery against one drive has been observed to break every drive bound after it, and a ring that cannot say who is blind to that.
Async jobs: usb_fs_job (ticket 2088)
Long operations without blocking your loop: an opt-in worker cog runs
whole jobs through the same fs_* facade (works on either filesystem), with
live progress and prompt cancel. One job at a time.
OBJ
usb : "usb_app"
job : "usb_fs_job"
' wiring: fill a 9-slot vtable AT RUNTIME with facade method-pointer shims
' (VT_* order in usb_fs_job.spin2; the acceptance harness shows the shims)
job.bind(@vtab)
job.start() ' +1 cog while started
job.copy(string("RUN1.DAT"), string("ARCH.DAT"))
repeat
usb.poll()
control_loop() ' never blocked
case job.state()
job.S_DONE: done_bytes := job.reap()
job.S_FAILED: err := job.error() ' then job.reap()
| Call | Meaning |
|---|---|
read_file(path, buf, max) / write_file(path, buf, len) / append_file(path, buf, len) |
Whole-file jobs. The buffer belongs to the job until reap(). write_file replaces. |
copy(src, dst) |
Internal bounce buffer; dst replaced; progress against source size. |
make_dir(path) / delete_path(path) |
Metadata jobs. |
state() |
S_IDLE/S_RUNNING/S_DONE/S_FAILED, non-blocking. |
bytes_done() / bytes_total() |
Progress (total 0 while unknown). |
cancel() |
Honored between ~90 ms chunks; job ends E_JOB_CANCELLED, partial output left. |
reap() |
Collect a finished job (bytes or error); E_JOB_BUSY while running - non-blocking by design (the blocking first cut deadlocked against detach handling on hardware). |
error(), kind(), guard_ok(), stop() |
Introspection / teardown. |
Semantics that matter:
- Paths are copied at post time; every job opens by path and closes before
finishing - no handle survives between jobs, which is what makes hot
detach recovery trivial: a mid-job detach fails the job in milliseconds
(measured 63-199 ms; the unbind raises an abort that in-flight transfers
observe), you
reap(), and the next job after re-attach just works. - Bus sharing is packet-granular: with a 100 ms instrument cadence running against a 192 KB copy, successful serial exchanges kept a 30 ms round trip (measured); under maximum interleave expect a small residual exchange loss (~10 % in the acceptance run) that a normal chat-retry absorbs.
- Keep calling
usb.poll()on your loop cog while jobs run - that is where detach handling executes.
Acceptance evidence: harness/top_async_job.spin2 (baseline sync cadence
wreckage vs async, verified copy, mid-job port-power detach, recovery).
4. Serial instruments: the expect engine (usb_ser_expect)
A generic tcl-expect-style conversation layer: it knows lines, glob patterns, timeouts and tokens — your application supplies the commands and expected shapes, so it applies unchanged to SCPI instruments, AT modems, Modbus-ASCII gear, Alicat controllers, or your own devices.
Binding
Transport is two method pointers — anything with a writer/reader works. Spin2 method pointers capture the calling object's context, so binding a child object's methods goes through one-line shims:
exp.bind(@wr, @rd)
exp.bind_baud(@sb) ' optional: enables autobaud()
PRI wr(b, n) : r
r := usb.ser_write(b, n)
PRI rd(b, m) : r
r := usb.ser_read(b, m)
PRI sb(b) : r
r := usb.ser_set_baud(b)
Conversation verbs
ms = 0 means "use set_timeout()'s default" — the tcl timeout variable,
made explicit.
| Verb | Meaning |
|---|---|
put(p) / sendline(p) |
Transmit; sendline appends the EOL string. |
readline(ms) : p |
Next complete line (CR, LF or CRLF consumed, blanks skipped); 0 on timeout. |
expect(p_pat, ms) : p |
Deliver lines until one glob-matches. 0 on timeout — line() then holds the last line seen, unparsed() any partial input: put both in your error report. |
expect_any(p_tbl, n, ms) : i |
Multi-branch expect over a table of patterns (long array of zstring pointers); returns the matched index, −1 on timeout. |
chat(cmd, pat, ms) : p |
drain() + sendline + expect — the standard exchange. |
drain() : n |
Swallow stale input until the line is quiet — a sampled condition (consecutive empty polls), not a fixed delay. |
autobaud(cmd, pat, p_rates, n) : baud |
Probe each rate until the reply matches; condition-driven (see below). p_rates = 0 uses the built-in table 38400, 19200, 57600, 115200, 9600, 4800. |
Autobaud's retry logic is evidence-based, not a count: at each rate it quiets the line and probes once. A matching reply locks the rate. Traffic that doesn't match is the signature of a garbled first frame (a UART can join mid-character right after a rate hop — physics, not a bug), so exactly one resend follows; a second consecutive garble means wrong rate, move on. Silence means wrong rate or absent device. Nothing loops "twice because twice worked."
Reply dissection
| Call | Meaning |
|---|---|
line() : p |
Last delivered line (the expect match). |
unparsed() : p |
Unterminated partial input (after a timeout). |
ntokens() / token(i) : p |
Split on the delimiter set (default space/tab). |
is_num(i) |
Token looks like [+-]digits[.digits]. |
token_int(i) : v |
Integer value (fraction truncated). |
token_milli(i) : mv |
Fixed-point → integer milli-units ("+2.503" → 2503). No floats anywhere. |
glob(pat, str) : yn |
The matcher itself (* any run, ? one char, whole-string), exposed for your own code. |
Dialect knobs (all optional)
| Call | Default | Use when |
|---|---|---|
set_eol(p_str) |
CR | Device wants LF or CRLF: set_eol(string(13, 10)). |
set_delims(p_str) |
space + tab | SCPI-style CSV replies: set_delims(string(",")). |
set_nocase(yn) |
off | OK/Ok/ok should all match. |
set_timeout(ms) |
1000 | Your instrument is slower/faster than typical. |
set_poll_gap(ms) |
2 | See ledger; rarely needed. |
Multiple serial devices at once (ticket 2160)
Status: awaiting hardware validation — compile-verified; built for a field rig running two MFCs, a DUT bridge, and a temperature controller.
Up to MAX_SERH (4) concurrently open serial devices, each with its own
handle — opening, reading, or re-bauding one never re-aims another. The
single-bound ser_* surface above is unchanged (don't open one device
through both at once).
| Call | Meaning |
|---|---|
ser_open_dev(addr, baud) : h |
Open a specific rostered device: vendor bridges get full per-chip bring-up (per-address chip-generation state — mixed FTDI generations and duplicate PL2303s are tracked separately), CDC gadgets get line coding + DTR/RTS. −1 = no slot / not serial / bring-up failed. Find addr via the roster + dev_serial()/dev_bcd() identity. |
ser_write_h(h, buf, len) / ser_read_h(h, buf, maxn) |
Per-handle I/O; FTDI status headers stripped on read. |
ser_set_baud_h(h, baud) / ser_close_h(h) |
Per-handle rate change (with the per-chip purge semantics) / close. |
ser_h_addr(h) |
The handle's device address (0 = closed). Handles drop automatically when their device detaches. |
Concurrency contract: one cog per handle at a time; different handles
from different cogs concurrently is the design point — every handle op
routes the calling cog only (per-cog bus selectors, ticket 1200).
Expect-engine per instrument: one usb_ser_expect instance per link,
bound through shims that capture the handle. Multi-channel FTDI parts open
channel A via a handle; bind_ftdi_ch remains the route to B..D.
Worked shapes
' Alicat MFC (validated live - harness/top_alicat_expect.spin2):
exp.autobaud(string("A"), string("A*"), 0, 0)
exp.chat(string("AS0.100"), string("A*"), 0) ' setpoint
flow := exp.token_milli(2)
' SCPI instrument:
exp.set_eol(string(10))
exp.set_delims(string(","))
exp.chat(string("*IDN?"), string("*"), 0) ' any reply line
p_maker := exp.token(0)
' AT modem, multi-branch:
' tbl[0] := @ok_pat ("OK"), tbl[1] := @err_pat ("ERROR*") (fill at runtime -
' DAT @label longs hold object offsets, not addresses)
case exp.expect_any(@tbl, 2, 0)
0: ' proceed
1: ' command rejected
-1: ' link dead
5. HID (keyboard / mouse)
begin() binds keyboard and mouse by interface boot protocol (a
composite receiver's keyboard interface has protocol 1, mouse protocol 2 —
they get separate endpoints, not one shared guess) and requests boot
protocol on each. When a device lists a report-only interface (protocol 0)
before the boot keyboard — Logitech Unifying C52B/C534 does this — bind
prefers protocol 1 so SET_PROTOCOL(boot) and the 8-byte poll land on the
real keyboard, not the vendor/DJ iface.
| Call | Meaning |
|---|---|
kbd_poll() : ok |
Pull the latest report (false = no new data). Dual-bus: aims the shared host xfer at the bound HID bus first. A short IN zeros the unread tail so a previous report cannot ghost extra keys. |
kbd_mods() / kbd_key(i) / kbd_char() |
Modifiers, usage codes, best-effort ASCII of kbd_key(0) (letters, digits, HID boot punctuation, Tab/Enter/Backspace/Escape). |
kbd_dev() / kbd_endp() / kbd_raw(i) |
Bound address / interrupt-IN endpoint / boot-report byte i (0..7). |
mouse_poll(), mouse_buttons(), mouse_dx(), mouse_dy() |
Mouse plane. |
set_int_interval(frames) |
Interrupt poll pacing (0 = every call). |
Status: validated live (2026-08-10, Unifying receiver $046D:$C52B;
2026-08-17, Unifying $046D:$C534 with a third HID iface + a second
receiver on the same hub): keyboard and mouse bound to their separate
boot-protocol interfaces, both endpoints answering, reports delivered —
including across a hot detach/reattach cycle (top_hotplug.spin2).
Gamepad / joystick (USB_JOY, ticket 2150)
Status: validated live (2026-08-17, Sony DualShock 4 $054C:$09CC on
bus 1 while a Unifying receiver held kbd/mouse on bus 0) — bind, poll,
and joy_kind/joy_axes/joy_button_count match the parsed descriptor.
Pads have no boot protocol — the HID report descriptor is parsed at
bind time (usb_hid_report.spin2; design and v1 caps in
docs/design/hid-gamepad-mapping.md) and
only an interface whose application collection is joystick/gamepad/
multi-axis binds here; everything else falls through to the keyboard path
unchanged. Requires USB_HID (enforced at build).
| Call | Meaning |
|---|---|
joy_present() / joy_kind() |
Bound + parsed / $04 joystick, $05 gamepad, $08 multi-axis. |
joy_axes(), joy_axis_usage(i), joy_axis_min/max(i) |
Introspection: how many axes, which each is (page<<16|usage — $30 X … $39 hat), declared range. |
joy_button_count() |
Total buttons (≤ 32). |
joy_poll() : ok |
One poll-once interrupt IN; nonzero = fresh report (report-ID filtered). |
joy_axis(i) / joy_axis_scaled(i) |
Raw (sign-extended per declared min) / normalized −32768..+32767. |
joy_buttons() / joy_button(i) |
32-bit mask (bit N−1 = button N) / single button, 1-based. |
joy_hat() |
0..7 clockwise from north, −1 centered/none. |
joy_mark() |
Snapshot baseline for the mapping wizard. |
joy_changed() : code |
First control differing from the baseline: (kind<<8)\|index, kind 1 button / 2 axis / 3 hat; 0 = none. Axis threshold ¼ span (16384) — ignores stick noise, trips on any deliberate motion. |
joy_control(code) |
Read a control by its joy_changed() code — how an app consumes stored mappings. |
The press-the-control-you-want wizard pattern, worked end-to-end:
examples/top_joy_example.spin2 (discover → map → play).
6. Advanced layer
Everything begin() does is composed from public calls you can use directly
for exotic topologies (several same-class devices, custom selection, raw
class work):
open(dm, en)/enumerate()— host up + single-device enumeration (compat path; carries the pre-v2 blind waits, see ledger).usb_hub_host—start(hub_ptr),enumerate_bus(),enum_one_port(pn),port_status(pn),parse_classes(da)+cls_*getters (incl.cls_cdc_comm_if()and the per-interfacecls_hid_*_i(i)set),find_iface_eps(da, class),get_config(da)+cfg_ptr(),dev_get_vid/pid/addr/port/count.bind_msc(a, ep)/disk_bind(a, eo, ei)+disk_set_iface(n),bind_cdc_full(a, eo, ei, comm_if),bind_ftdi(a, eo, ei),bind_ftdi_ch(a, eo, ei, ch),bind_cp210x(a, eo, ei),bind_pl2303(a, eo, ei),bind_hid(a, ep)— manual class pointing; endpoint packing isaddr | (ep << 8)where you must do it yourself.- Class objects (
usb_class_hid/cdc/ftdi/cp210x/pl2303/msc) andusb_host(control_xfer,bulk_in/out,int_in,bus_reset_now(), metrics) — the transfer plumbing.
Layer map:
your app ── default ──► usb_app session layer (begin/await/ready + kbd_/ser_/disk_/fs_)
│ │
│ advanced ──► bind_* + usb_hub_host (enumeration, descriptors)
│ │
└──────────► usb_class_hid / cdc / ftdi / cp210x / pl2303 / msc ─► usb_host ─► PHY cog
PHY wire timing (usb_host.spin2)
Wire-level values live in usb_host.spin2's wire-timing CON block; every
one carries a provenance class — the honest answer to "why this value":
| Class | Meaning |
|---|---|
| SPEC | Derived from USB 2.0 with a citation; not tunable. |
| EMPIRICAL | No derivation exists — the guarded silicon behavior is undocumented. The value is sweep-found and canary-guarded, and replacement by a sampled condition was attempted on hardware and refuted. Treat as provisional forever. |
| WINDOW | Give-up bound on a sampled wait. Any value above the spec minimum is correct; size only shapes error-path latency. |
| MEASURED | Bound computed from wire arithmetic + measured margins. |
| Value | Class | Story |
|---|---|---|
p_twk = ¾ bit period above TWK_BREAK_HZ, else 0 |
EMPIRICAL | Guards a smart-pin bit-stuff/byte-handoff race (2087/4040). Sweeps: @200 MHz ≤3 fail / 6–20 pass; @300 ≤12 fail / 16–20 pass. Sampling refuted on hardware 2026-08-09: DP's IN is a buffer-empty event, not a level (TESTP poll deadlocks); SE1-latch corrupts sustained streams. Guard: top_txpat at every operating clock. |
TWK_BREAK_HZ = 168 MHz |
EMPIRICAL | Inherited from OBEX 4198; confirmed only at its edges (≤160 MHz any gap breaks TX, ≥200 no-gap corrupts). The interior is uncharacterized. |
p_rwkovr = 1 bit period above the breakpoint |
EMPIRICAL | RX poll pacing (1140): unpaced RDPIN ack pressure races the same undocumented handoff engine. |
Inter-packet gap p_bt4 = 4 bit times |
SPEC-bounded choice | Spec window: ≥2 bt bus turnaround, and (for responses) well under the peer's 16–18 bt timeout. 4 keeps margin both ways. It is the dominant term of the ACK budget below. |
| ACK latency budget | computed | The full EOP→ACK-first-edge sum (detect granularity + validation cycles + gap + SOP/p_twk) is written out at the p_reop ACK site and computed by ack_latency_bt10() (surfaced in metrics_line(); target ≤ 10 bt of the 16–18 bt device timeout, leaving hub-transit margin). Note the declared interaction: p_twk spends ACK budget — the two values trade against each other. 0190's hub failures were this sum silently exceeding the window; it is now visible in every characterization log. |
RSPW_US_IN/SETUP/HS = 500 µs / 2 ms / 200 µs |
WINDOW | Give-up bounds on the sampled SOP wait; spec minimum ~1.5 µs; sized for hub chains and cold firmware. |
RSPW_US_ISO = 250 µs |
MEASURED | ISO-IN give-up; bounds the per-frame loss when a mic skips a frame. Measured 2026-08-11 on the CM108 via hub: steady-state turnaround exceeds 50 µs (the original 50 µs froze capture at 3 frames). Exercised live by the loopback acceptance (capture at exactly 48,000/44,100 Hz — top_audio_accept.spin2). |
ISO frame reservation (UH_L_AUD_RSV) |
MEASURED-class formula | Computed at aud_open: (payload+overhead bytes) × 8 bit × 1.07 stuffing ÷ 12 bit/µs + gap margin; both frame guards add it so bulk never squats on the audio slot. Validated by the acceptance run: 60 s zero-underrun playback with concurrent MSC at 251 KB/s. Ring sizes ledgered in usb_hub_ram.spin2. |
GUARD_US_TXN/BULK = 350 / 120 µs |
MEASURED | Frame-guard bounds so a started transaction never straddles a due SOF: wire time (~51 µs for 64 B) + handshake window + margin. |
Audio (USB_AUDIO, tickets 1180/2110)
Fully validated 2026-08-12 (tickets 1180/2110 CLOSED) via a loopback
jumper (headphone out → mic in, CM108 $0D8C:$0014): capture rate measured
exactly 48,000 and 44,100 Hz (fractional path verified end-to-end),
four-tone Goertzel matrix each at its own bin, volume/mute effective
(CM108 coarse steps), and 60 s of playback with zero underruns while
the 2088 job worker cog streamed file copies at 251 KB/s — audio ring ops
take no locks, so storage error budgets cannot block the stream (run
harness/top_audio_accept.spin2 to reproduce; RESULT PASS all phases).
aud_read consumes whole frames only — a frame that doesn't fit stays
queued (size the buffer ≥ the mic max packet). Known characteristic:
capture under load holds ~99.3% with healthy bus peers but degrades when a
degraded device slows responses (SOF jitter vs synchronous-mode ISO;
adaptive-mode playback is immune).
| Call | Meaning |
|---|---|
aud_present() : yn |
A UAC1 device is bound (auto-bind takes the first class-1 device). |
aud_open(rate) : ok |
Open playback: alt-setting select, SET_CUR sampling rate (device STALLs unsupported rates — it is the authority), ring + fractional-rate registers programmed, PHY armed. 44.1 kHz is exact via a 16.16 samples-per-frame accumulator. |
aud_mic_open(rate) : ok |
Open capture; mic frames land in fixed slots, drained by aud_read. |
aud_write(buf, n) : accepted |
Queue 16-bit LE interleaved PCM. Never blocks; pace on the return value or aud_free(). |
aud_read(buf, maxn) : n |
Drain captured PCM (0 = none pending). |
aud_volume(v256) / aud_mute(yn) |
Feature-Unit master controls (s16, 1/256 dB units). No-op success if the device has no Feature Unit. |
aud_underruns() / aud_overruns() / aud_frames() / aud_in_frames() |
Stream health counters — the 1180 acceptance metric is zero underruns for 60 s under concurrent MSC load. |
aud_close() |
Disarm the PHY, drop the ISO frame reservation, park alt-0. |
Cross-cog contract: aud_write is single-PRODUCER and aud_read
single-CONSUMER — each from any one cog, touching only hub-RAM ring state.
The intended pattern is a dedicated DSP cog owning the sample stream
(examples/top_audio_dsp_example.spin2 — RMS + Goertzel tone detect;
examples/top_audio_loop_example.spin2 — tone playback + full-duplex biquad
loopback) while the main cog keeps poll() and hot-plug.
Scheduling: the PHY transmits/receives ISO payloads immediately after
each SOF (never Spin-paced), and the frame guards subtract the live ISO
reservation (UH_L_AUD_RSV) so bulk defers around the audio slot — MSC
keeps ~600–700 KB/s alongside a 48 kHz stereo + mic pair by budget.
MIDI (USB_MIDI, ticket 2140)
Status: awaiting hardware validation — compile-verified; every wire primitive it uses (bulk OUT, poll-once IN, config parse) is bench-proven.
USB-MIDI 1.0 is Audio class 1, MIDIStreaming subclass 3: fixed 32-bit event packets over plain bulk endpoints. Byte 0 = cable | CIN (Code Index Number — tells each packet's meaning up front, no running status), byte 1 = MIDI status, bytes 2–3 = data. Auto-bind takes the first MIDIStreaming device in port order (a MIDI-only controller no longer registers as an audio device — parse discriminates subclass 3 from AC/AS).
| Call | Meaning |
|---|---|
midi_present() / midi_out_present() : yn |
IN bound / device also has an OUT jack (many controllers are IN-only). |
midi_read() : ev |
Next event packet, 0 = none. Poll-once: an idle controller costs ~1 ms; one bulk IN buffers up to a 16-event burst, served one per call. |
midi_write(ev) : ok |
Send one event to the device's MIDI OUT jack. |
midi_pack(cable, cin, st, d1, d2) : ev |
Compose an event (CIN constants on usb_app: CIN_NOTE_ON …). |
midi_ev_cin/status/chan/data1/data2(ev) |
Field decode (note/velocity/controller/value). |
midi_is_note_on/off(ev) |
Note events — velocity-0 NOTE ON is honored as note-off per MIDI convention. |
midi_bend(ev) : v |
Pitch bend as signed −8192..+8191. |
Worked example: examples/top_midi_example.spin2 — a monophonic
velocity-sensitive synth (with USB_AUDIO) or a decoded event monitor
(without), including an exact equal-temperament note table (top-octave
values + >>1 per octave — integer halving is the tempered octave).
Timing constants ledger
Rule: a number may pace a sampling loop, satisfy a spec recovery time, or be a measured device allowance with margin — and it must say which. "App choice" values live in application code, commented as such.
Removed from the user path (the former hacks)
| Was | Where | Replaced by |
|---|---|---|
| 2000 ms EN power-off dwell before every session | every harness | Nothing: bus reset is USB's state reset; the probe ladder recovers late boots. Explicit power_cycle(ms) remains for genuinely wedged hardware. |
500 ms after VBUS in open() |
usb_app v1 (kept there for compat) | begin(): probe ladder samples readiness. |
1500 ms "boot allowance" in bus_reset() |
usb_host (kept in compat bus_reset()) |
bus_reset_now() + ladder re-reset at half budget: a device that missed reset #1 probes silent, which is the detection. |
| 50 ms post-reset settle | usb_app v1 | RESET_RECOVERY_MS = 10 (spec) + probe. |
| 10 × 300 ms address-0 probe loops | every harness | The ladder, inside begin() (100 ms sampling, 5 s default budget). |
| Hand-rolled 500 ms late-attach port polls | harnesses | rescan() / await_*() at PORT_SCAN_MS. |
drain(80) / drain(50) fixed drains |
expect users / chat() |
drain(): quiet detected by consecutive empty samples. |
repeat 2 autobaud retry |
Alicat harness | Evidence-driven: retry once only when unmatched traffic (garble) was seen. |
Session layer (usb_app)
| Constant | Value | Basis |
|---|---|---|
RESET_RECOVERY_MS |
10 ms | Spec — USB 2.0 §7.1.7.5 TRSTRCY: a device may ignore traffic this long after reset. |
ADDR_SETTLE_MS |
2 ms | Spec — USB 2.0 §9.2.6.3 SetAddress recovery (the same value is used inline by hub enumeration). |
PROBE_INTERVAL_MS |
100 ms | Sampling — liveness probe period; one 8-byte control transfer ≈ 1 ms of FS bus per sample → 10 Hz costs ~1 % of the bus. |
ATTACH_TIMEOUT_MS |
5000 ms | Measured — bench hub first answers ~1.6 s after power; ×3 margin. Override: set_attach_timeout(). |
VBUS_RECYCLE_MS |
2500 ms | Measured — the begin ladder's final rung: a real VBUS drop long enough for supply caps to discharge. ≤200 ms leaves bridges brown-out-wedged (2088 phase C); ~3 s clears even ASMedia sick states; field report 2026-08-11: a wedged USB-3 stick recovers ONLY on a real VBUS drop, never on bus reset. Fires at most once, only when the ladder is otherwise dry. |
PORT_SCAN_MS |
250 ms | Sampling — port connect is a level (and connect-change a latched bit) in the hub's status register; 4 Hz bounds detection latency far below device boot variance (SSD bridge: 6–9 s measured) at one control transfer per port per scan. Also poll()'s internal rate limit. |
| Direct-attach liveness debounce | 2 misses | Sampling — a detach is declared only after two consecutive failed address probes, debouncing a single transient control-transfer failure (hubless topologies only; hub ports use the change bits). The root hub gets the same probe + debounce (it is not a roster leaf). |
ROOT_DEBOUNCE_SCANS |
2 | Sampling — J/K line idle must hold two consecutive scans before a root-attach attempt; symmetric with the 2-miss detach debounce (field report 2026-08-12). |
ENUM_BACKOFF_SCANS / ENUM_BACKOFF_CAP |
4 / 32 scans | Measured trade (field 0530) — first retry of a port that failed enumeration comes 1 s later, doubling per consecutive failure to an 8 s ceiling; a connect-change (real replug) or the port emptying clears it. A port that failed five times in a row will not succeed one scan later, and each attempt costs ~1 s against a dead device — retrying every pass cost the field bench 17 s per console command. One enumeration attempt max per scan pass bounds what a single poll() can cost its caller. |
ROOT_DRY_RESCUE / ROOT_DRY_CAP |
3 / 24 dry probes | Measured trade (field 0530) — a J-reading root that answers nothing to 3 consecutive paced probes (~6 s into the outage) earns a real VBUS drop (VBUS_RECYCLE_MS), the only recovery a wedged device responds to (field 2026-08-11). The gate doubles per dry rescue so floating-J hardware with a genuinely empty bus settles to a ~48 s rescue cadence. Nothing is rostered on the bus when it fires, so nothing live is disturbed. |
ROOT_RETRY_SCANS |
8 (~2 s) | Measured trade — paced re-probe of a J/K-reading empty root. The Z J-detector reads J on an unpowered floating bus (hardware-proven 2026-08-09), so line state cannot arbitrate attach on such hardware; the post-reset address-0 probe answer is the only honest evidence. One attempt costs ~25–30 ms of that bus (15 ms SE0 + 10 ms recovery + ~1 ms probe); every 8th scan keeps the duty < 2 % while catching a genuine attach within one cadence of its connect (validated: hub re-attach in 1.1 s, top_rootplug). SE0/SE1 hardware pays nothing until the SE1→J transition. |
power_cycle(dwell) |
caller's | Unobservable — supply-cap discharge time can't be sensed from the P2 (no VBUS ADC on the header), so it stays an explicit parameter; 2000 ms never failed on this bench. |
Expect engine (usb_ser_expect)
| Constant | Value | Basis |
|---|---|---|
POLL_GAP_MS |
2 ms | Derived — half the 4 ms FTDI latency timer the serial plane programs: no arrival window is skipped (Nyquist against the adapter's flush period). For CDC it merely paces NAK polling. |
QUIET_POLLS |
2 | Sampling — drain's end condition: two consecutive empty samples one gap apart = the adapter proved empty across a full latency window, twice (debounce against reading mid-flush). |
DRAIN_CAP_MS |
250 ms | Bound — a continuously-streaming device must not trap the caller; hitting the cap means "device is babbling", and drain() returns what it swallowed so the app can tell. |
AUTOBAUD_PROBE_MS |
300 ms | Derived — a 60-char command+reply at the slowest built-in rate (9600) occupies ~62 ms of wire; ×4 margin plus device parse time. Within the window the reply itself is the sample. |
DFLT_TIMEOUT_MS |
1000 ms | Default policy — tcl-expect's timeout analog (theirs 10 s; ours sized for instrument links answering in tens of ms). set_timeout() overrides. |
LINE_MAX |
120 | Envelope — ASCII instrument lines run 20–80 chars; oversize truncates, terminator still consumed. |
Serial adapters
| Constant | Value | Basis |
|---|---|---|
| FTDI latency timer | 4 ms | Derived — short instrument replies flush as one packet; halves the chip's 16 ms default for snappier readline at negligible bus cost. |
| FTDI baud divisors | — | Silicon — 48 MHz base, FT232BM sub-bit fraction encoding (FTDI app notes). |
| FTDI IN status strip | 2 bytes/packet | Silicon — modem/line status prefix on every bulk-IN packet. |
Async job service (usb_fs_job)
| Constant | Value | Basis |
|---|---|---|
CHUNK_BYTES |
4096 | Derived — ~90 ms of bus per slice at the measured ~45 KB/s: progress/cancel latency ~0.1 s while ≥ 8 sectors keeps the whole-sector fast path (within a few % of ceiling per the perf tables). |
IDLE_POLL_MS |
2 ms | Sampling — worker's job-mailbox poll when idle; start latency invisible against ~90 ms slices, zero bus cost. |
STACK_LONGS + guard |
400 + 16 canary | Envelope — worker call chain (facade→fs→MSC→client + debug), $DEADBEEF canary checked after every job (technique inherited from the FAT32 worker). |
Transfer engine (usb_client)
| Constant | Value | Basis |
|---|---|---|
XFER_STALL_MS |
20 s | Bound (field 0530) — wall-clock ceiling on zero-progress retrying inside one bulk transfer. The try budgets assumed ~1–2 ms per attempt; with a wedged PHY each attempt can cost phy_locked's 200 ms bail-out, and 10 000 tries then held the calling cog for over half an hour (field bench: console dead for minutes behind one stuck storage read). 20 s honors MSC's SCSI-scale patience (10–20 s worst case, 2087); any data progress resets the clock. |
Storage (usb_class_msc / usb_app)
| Constant | Value | Basis |
|---|---|---|
| INQUIRY window | 10 tries × 300 ms | Measured — a freshly powered card reader ACKs the CBW but NAKs INQUIRY data for ~2.6 s while it mounts the card; BOT reset recovery is interleaved at tries 3 and 6. |
| TEST UNIT READY loop | 10 × 50 ms | Protocol — sense-clearing poll (REQUEST SENSE between tries); 50 ms paces the not-ready→ready transition. |
| CSW patience | 150 × 100 ms (15 s) | Measured — flash/GC stalls of several seconds observed mid-file (ticket 2087); SCSI-scale command timeout. |
| Bulk-data NAK/silence budget | 10 000 tries (~10–20 s) | Measured — same stalls hit the data stage itself; abandoning mid-transfer wedges BOT (2087). Healthy transfers never spend it. MSC's client instance only. |
| BOT chunk | 4096 B | Envelope — per-command transfer size vs hub-RAM workspace (set_max_chunk adjusts). |
Host / PHY (engine internals, for completeness)
| Constant | Value | Basis |
|---|---|---|
| Reset SE0 hold | 15 ms | Spec+margin — ≥10 ms (§7.1.7.5), single continuous drive (re-triggering reads as reset chatter and shuts hub ports — hardware-proven). |
| SOF start at SE0 release | — | Spec — devices suspend after 3 ms of idle bus; SOF must run before Spin returns to the caller. |
| Token→DATA gap | 4 bit times | Spec — receiver's next-packet window is 16–18 bit times (§7.1.19.1); matches the reference driver. |
| AKPIN→WYPIN TX gap | ¾ bit period (computed; 12 @200 MHz, 18 @300 MHz; 0 <168 MHz) | Measured, twice — the stuff-boundary race window scales with the bit period, not absolute time: sweep @200 MHz failed ≤3, passed 6–20; sweep @300 MHz failed ≤12, passed 16–20 (a fixed 8-clock gap silently corrupted at 300 — ticket 4040 canary catch). ¾-bit clears both edges with margin. Re-run top_txpat.spin2 at every new clock. Override: set_tx_tweak(). |
| Bulk NAK default | 64 tries × ~1 ms | Protocol — NAK is flow control; ~64 ms covers interactive-class devices without stalling HID polling. Storage raises its own budget (above). |
| RX CRC-error retries | 3 | Spec — USB 2.0 §4.5.2, three errors per transaction; the PHY withholds ACK on bad CRC so the device retransmits. Found live (2088): aborting on the first error killed whole BOT transfers under two-cog MSC+serial interleave. |
| Mailbox BUF write | inside the lock | Race fix (1140) — the buffer pointer accompanying a PHY command must be written after lock acquisition; the old pre-lock write let a second cog clobber it, routing one cog's packet data through another's buffer. Collision rate scaled with Spin speed: ~10 % exchange loss at 200 MHz (previously misattributed to "interleave casualties"), storm-grade CRC/timeout bursts at 270–300 MHz. Post-fix: zero PHY error deltas and zero serial loss in the 300 MHz interleave soak. |
| READ(10) short-IN | fail + retry | Protocol — a short bulk-IN lawfully ends a transfer, but on READ(10) it is missing sector data; accepting it silently put a 64-byte hole in a verified copy (2088). The CSW is still consumed, then the command fails so the caller re-reads. |
| Surprise-removal abort | observed ≤ ~1 ms/loop | Sampling — hot-detach unbind raises a per-instance abort flag polled by every bulk retry loop; measured job-failure latency 63–199 ms end-to-end vs minutes of dead-device error budgets without it (2088). |
| Hub port reset hold | 50 ms | Spec+margin — hub port reset TDRST 10–20 ms typical. |
| SET_ADDRESS retries | 3 | Spec — 3-error transaction rule (§4.5.2); some leaves miss the first attempt right after port reset (observed). |
Change history
- v2.8 (2026-08-13, ticket 1205) — nested hubs, one level: inner hubs
are configured and walked (parent-hub column in the roster, hub-aware
port sampling, inner-hub-death cascade). Validated live: full FAT32
acceptance + TX canary + audio-under-load through a dock-style combo
unit's inner hub; cross-bus multi-cog concurrency (1200) validated live
the same session (
top_xbus1200, RESULT PASS). - v2.7 (2026-08-13, tickets 1200/2160/1205, field report #3) —
per-cog bus routing: the dual-bus selector moved from object state to
per-calling-cog state, so cogs aimed at different buses interleave safely
(previously one cog's probe could re-aim another's in-flight transfer
onto the wrong pin pair — observed as >60 s file-op hangs under
concurrent serial+storage); the shared control-transfer staging gained
its own lock. Multi-device serial handles:
ser_open_dev+ser_*_h(4 concurrent, per-address chip state in FTDI/PL2303). Nested hubs: still single-level, but now loudly diagnosed instead of silently absent (recursion = ticket 1205). All awaiting hardware. - v2.6 (2026-08-12, ticket 2150) — gamepad/joystick plane (
USB_JOY): bind-time HID report-descriptor parsing (usb_hid_report),joy_*introspection/read API, and thejoy_mark()/joy_changed()/joy_control()mapping primitive. Joystick no longer aliases the keyboard endpoint; per-device unbind. Awaiting hardware validation. - v2.5 (2026-08-12, ticket 2140) — USB-MIDI host (
USB_MIDI):midi_*event API over MIDIStreaming bulk endpoints, poll-once IN, decode helpers; parse now discriminates MIDIStreaming (subclass 3) from real audio interfaces. Awaiting hardware validation. Also: audio detach cleanup un-nested fromUSB_CDC(latent since 1180 — an audio-only build never cleared ISO state on detach), and report-only LS speed-hint instrumentation (line_speed_hint(), ticket 1190 prep). - v2.4 (2026-08-12) — root hot-plug symmetry (field report): hub-death
teardown (leaves + port-0 root event) and root re-attach from
poll()alone via a scoped per-bus reset+probe ladder; per-bus direct-liveness counters. Filesystem wear policy hardened the same day: strict next-fit in both drivers (no delete rollback), exFAT mount-time hint from a backward bitmap scan, FAT32 FSInfo persisted on everyfs_sync()and derived by FAT scan when the media has none (see README "Flash wear policy"). - v2.3 (tickets 1180/2110) — audio plane (
USB_AUDIO):aud_*API over UAC1 devices, isochronous OUT/IN serviced by the PHY on every SOF from lock-free hub-RAM rings, exact fractional rates (44.1 kHz via 16.16 accumulator), ISO frame reservation folded into the frame guards. - v2.2 (ticket 1160) — dual-bus:
begin2,dev_bus, per-bus addresses (bus 1 = 8..15), per-bus SOF/frame/toggles, both-bus hot-plug. One PHY cog. - v2 (ticket 0180) — session layer (
begin/await_*/rescan/err_str), sampled readiness everywhere, serial FTDI/CDC policy, HID per-interface binding, fs mount-on-first-use, generic expect engine, this ledger. - v1 (ticket 0170) — original facade; its surface remains as the advanced layer.