v0.0.28 - Finalize Feather TinyUSB migration and remove cardputer artifacts
This commit is contained in:
220
plans/feather_tinyusb_composite.md
Normal file
220
plans/feather_tinyusb_composite.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# 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`, product `Feather S3 TFT n_signer`
|
||||
- Authenticated CDC `get_public_key` returned the same pubkey as authenticated WebUSB `get_public_key` for the same caller key (confirming both transports go through the same dispatcher and key store).
|
||||
|
||||
See [`firmware/README.md`](../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:
|
||||
|
||||
1. 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).
|
||||
2. The **TinyUSB OTG stack** (managed by the `espressif/esp_tinyusb` component — 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/ttyACM0` driven by the kernel `cdc_acm` driver.
|
||||
- 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_event` with on-device approval, TFT status, button handling.
|
||||
|
||||
## 3. Non-goals
|
||||
|
||||
- Console / `printf` over 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
|
||||
|
||||
```mermaid
|
||||
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`:
|
||||
|
||||
```diff
|
||||
-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`](firmware/feather_s3_tft/main/webusb_transport.c:1) | 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`](firmware/feather_s3_tft/main/webusb_transport.h:1) | 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`](firmware/feather_s3_tft/main/main.c:1) | 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`](firmware/feather_s3_tft/main/CMakeLists.txt:1) | 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`](examples/feather_get_public_key.py:115) and [`feather_sign_event.py`](examples/feather_sign_event.py:162) 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`](examples/feather_webusb_demo.html:1) 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:
|
||||
|
||||
1. `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`
|
||||
2. Re-enable `CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y`.
|
||||
3. Build + bootloader-mode flash.
|
||||
4. Confirm `/dev/ttyACM0` returns 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
|
||||
|
||||
1. **7b.1** Apply sdkconfig changes (`defaults` and active `sdkconfig`).
|
||||
2. **7b.2** Rewrite descriptor block in the transport source: add IAD + CDC-ACM interface descriptors, keep Vendor descriptor, fix endpoint numbers, fix string indices.
|
||||
3. **7b.3** Add CDC framer (4-byte BE length, mirrors existing vendor framer) using `tud_cdc_*` API. Expose `cdc_recv_frame`, `cdc_send_frame` from the transport module.
|
||||
4. **7b.4** Strip `usb_serial_jtag_*` from [`main.c`](firmware/feather_s3_tft/main/main.c:1). Replace `recv_frame_stdin` / `send_frame_stdout` with calls to `cdc_recv_frame` / `cdc_send_frame`. Restore the dual-transport selection block.
|
||||
5. **7b.5** Re-enable transport init early in [`app_main()`](firmware/feather_s3_tft/main/main.c:858). Update TFT idle text to "usb cdc+webusb".
|
||||
6. **7b.6** Build. Flash via bootloader mode. Verify enumeration with `lsusb` and `ls /dev/ttyACM*`.
|
||||
7. **7b.7** Run all CDC validation steps in §9.5 – §9.7.
|
||||
8. **7b.8** Run WebUSB validation steps in §9.8 – §9.9.
|
||||
9. **7b.9** Update [`firmware/README.md`](firmware/README.md:1) with the "Flashing workflow" and "USB transport" sections.
|
||||
10. **7b.10** Bump firmware version string in [`main.c`](firmware/feather_s3_tft/main/main.c:25); update root [`README.md`](README.md:1) USB 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.
|
||||
Reference in New Issue
Block a user