How firmware actually gets from fbuild deploy to a connected board.
The point of this doc is so an agent adding a new board family knows
which pieces to extend and which ones to leave alone, instead of
copy-pasting an ESP-shaped path onto a CDC-bridge board and losing an
afternoon to the FastLED/FastLED#3300 trap.
fbuild-cli (`deploy` subcommand)
│ thin HTTP client — no build/flash logic lives here
▼
fbuild-daemon (HTTP/WebSocket server)
│ request validation, device lease, build orchestration
▼
fbuild-build::BuildOrchestrator (per-platform impl)
│ produces firmware.elf, firmware.bin, build_info.json
▼
fbuild-deploy::Deployer trait
│ board-family-specific flash path
│ • ESP32: esptool / espflash via serial
│ • LPC8xx: pyOCD / probe-rs via CMSIS-DAP USB
│ • Teensy / SAMD / RP2040: 1200-bps touch + UF2 copy
▼
post_deploy_recovery (Deployer-supplied)
│ serial-port re-enumeration wait, board-specific quirks
▼
fbuild-serial::manager (re-attach monitor)
│ open_port with DTR=true, RTS=true per docs/usb-cdc-control-line-matrix.md
▼
Client receives stream over WebSocket
crates/fbuild-deploy/src/lib.rs::Deployer is the contract every
board family implements:
trait Deployer {
fn deploy(&self, ...) -> Result<()>;
fn post_deploy_recovery(&self, port: &str) -> Result<()>; // default: 3s sleep
}post_deploy_recovery is the cross-family hook for "the board's USB
endpoint just disappeared because we reset it; here's how long /
how to wait for it to come back." Override when:
- The board uses USB-bootloader flashing (Teensy / RP2040 / SAMD) — the bootloader's VID:PID is different from the app's. The recovery has to poll for the app's VID:PID to reappear.
- The board uses a CMSIS-DAP debug probe (LPC845-BRK) — the debug probe stays enumerated through reset; the VCOM bridge may blink on Windows. Need a board-specific wait.
- The board is LPC + CMSIS-DAP specifically — wedge recovery needs extra retries; see #605 Phase 1.
BoardFamily (in crates/fbuild-serial/src/boards.rs) is the
taxonomy that gates DTR/RTS conventions today. It will eventually
also gate the Deployer selection (#687 — the
polymorphic ResetMethod dispatch registry) and per-family handoff
timing (#691).
Today the dispatch is more ad-hoc:
- ESP variants →
fbuild-deploy::esp32_native(espflash) orfbuild-deploy::esp32_external_uart(esptool over UART) - LPC8xx →
fbuild-deploy::pyocd_cmsis_dap(placeholder; bring-up in progress in the LPC845-BRK meta, #586) - Teensy / SAMD / RP2040 → 1200-bps-touch path (still partial)
The follow-up issues track the full polymorphic version:
- #687 —
BoardFamilyenum + polymorphicResetMethoddispatch registry. The thing this doc will reference as the canonical source. - #688 —
BootModeClassifierregistry (ESP-only today; generalize to per-family). - #691 —
HandoffTimingonBoardFamily. - #692 — enumerate supported deploy protocols per board; fail fast on unsupported.
- #693 — USB-level bootloader re-enumeration detection (complement to #688).
crates/fbuild-deploy/src/rp2040.rs (--transport picotool|uf2,
#1162) selects which stock transport is tried first:
- Shared reset ladder. After the pre-touch scan and 1200-bps CDC touch,
fbuild waits for BOOTSEL. If none appears and the selected runtime endpoint
supplied an exact VID, PID, and non-empty USB serial, it asks the Pico SDK
application reset interface to enter BOOTSEL and waits again. On Windows,
fbuild maps the selected CDC identity to one exact healthy WinUSB reset
interface and sends the Pico class control request directly. Other hosts use
managed
picotool reboot -u --vid ... --pid ... -f; picotool derives the runtime serial from the opened application device because that interface cannot be selected reliably with--ser. The fallback is attempted only after fbuild resolves one exact runtime identity, and picotool refuses a forced command when the VID/PID is ambiguous. A missing or ambiguous identity skips this layer, so an unscoped forced command is impossible. Deployment results name the application reset-interface reboot when it ran successfully. picotool(default). After the shared reset ladder and UF2 preparation, fbuild derives one exact BOOTSEL VID:PID from the verified FastLED/boards profile for the selected RP family and binds each picotool operation to that identity plus the selected runtime USB serial:--vid 0x<registry-vid> --pid 0x<registry-pid> --ser <serial>. It then runs a Windows-only PICOBOOT driver preflight for that same composite interface, a boundedpicotool infoprobe, andpicotool load <uf2> -x. The ROM load does not use-f; forced application reset is the separately scoped ladder step above. A missing runtime serial or ambiguous/missing registry BOOTSEL identity disables picotool; fbuild uses only an explicitly identified BOOTSEL mass-storage volume. A Windows driver problem, including Code 43, also skips picotool rather than spending its timeout. Any remaining picotool failure falls back to the BOOTSEL mass-storage path; if that also fails, the combined error names both transports' failures.uf2. Preserves the historical order exactly: BOOTSEL mass-storage first (with bounded transfer retries across fresh enumerations), managed picotool as the fallback.
Each stage-timeout env var accepts integer seconds in 1..=600; anything else logs a warning and keeps the default:
| Env var | Default | Governs |
|---|---|---|
FBUILD_RP2040_BOOTLOADER_TIMEOUT_SECS |
10 s | BOOTSEL volume discovery after the 1200-bps touch (and re-discovery between transfer retries) |
FBUILD_RP2040_UF2_WRITE_TIMEOUT_SECS |
60 s | Per-attempt watchdog on the NEW.UF2 write; a timed-out write feeds the normal retry/picotool-fallback path |
FBUILD_RP2040_POST_DEPLOY_TIMEOUT_SECS |
15 s | Eject watch after the write, and the runtime-CDC reappearance wait |
FBUILD_RP2040_PICOTOOL_TIMEOUT_SECS |
60 s | Target-bound picotool load -x budget, both as the picotool-primary load and the picotool-fallback load |
Outcome note: once the eject watch (mass-storage path) or a successful picotool load (primary or fallback) has confirmed the ROM accepted the image, a quiet runtime-CDC window no longer fails the deploy — it reports success with no port ("flashed, CDC unconfirmed"), so CI does not re-flash a healthy board whose first-plug driver install outlived the window. A genuine port-enumeration error still fails.
On Windows, the daemon retains the last non-empty LocationPaths observed
for a runtime endpoint. An unidentified VID_0000&PID_0002 Code 43 node is
eligible for the explicit/admin-gated exact-child restart only when one and
only one matching RP history has the same normalized physical USB path. The
elevated helper re-queries that path before acting. A different location or
multiple matches fail closed; fbuild never cycles a hub or changes the
host-wide selective-suspend policy.
The right sequence today:
- VID:PID first. Publish the board's bootloader and runtime USB
endpoints in FastLED/boards, then
exercise fbuild's normal catalogue ingestion path. Never add a literal
VID/PID to
BOARD_FINGERPRINTS,ENVIRONMENT_TO_VCOM, generated Rust, or any other production fallback in this repository. - Family classification. Resolve the published FastLED/boards product
identity and map that metadata to
CdcAcmBridge(or the appropriate family behavior). If the catalogue cannot express a needed distinction, extend its published schema instead of embedding another ID table in fbuild. RP2350 normally uses 1200-bps touch and host-ready idle, matching the RP2040 convention. - DTR/RTS matrix. Add a row to
docs/usb-cdc-control-line-matrix.mdfor the new chip, with a datasheet citation and capture date. - Deployer impl. If the existing
fbuild-deploy::pyocd_cmsis_dapor RP2040 1200-bps-touch path is reusable, register the new board there. Otherwise add a new sibling module undercrates/fbuild-deploy/src/<family>/. ImplementDeployer+ overridepost_deploy_recoveryif the recovery timing differs. - Test.
fbuild serial probe listshould now show the new board's hint.fbuild serial probe find --env <new-env>should return the right port.fbuild deploy --env <new-env>should complete;--monitorshould reattach without dropping bytes.
Do NOT start by copy-pasting an esp32_native deploy path. The
default ESP DTR/RTS state is (false, false) — fatal for any
CDC-ACM bridge.
fbuild deploy -e lpc845brk
> error: pyocd CMSIS-DAP connect failed: device not found
The deploy path goes through the CMSIS-DAP USB endpoint
((0x1FC9, 0x0132)), NOT the COM port. Two separate USB devices.
# Check what's actually enumerated
$ fbuild serial probe list
COM20 16C0:0483 ser=… [LPC11U35 VCOM bridge (LPC845-BRK USART0) OR PJRC Teensy USB-Serial]
COM10 1FC9:0132 ser=… [NXP CMSIS-DAP debug (LPC845-BRK / LPC11U35)]Both endpoints present — good. pyOCD's failure is its own (probably
a driver / udev rule issue, not an fbuild bug). The point is:
COM20 is the data port, COM10 is the deploy port. Don't try
to flash via the wrong endpoint.
commands-reference.md— everyfbuildsubcommand.../../docs/usb-cdc-control-line-matrix.md— DTR/RTS rules per board family (#689).../../crates/CLAUDE.md— crate dependency graph + boundaries.../../docs/CLAUDE.md— architecture-doc routing table.
Filed in #695.