12 KiB
Phase 7b — TinyUSB Composite Device on Feather S3 TFT
Status: completed (PoC working on hardware) Owner: firmware/feather_s3_tft Supersedes the runtime portion of Phase 7 (WebUSB-only via TinyUSB while keeping ROM USB Serial/JTAG).
Outcome summary
Implemented and validated end-to-end on real Feather S3 TFT hardware:
- Single TinyUSB composite USB device (
303a:4001) exposing CDC-ACM (/dev/ttyACM0) plus Vendor/WebUSB. - Same dispatcher serves both transports; per-request response is sent over the same transport that received it.
- All Phase 0–6 behaviors (auth envelope, on-device approval, sign_event signature verification) preserved.
- Linux udev rule (
/etc/udev/rules.d/99-nsigner-webusb.rules) added for browser WebUSB access on the host.
Working hashes/IDs observed during validation:
- VID:PID
303a:4001, productFeather S3 TFT n_signer - Authenticated CDC
get_public_keyreturned the same pubkey as authenticated WebUSBget_public_keyfor the same caller key (confirming both transports go through the same dispatcher and key store).
See firmware/README.md for runtime usage.
1. Why
The ESP32-S3 has a single USB peripheral. It can be routed to one of two mutually exclusive owners:
- The ROM-provided USB Serial/JTAG controller (current firmware path — gives us
/dev/ttyACM0"for free" but cannot expose any other interface, so a browser cannot speak WebUSB to it). - The TinyUSB OTG stack (managed by the
espressif/esp_tinyusbcomponent — lets us define arbitrary device descriptors including CDC + Vendor/WebUSB, but takes the peripheral away from the ROM controller).
Phase 7 attempted to run TinyUSB and the ROM USB Serial/JTAG controller simultaneously. That is not a supported configuration on this silicon: enabling TinyUSB causes the /dev/ttyACM0 device node to disappear, breaking every existing host-side script.
Phase 7b commits fully to TinyUSB and replaces the ROM path with a TinyUSB-managed composite device that exposes both CDC-ACM (so existing pyserial tooling keeps working unchanged) and a Vendor/WebUSB interface (so the browser demo works in Chrome / Edge).
2. Goal
A single firmware build produces a USB device that:
- Enumerates on Linux as
/dev/ttyACM0driven by the kernelcdc_acmdriver. - Enumerates on Chrome / Edge as a WebUSB-capable vendor interface bound to a registered URL.
- Routes inbound framed JSON-RPC frames (4-byte big-endian length + JSON body) from either transport into the same dispatcher.
- Sends each response back on the same transport that received the request.
- Preserves all current behavior: auth envelope verification,
get_public_key,sign_eventwith on-device approval, TFT status, button handling.
3. Non-goals
- Console /
printfover USB. Boot logs stay on TFT and remain silent on USB to avoid corrupting framed transport bytes (current behavior preserved). - Hot-swap between ROM USB Serial/JTAG and TinyUSB at runtime.
- Automatic entry into the ROM bootloader from firmware. Flashing requires manual bootloader-mode entry.
- WiFi / network transports.
4. Architecture
4.1 Runtime block diagram
graph LR
subgraph Host
PY[pyserial scripts]
BR[Chrome WebUSB demo]
end
subgraph TinyUSB_Composite [TinyUSB Composite USB Device VID:PID 303A:4001]
IAD[IAD]
CDC[CDC-ACM ITF 0+1 EP 0x02/0x82/0x83]
VEN[Vendor ITF 2 EP 0x01/0x81]
end
subgraph Firmware
FRX[frame receiver]
DSP[dispatcher: auth + sign_event/get_public_key]
FTX[frame sender]
TFT[TFT + buttons approval]
end
PY -- /dev/ttyACM0 --> CDC
BR -- WebUSB --> VEN
CDC --> FRX
VEN --> FRX
FRX --> DSP
DSP --> FTX
DSP <--> TFT
FTX --> CDC
FTX --> VEN
4.2 USB descriptor layout
| Item | Value |
|---|---|
bDeviceClass / Subclass / Protocol |
MISC / COMMON / IAD (composite) |
idVendor / idProduct |
0x303A / 0x4001 |
bcdUSB |
0x0210 (required for BOS / WebUSB) |
| Interface 0 + 1 | CDC-ACM, EP 0x02 OUT (bulk), 0x82 IN (bulk), 0x83 IN (notification) |
| Interface 2 | Vendor, EP 0x01 OUT (bulk), 0x81 IN (bulk) |
| BOS | WebUSB platform descriptor + MS OS 2.0 platform descriptor |
| MS OS 2.0 set | Binds Vendor interface (ITF 2) to WINUSB compatible ID and a DeviceInterfaceGUIDs registry property |
| WebUSB landing URL | A short URL to a hosted page that loads the demo (TBD; placeholder example.com/nsigner/feather) |
| String descriptors | [0]=lang 0x0409, [1]=n_signer, [2]=Feather S3 TFT n_signer, [3]=123456, [4]=n_signer CDC, [5]=n_signer WebUSB |
4.3 Frame-level protocol (unchanged)
Both transports carry the same framed JSON-RPC bytes:
+--------+--------+--------+--------+----------------------------+
| length (uint32 big-endian) | payload (JSON-RPC bytes) |
+--------+--------+--------+--------+----------------------------+
Length excludes the 4-byte header. Payload is a single complete JSON-RPC request or response. No streaming, no multi-frame messages.
4.4 Dispatcher routing
Pseudocode for the main loop:
loop {
if cdc_recv_frame(buf, &len) == OK {
handle_request(buf, len, response_buf, &resp_len);
cdc_send_frame(response_buf, resp_len);
continue;
}
if webusb_recv_frame(buf, &len) == OK {
handle_request(buf, len, response_buf, &resp_len);
webusb_send_frame(response_buf, resp_len);
continue;
}
vTaskDelay(2 ms);
}
A request received over CDC is responded to over CDC; a request received over WebUSB is responded to over WebUSB. The two transports do not cross-talk.
5. Build configuration changes
In firmware/feather_s3_tft/sdkconfig.defaults:
-CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
+# CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG is intentionally unset.
+# TinyUSB owns the USB peripheral; route boot console to UART so logs do not
+# corrupt framed transport bytes.
+CONFIG_ESP_CONSOLE_UART_DEFAULT=y
+CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
+CONFIG_TINYUSB_CDC_ENABLED=y
+CONFIG_TINYUSB_CDC_COUNT=1
CONFIG_TINYUSB_VENDOR_COUNT=1
Active sdkconfig will need the same values applied (Phase 6 taught us that defaults alone is not sufficient).
6. Source layout changes
| File | Change |
|---|---|
firmware/feather_s3_tft/main/webusb_transport.c |
Renamed to usb_transport.c; descriptor table grows from Vendor-only to IAD+CDC+Vendor; adds CDC framer alongside existing Vendor framer |
firmware/feather_s3_tft/main/webusb_transport.h |
Renamed to usb_transport.h; exposes usb_transport_init, cdc_recv_frame, cdc_send_frame, webusb_recv_frame, webusb_send_frame |
firmware/feather_s3_tft/main/main.c |
Drops all usb_serial_jtag_* calls; uses cdc_recv_frame / cdc_send_frame instead; re-enables both transports in the receive loop |
firmware/feather_s3_tft/main/CMakeLists.txt |
Replace webusb_transport.c entry with usb_transport.c |
firmware/README.md |
New "Flashing workflow" section documenting bootloader-mode requirement and the expected lsusb / dmesg enumeration |
7. Trade-offs and accepted constraints
| Concern | Decision |
|---|---|
| Flashing requires bootloader-mode entry every time | Accepted. Document clearly. The ROM bootloader still provides USB Serial/JTAG, so idf.py flash still works whenever the user enters bootloader mode. |
Boot console no longer auto-shows on /dev/ttyACM0 |
Accepted. TFT is the boot diagnostic surface. UART can be wired manually if deeper debugging is needed. |
| Existing host scripts may need a small startup delay (TinyUSB CDC enumerates slightly slower than ROM USB Serial/JTAG) | Existing per-port serial-open hardening in feather_get_public_key.py and feather_sign_event.py already includes a time.sleep(3.0) settle. Tune if needed. |
| Two transports could in principle race | Single-threaded receive loop; only one frame is being processed at a time. |
| Windows user experience | MS OS 2.0 descriptor binds Vendor interface to WinUSB so Chrome WebUSB works without driver install; CDC is bound by the inbox driver. |
8. Security posture
No change. Auth envelope is enforced identically on both transports. The transport layer is treated as an untrusted byte pipe. Frame size is bounded by s_frame_buf (4096 bytes). Replay cache, body-hash binding, and on-device approval all remain in place.
9. Validation matrix
| Step | Method | Pass criteria |
|---|---|---|
| 9.1 Build | make firmware-feather |
No warnings, single binary produced |
| 9.2 Flash | Bootloader-mode + idf.py -p /dev/ttyACM0 flash |
Hash of data verified |
| 9.3 Reset | Tap RESET (no BOOT) | TFT shows "n_signer" + npub + "usb cdc+webusb" |
| 9.4 Enumeration (Linux) | lsusb, dmesg | tail |
303a:4001, kernel binds cdc_acm, creates /dev/ttyACM0 |
9.5 CDC get_public_key |
./.venv/bin/python ./examples/feather_get_public_key.py /dev/ttyACM0 |
Returns expected 64-hex pubkey, exit 0 |
9.6 CDC sign_event |
./.venv/bin/python ./examples/feather_sign_event.py /dev/ttyACM0 |
Returns signed event, signature verifies |
| 9.7 CDC auth-required reject | Send a request with no auth envelope (manual or via test harness) |
Returns error 2014 |
| 9.8 WebUSB connect | Open feather_webusb_demo.html in Chrome |
Pairing dialog shows the device |
9.9 WebUSB get_public_key |
Click "Get public key" in demo | Returns same pubkey as 9.5 |
| 9.10 Recovery | Run 9.5 again after 9.9 | Both transports remain healthy |
10. Roll-back plan
If composite enumeration fails or breaks /dev/ttyACM0 access in a way that cannot be quickly fixed, revert by:
git checkout HEAD -- firmware/feather_s3_tft/sdkconfig.defaults firmware/feather_s3_tft/main/main.c firmware/feather_s3_tft/main/usb_transport.c firmware/feather_s3_tft/main/usb_transport.h- Re-enable
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y. - Build + bootloader-mode flash.
- Confirm
/dev/ttyACM0returns and CDC scripts pass.
The ROM bootloader is in mask ROM and cannot be bricked by firmware, so this rollback is always available as long as the user can press BOOT + RESET.
11. Step-by-step execution checklist
- 7b.1 Apply sdkconfig changes (
defaultsand activesdkconfig). - 7b.2 Rewrite descriptor block in the transport source: add IAD + CDC-ACM interface descriptors, keep Vendor descriptor, fix endpoint numbers, fix string indices.
- 7b.3 Add CDC framer (4-byte BE length, mirrors existing vendor framer) using
tud_cdc_*API. Exposecdc_recv_frame,cdc_send_framefrom the transport module. - 7b.4 Strip
usb_serial_jtag_*frommain.c. Replacerecv_frame_stdin/send_frame_stdoutwith calls tocdc_recv_frame/cdc_send_frame. Restore the dual-transport selection block. - 7b.5 Re-enable transport init early in
app_main(). Update TFT idle text to "usb cdc+webusb". - 7b.6 Build. Flash via bootloader mode. Verify enumeration with
lsusbandls /dev/ttyACM*. - 7b.7 Run all CDC validation steps in §9.5 – §9.7.
- 7b.8 Run WebUSB validation steps in §9.8 – §9.9.
- 7b.9 Update
firmware/README.mdwith the "Flashing workflow" and "USB transport" sections. - 7b.10 Bump firmware version string in
main.c; update rootREADME.mdUSB description.
12. Out of scope (future)
- Add USB DFU runtime trigger so the firmware can reboot itself into bootloader mode on demand.
- Add a small TUI on the TFT showing per-transport activity counters (rx/tx by source).
- Move WebUSB landing URL to a project-owned domain.