# 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.
