Status: validated live — FTDI (incl. 4-channel H-series), PL2303, CP210x bridges and CDC-ACM gadgets, plus a real instrument (Alicat MFC) on the expect layer.
What you need
- A P2 board with the USB serial-host header (base pin 16 assumed below)
- A USB-serial cable or device: FTDI, Prolific PL2303, SiLabs CP210x, or any CDC-ACM gadget — one API for all of them, the driver detects the silicon
- Build gate:
USB_CDC
pnut-ts -d -I <path-to>/src -D USB_CDC my_serial.spin2
The smallest program
#IFDEF USB_CDC
#PRAGMA EXPORTDEF USB_CDC
#ENDIF
CON
_clkfreq = 200_000_000
OBJ
usb : "usb_app"
VAR
byte rx[64]
PUB main() | n
usb.begin(16)
if usb.await_serial(5000)
debug("serial bound, kind=", udec_(usb.serial_kind())) ' 1 CDC, 2 FTDI,
usb.ser_open(38400) ' 3 CP210x, 4 PL2303
usb.ser_write(@hello, 7)
repeat
n := usb.ser_read(@rx, 63) ' one poll; 0 = nothing waiting
if n
rx[n] := 0
debug("rx: ", zstr_(@rx))
usb.poll()
waitms(2)
DAT
hello BYTE "hello", 13, 0
What bring-up actually does
usb.begin(16)enumerates and binds serial with a deliberate policy: the first vendor bridge (FTDI/CP210x/PL2303) wins over the first CDC gadget — instrument cables are bridge silicon, and a debug console on a composite board would otherwise shadow them.ser_open(baud)runs the right bring-up for the detected silicon — FTDI divisors and latency timer, PL2303's vendor init dance, CP210x IFC_ENABLE, or CDC SET_LINE_CODING — then 8N1, DTR/RTS asserted.ser_readis a single poll: data waiting in the adapter's FIFO comes back (max ~62 bytes per call), an empty line returns 0 immediately. Backpressure is inherent — data you haven't read waits in the device.
Talking to instruments: the expect layer
For request/response devices (SCPI gear, AT modems, flow controllers),
skip hand-rolled parsing — usb_ser_expect gives tcl-expect-style
conversations with globs, timeouts, tokenizing, and evidence-driven
autobaud:
OBJ
usb : "usb_app"
exp : "usb_ser_expect"
exp.bind(@wr, @rd) ' two one-line shims (below)
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) ' "+2.503" -> 2503, no floats
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)
Several instruments at once
Need more than one link live? Open each by address with its own handle (awaiting hardware validation, ticket 2160):
h1 := usb.ser_open_dev(addr_mfc, 38400) ' find addr via dev_*() roster +
h2 := usb.ser_open_dev(addr_temp, 9600) ' dev_serial() identity
usb.ser_write_h(h1, @cmd, n)
n := usb.ser_read_h(h2, @buf, 63)
Up to 4 handles; per-handle baud/close; handles drop automatically on detach. One cog per handle at a time — but different handles may be driven from different cogs concurrently, which is the point.
Troubleshooting
| Symptom | Likely cause |
|---|---|
await_serial times out |
Cable on the other bus / not attached — check the dev_*() roster; PL2303 "HXN" variants degrade (detected + reported). |
| Garbage after replug | Device rebooted to its default rate — rerun autobaud() / ser_open(). |
| Instrument answers intermittently | Use exp.chat() (it drains stale input first) instead of raw write/read. |
Next
- Instrument conversation, end to end:
harness/top_alicat_expect.spin2 - Plain serial example:
examples/top_cdc_example.spin2 - Full expect contract + dialect knobs: docs/api/usb-app-api.md §4