Parallax Propeller2 USB driver - the MashUp

Hot-plug guide — handling insert, remove, and rearrange
Login

Hot-plug guide — handling insert, remove, and rearrange

Ticket 0185. The session layer treats the bus roster as a living thing: devices attach late, disappear without warning, and get swapped. This guide shows how to write an application that keeps working through all of it. Validated live end-to-end by harness/top_hotplug.spin2 (attach event, programmatic detach of a mounted volume, remount, file I/O, binding-integrity check) and harness/top_rootplug.spin2 (root-device death and recovery — see below).

Coverage is symmetric since 2026-08-12 (field report from a P2 Edge bench): the scan handles the root device of each bus too — the hub itself, or a direct-attached device. Pulling the hub tears down its whole subtree (leaf detach events, then a port-0 root detach); re-inserting a root device — hub or direct — is picked up by poll() alone, no begin() needed, without disturbing the other bus. Previously root re-attach was never detected and a re-inserted hub left its bus dead.

The model

Three pieces, all in usb_app:

  1. Detection — a change scan samples every hub port's status register. Connect is a level; connect-change is a latched bit the hub holds until the host acknowledges it, so even a fast swap that ends "still connected" is caught. The scan consumes the bits properly (act, then acknowledge), so an event fires exactly once.
  2. Reaction — the driver fixes itself before telling you: on removal it drops that address's class bindings, abandons a mounted filesystem with zero I/O to the dead device, and compacts the roster; on arrival it enumerates and auto-binds with the same selection policy as begin().
  3. Notification — your handler (or poll()'s return count) tells you it happened, so you re-establish application state: reopen files, redo autobaud, refresh a UI.

Quickstart

OBJ
  usb : "usb_app"

PUB main()
  usb.begin(16)
  usb.on_change(@reconfigured)      ' optional but recommended
  repeat
    usb.poll()                      ' idle loop: costs nothing between samples
    do_work()

PRI reconfigured(kind, port, addr)
  if kind == usb.EV_DETACH
    ' bindings for that device are ALREADY gone; fix your app state
    log_lost(port)
  else                              ' usb.EV_ATTACH
    ' the device is ALREADY enumerated and bound; take it into service
    log_found(port, addr)

poll() is internally rate-limited to the port-scan period (250 ms — see the timing ledger), so calling it every loop iteration is free: between samples it returns 0 immediately. When a scan is due it runs, handles every change, fires your handler once per change, and returns the change count. No handler? Just use the return value and the ready-getters.

The API

Call Meaning
poll() : n Rate-limited change scan; the idle-loop call.
rescan() : n Force one full change scan now.
on_change(mp) Bind/unbind (0) the handler (kind, port, addr).
EV_ATTACH / EV_DETACH kind values (constants on usb_app).
await_drive/serial/kbd(ms) : ok Block for a class, scanning while waiting.
drive_ready() serial_ready() kbd_ready() mouse_ready() Truth now — they go false on detach and true again after re-attach.
dev_count(), dev_addr/port/vid/pid(i) The live roster.
hub_ports(), hub_port_status(pn) Port introspection (status bit 0 = connect, bit 16 = pending connect-change).
hub_port_power(pn, on) Per-port power — how the self-test produces a real detach without hands. Ganged-power hubs lawfully ignore it; verify via hub_port_status().

Handler rules (important)

Your handler runs on whatever cog called poll()/rescan()/await_*(), synchronously, once per change, after the driver has already reacted.

What detach actually does, per class

The instant a scan sees a device gone, before your handler runs:

Nested hubs (ticket 1205): one level of hub-behind-hub is walked - combo hub+reader units enumerate their whole subtree, and every rostered device carries its parent hub, so port sampling asks the right hub. An inner hub's death tears down its children first (each with its own EV_DETACH), then the inner hub itself. A hub at depth 2 still gets the loud not-enumerated diagnostic.

When the hub itself dies (unplugged, power lost): the hub is not a roster leaf, so it has its own liveness sample — the same answer-a-descriptor evidence and 2-miss debounce as direct devices. On death, every leaf behind it goes through the per-class teardown above (EV_DETACH each, with its port), then a final EV_DETACH with port 0 reports the root itself and the dead hub's port walk stops.

When a port fails enumeration (field 0530): the attempt is not repeated on every scan. Each consecutive failure doubles that port's retry delay (1 s first retry, 8 s ceiling), a connect-change on the port — a real replug — clears the backoff immediately, and at most one enumeration attempt runs per scan pass. A dead device on a live port used to cost the polling cog ~1 s out of every 250 ms scan (measured: 17 s per console command); now it costs one bounded attempt every backoff period.

When a root device wedges (field 0530): a root that keeps its pull-up (line reads J) but answers nothing to three consecutive paced reset+probes gets a real VBUS drop and restore, scoped to its bus — the same final rung begin()'s ladder has, because a wedged device recovers only on a real power drop (field report 2026-08-11). It fires only while nothing is rostered on that bus, spans several scans (never blocks one poll() for the off-dwell), and paces itself with a doubling gate on hardware whose empty bus floats J. set_vbus_rescue(0) disables it — do that when your application gates bus power itself, so the driver does not fight you for the EN pin. Validated live: harness/top_rootplug.spin2 P5 — power cut behind the driver's back, subtree re-rostered 9.9 s later by poll() alone.

After re-attach, class bindings are restored automatically (same policy as begin(): serial prefers vendor bridges (FTDI/CP210x/PL2303), storage/HID first-found by port order), but application state is yours to re-establish:

A rearrangement (unplug A, plug B into the same port) is simply a detach and an attach falling out of the same scan — both events fire, in that order.

Root attach: how an empty bus comes back

When a bus has no root device (hub gone, or direct device gone), each scan samples that bus's D−/D+ line state — free, one PHY command. SE0/SE1 means nothing attached: zero cost until something arrives. J/K idle held for two consecutive scans (symmetric with the 2-miss detach debounce) triggers a scoped attach ladder: reset that bus only, spec recovery, one address-0 probe. The probe answer is the attach evidence; on it, the normal bring-up tail re-rosters the root and (for a hub) all its leaves — EV_ATTACH fires per device, port 0 first.

One hardware honesty note: on some benches (P2 Eval + serial-host add-on, hardware-proven 2026-08-09) a dead unpowered bus floats J, so line state alone cannot arbitrate attach there. On such hardware the empty-bus scan pays one ~25 ms reset+probe every ~2 s (ROOT_RETRY_SCANS, see the timing ledger) — harmless on an empty bus, and the paced probe is the detector. On hardware whose detached bus reads SE1 (P2 Edge + #64006B, per the field report), attach shows up as the SE1→J transition and the empty bus costs nothing.

Validated live by harness/top_rootplug.spin2 (this rig's EN pin gates hub power, making root removal programmatic): hub death → 3 leaf + 1 root detach events, roster drained; 6 s floating-J soak with zero phantom attaches; EN restore → port-0 EV_ATTACH + full subtree re-rostered via poll() alone in 1.1 s; all addresses answering after (RESULT PASS).

Choosing your waiting style

Cogs and concurrency

The simple, recommended shape: run poll() on the same cog that uses the affected classes (typically your main loop cog). Scans and transfers are individually safe from any cog (everything serializes on the host lock), but if one cog is inside a storage call when another cog's scan processes that device's removal, the in-flight call ends with I/O errors while the teardown happens under it — harmless in practice (errors were inevitable; the device is gone) but noisier to reason about. Single-cog polling avoids the interleaving entirely.

Two buses (ticket 1160)

Nothing changes for your handler. After begin2(), poll()/rescan() scan both buses in one pass; events carry the device address, and addresses are per-bus (bus 0: 1..7, bus 1: 8..15), so app.dev_bus() or a simple addr >= 8 tells you which header the event came from. Port numbers repeat across buses — always pair a port with its bus when logging.

Ganged-power hubs and direct attach

Troubleshooting

Symptom Likely cause
No EV_DETACH when you cut port power in software Ganged-power hub — the port never lost power. Verify with hub_port_status() bit 8/0.
Periodic brief bus resets on an empty bus Expected on hardware whose dead bus floats J (see above): the root-attach pass probes at the ROOT_RETRY_SCANS cadence because the line cannot prove absence. Harmless — nothing is attached to disturb.
Periodic VBUS drops on an empty bus Same floating-J hardware: the silent probes look like a wedged root, so the rescue rung fires — at a doubling cadence that settles near ~48 s. Harmless (nothing attached), or set_vbus_rescue(0) to silence it.
A drive drops off the roster and rescan() never brings it back Before 0530 this needed a reboot (begin()'s ladder has the VBUS rung; rescan() didn't). Now the rescue rung recovers it within ~10 s. If it still stays gone: the device reads SE0 (its pull-up is truly gone) — reseat the cable/power.
Attach/detach pairs repeating Marginal cable/power: the device brown-outs on inrush, drops, retries. The scan handles it, but fix the hardware.
Device present but no EV_ATTACH at startup It was attached before begin() — already in the roster, no event. Events report changes; read the roster for the initial state.
fs_* returns −87 after a replug Stale handle from before the detach. Reopen by path.
Serial timeouts after a replug The device rebooted to its default baud — rerun autobaud().

Worked example

harness/top_hotplug.spin2 exercises every path above in one run and is the reference for the patterns: roster display, event handler, blocking and event-driven waits, the port-power detach of a mounted volume, remount with real file I/O, and a post-cycle check that every binding still points at a live address.

When it isn't a hot-plug

A device that stops answering without a detach event has not been unplugged — it has wedged, and the recovery path is a different one. See getting-started/recovery.md: the hub-disabled port and the wedged device look alike in the logs and are told apart only by the port's ENABLE bit.