332 lines
20 KiB
Markdown
332 lines
20 KiB
Markdown
# Hardware Signer Port: ESP32-2432S028 ("CYD")
|
||
|
||
## Goal
|
||
|
||
Port the existing [`firmware/feather_s3_tft/`](../firmware/feather_s3_tft/) hardware signer to the
|
||
**ESP32-2432S028 "Cheap Yellow Display" (CYD)** board, keeping the on-the-wire protocol identical
|
||
so the existing [`client/`](../client/) library and the broader n_signer dispatcher stack are
|
||
unchanged. The new firmware lives at `firmware/cyd_esp32_2432s028/` and coexists with the feather
|
||
firmware — both boards are supported targets from this point forward.
|
||
|
||
A working prototype/test bed of every CYD peripheral we depend on already exists at
|
||
`/home/user/lt/esp32_playground/cyb-esp32-2432s028/`. This plan reuses those drivers wholesale.
|
||
|
||
## Hardware comparison (what actually changes)
|
||
|
||
| Concern | feather_s3_tft (existing) | CYD ESP32-2432S028 (new) |
|
||
| -------------------- | ----------------------------------------- | -------------------------------------------------------- |
|
||
| MCU | ESP32-S3 | ESP32-WROOM-32 (classic, dual-core Xtensa) |
|
||
| Native USB | Yes (TinyUSB CDC + Vendor/WebUSB) | **No** — CH340 USB-UART bridge only |
|
||
| Host transport | WebUSB (Vendor) + CDC | UART0 framed over CH340; browser uses **Web Serial API** |
|
||
| Display | 240×135 ST7789 | 240×320 ILI9341 (~6× more pixel area) |
|
||
| Display bus | SPI | SPI (HSPI), DC=IO2, CS=IO15, SCK=IO14, MOSI=IO13 |
|
||
| Backlight | GPIO45 | GPIO21 (PWM via LEDC) |
|
||
| Input | 3 tactile buttons (D0/D1/D2) | XPT2046 resistive **touchscreen** + BOOT button (IO0) |
|
||
| Touch bus | n/a | Bit-banged SPI on IO25/32/33/36/39 |
|
||
| Extras available | NeoPixel, battery monitor | SD card, RGB LED (IO4/16/17), LDR (IO34), speaker (IO26) |
|
||
| Flash | 4 MB | 4 MB |
|
||
| PSRAM | Yes (quad) | No |
|
||
| GUI framework | Hand-rolled framebuffer + `font5x7.h` | **LVGL 8.3** (touch + larger screen need it) |
|
||
|
||
Two implications are load-bearing for the rest of this plan:
|
||
|
||
1. **Transport.** Frames already pass through a byte stream (length-prefix, JSON-RPC payload) via
|
||
[`cdc_send_frame()` / `cdc_recv_frame()`](../firmware/feather_s3_tft/main/usb_transport.c:1).
|
||
Swap the TinyUSB backend for UART0 and the dispatcher above it is unchanged. WebUSB is **not
|
||
reachable on this hardware** (see "WebUSB note" below); the browser-side replacement is the Web
|
||
Serial API.
|
||
|
||
2. **GUI.** The touchscreen + 240×320 panel makes the hand-rolled text UI a poor fit. The playground
|
||
already proves LVGL 8.3 works end-to-end on this board (see `11_lvgl_demo` and especially
|
||
`12_lvgl_keyboard`, which is a working LVGL on-screen keyboard — exactly what mnemonic entry
|
||
needs). Adopt LVGL for the new firmware.
|
||
|
||
## WebUSB note (for the record)
|
||
|
||
WebUSB requires a native USB device with BOS + WebUSB platform descriptors. The CYD's MCU has no
|
||
USB peripheral; the USB jacks connect to a CH340 UART bridge. The host sees a generic
|
||
`VID:1A86 PID:7523` serial port — no descriptors we control, no Vendor interface, no WebUSB.
|
||
|
||
The functional equivalent on this hardware is **Web Serial** (`navigator.serial`), Chromium-only
|
||
but supported in Chrome / Edge / Brave / Opera. For Firefox/Safari users, the existing
|
||
[`plans/nsigner_browser_extension.md`](nsigner_browser_extension.md) path (native messaging host
|
||
+ extension) remains the fallback — same wire protocol, different bridge.
|
||
|
||
Action item for the `nostr_login_lite` integration: feature-detect both transports and prefer
|
||
`navigator.usb` when a feather is plugged in, `navigator.serial` when a CYD is plugged in. One
|
||
build supports both signers.
|
||
|
||
## Target directory layout
|
||
|
||
```
|
||
firmware/cyd_esp32_2432s028/
|
||
├── CMakeLists.txt
|
||
├── partitions.csv # same layout as feather, 4MB flash
|
||
├── sdkconfig.defaults # target=esp32, no PSRAM, no TinyUSB
|
||
├── version.cmake # mirror feather's GIT_HASH injection
|
||
├── dependencies.lock # generated
|
||
├── components/
|
||
│ └── secp256k1/ # symlink or copy from feather (CMakeLists already vendored)
|
||
└── main/
|
||
├── CMakeLists.txt
|
||
├── idf_component.yml # depends on lvgl/lvgl ^8.3.11
|
||
├── lv_conf.h # copied from playground 12_lvgl_keyboard
|
||
├── main.c # dispatch loop, ported from feather main.c
|
||
├── ili9341.c / ili9341.h # display driver from playground
|
||
├── display.c / display.h # thin shim presenting the same API surface feather uses,
|
||
│ # implemented in terms of LVGL primitives (so the n_signer
|
||
│ # dispatcher's calls like display_clear() still work)
|
||
├── touch.c / touch.h # XPT2046 bit-bang driver + calibration helpers
|
||
├── ui_lvgl.c / ui.h # LVGL screens (mnemonic entry, approval, idle)
|
||
├── input.c / input.h # approval primitives (approve_once / always / deny / timeout)
|
||
├── uart_transport.c / uart_transport.h # replaces usb_transport.c
|
||
├── nvs_calib.c / nvs_calib.h # touch calibration persistence
|
||
├── bech32.c / bech32.h # copied from feather
|
||
├── mnemonic.c / mnemonic.h # copied from feather
|
||
├── mnemonic_wordlist.h # copied from feather
|
||
├── key_derivation.c / .h # copied from feather
|
||
└── secure_mem.c / .h # copied from feather
|
||
```
|
||
|
||
Shared files that should not be duplicated (`bech32`, `mnemonic`, `key_derivation`, `secure_mem`)
|
||
will be copied for now to keep the two firmware trees independent. A future cleanup task can
|
||
extract them into a shared component under `firmware/common/` once both boards are stable.
|
||
|
||
## Architecture overview
|
||
|
||
```mermaid
|
||
flowchart TB
|
||
subgraph Host
|
||
Browser[Browser / nostr_login_lite] --> WS[navigator.serial]
|
||
Native[Native nsigner / Qubes RPC] --> Tty[/dev/ttyUSB0]
|
||
WS --> Tty
|
||
end
|
||
|
||
subgraph CYD_Firmware
|
||
Tty --> UART[uart_transport.c]
|
||
UART --> Disp[dispatcher in main.c]
|
||
Disp --> Sign[sign_event / get_public_key / nip04 / nip44]
|
||
Disp --> Approve[approval flow]
|
||
Approve --> UI[ui_lvgl.c - approval screen]
|
||
UI --> Touch[XPT2046 touch.c]
|
||
UI --> LCD[ILI9341 ili9341.c]
|
||
Sign --> Keys[key_derivation.c + secure_mem.c]
|
||
end
|
||
```
|
||
|
||
Same diagram as feather, with UART replacing TinyUSB and LVGL replacing the hand-rolled
|
||
framebuffer. The `dispatcher` / `sign_event` / `approval` boxes are byte-for-byte the same code.
|
||
|
||
## Detailed implementation phases
|
||
|
||
### Phase 1 — project skeleton (no signer logic yet)
|
||
|
||
Goal: get a blinking-LED-equivalent firmware that compiles, flashes, and shows "n_signer" on the
|
||
LCD. No transport, no signing, no LVGL yet.
|
||
|
||
1. Create `firmware/cyd_esp32_2432s028/` with `CMakeLists.txt`, `partitions.csv` (copied from
|
||
feather, both bootloader and app slots fit in 4 MB without OTA), and `sdkconfig.defaults`:
|
||
```
|
||
CONFIG_IDF_TARGET="esp32"
|
||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384
|
||
CONFIG_FREERTOS_HZ=1000
|
||
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
|
||
# UART0 stays the console + transport. Keep it on stock pins (IO1 TX, IO3 RX).
|
||
```
|
||
2. Vendor the display driver from `/home/user/lt/esp32_playground/cyb-esp32-2432s028/02_hello_tft/main/ili9341.c` and `.h` into `main/`.
|
||
3. Add `main/CMakeLists.txt` registering only `main.c` and `ili9341.c`, with `REQUIRES esp_driver_gpio esp_driver_spi`.
|
||
4. Write a stub `main.c` that calls `ili9341_init()`, fills black, draws `"n_signer cyd"` at
|
||
(10,10), and idles. Exit criterion: flash via `idf.py -p /dev/ttyUSB0 flash` and see text on
|
||
the LCD.
|
||
|
||
### Phase 2 — peripherals and LVGL
|
||
|
||
Goal: LVGL on the device with a touch-driven hello-world.
|
||
|
||
1. Vendor the XPT2046 bit-bang driver from `12_lvgl_keyboard/main/main.c` (extract into
|
||
`touch.c` / `touch.h` with `touch_init()`, `touch_read_raw()`, `touch_read_calibrated()`,
|
||
`touch_run_calibration()`).
|
||
2. Vendor `lv_conf.h` from `12_lvgl_keyboard/main/lv_conf.h`.
|
||
3. Add `main/idf_component.yml`:
|
||
```yaml
|
||
dependencies:
|
||
idf: { version: ">=5.0.0" }
|
||
lvgl/lvgl: "^8.3.11"
|
||
```
|
||
4. In `main.c`, port the LVGL bring-up sequence verbatim from `12_lvgl_keyboard`:
|
||
`lv_init` → `lv_disp_drv` with `lvgl_flush_cb` calling `ili9341_blit_rgb565` → `lv_indev_drv`
|
||
with `lvgl_touch_read_cb` calling `touch_read_calibrated` → 2 ms periodic `esp_timer` for
|
||
`lv_tick_inc(2)` → loop `lv_timer_handler()` + `vTaskDelay(5)`.
|
||
5. **Touch calibration persistence.** Add `nvs_calib.c`:
|
||
- `nvs_calib_load(touch_calib_t *out)` returns `ESP_ERR_NVS_NOT_FOUND` on first boot.
|
||
- `nvs_calib_save(const touch_calib_t *in)`.
|
||
- On first boot, run the 4-corner calibration UI from `12_lvgl_keyboard`, save to NVS, never
|
||
prompt again unless the user holds BOOT (IO0) during reset to force re-calibration.
|
||
6. Build a throwaway LVGL screen with `lv_label` "Touch me" and a button that flips a label —
|
||
verifies touch + flush end to end.
|
||
|
||
### Phase 3 — UART transport
|
||
|
||
Goal: byte-for-byte protocol parity with feather, over UART instead of USB.
|
||
|
||
1. Read the existing frame protocol in [`firmware/feather_s3_tft/main/usb_transport.c`](../firmware/feather_s3_tft/main/usb_transport.c) carefully — note the 4-byte big-endian length prefix, the max frame size (`USB_MAX_FRAME = 4096`), and the ring buffer semantics.
|
||
2. Implement `uart_transport.c` exposing identical signatures:
|
||
```c
|
||
int uart_transport_init(void);
|
||
int cdc_recv_frame(uint8_t *payload, size_t payload_max, size_t *out_len);
|
||
int cdc_send_frame(const uint8_t *payload, size_t len);
|
||
bool cdc_is_connected(void); /* always true for UART once init runs */
|
||
```
|
||
Keep the names `cdc_send_frame` / `cdc_recv_frame` so [`main.c`](../firmware/feather_s3_tft/main/main.c:152-158) needs no changes.
|
||
3. Use `driver/uart.h`, `UART_NUM_0`, 115200 baud, 8N1, hardware flow control disabled. RX into a
|
||
FreeRTOS-queue-backed event task, then drain into the same length-prefix ring buffer logic the
|
||
feather uses.
|
||
4. **Console contention.** UART0 is also where ESP_LOGI prints. Two options:
|
||
- Route `esp_log` to UART1 on IO22/IO27 (the CN1 connector) so devs can still see logs without
|
||
disturbing the protocol byte stream. Set `CONFIG_ESP_CONSOLE_UART_CUSTOM=y`,
|
||
`CONFIG_ESP_CONSOLE_UART_NUM=1`, `CONFIG_ESP_CONSOLE_UART_TX_GPIO=22`.
|
||
- Or silence logs in release builds (`CONFIG_LOG_DEFAULT_LEVEL_NONE=y`).
|
||
Recommendation: route logs to UART1. The CN1 connector is exactly the broken-out pin that
|
||
developers will solder a header to.
|
||
5. Smoke-test with a small Python script that opens `/dev/ttyUSB0`, sends a `{"jsonrpc":"2.0","method":"ping"}` frame, and prints the reply.
|
||
|
||
### Phase 4 — port shared crypto + dispatcher
|
||
|
||
Goal: signer logic running, with a stub UI that auto-approves everything.
|
||
|
||
1. Copy these files verbatim from `firmware/feather_s3_tft/main/` to `firmware/cyd_esp32_2432s028/main/`: `bech32.c/.h`, `mnemonic.c/.h`, `mnemonic_wordlist.h`, `key_derivation.c/.h`, `secure_mem.c/.h`.
|
||
2. Reference the `nostr_core_lib` sources from `CMakeLists.txt` the same way feather does (relative paths to `../../../resources/nostr_core_lib/`).
|
||
3. Copy [`firmware/feather_s3_tft/main/main.c`](../firmware/feather_s3_tft/main/main.c) as a starting point. Replace the includes of `buttons.h`, `ui.h`, `display.h` with the new headers (added in Phase 5). For now, stub:
|
||
```c
|
||
static approval_decision_t wait_for_approval(uint32_t timeout_ms) {
|
||
(void)timeout_ms;
|
||
return APPROVAL_ONCE; /* PHASE 4 STUB - REMOVE BEFORE MERGE */
|
||
}
|
||
```
|
||
4. Hard-code a known test mnemonic into `s_mnemonic` so we can exercise `get_public_key` and
|
||
`sign_event` without an entry UI yet.
|
||
5. Exit criterion: from the host, `get_public_key` returns the expected npub for the hard-coded
|
||
mnemonic, and `sign_event` returns a valid Schnorr signature.
|
||
|
||
### Phase 5 — LVGL signer UI
|
||
|
||
Goal: real user-facing flows on the LCD/touch.
|
||
|
||
Screens and state machine:
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> Splash
|
||
Splash --> MainMenu : 1s
|
||
MainMenu --> Generate : tap Generate
|
||
MainMenu --> EnterMnemonic : tap Enter
|
||
MainMenu --> View : tap View (only if has_phrase)
|
||
Generate --> ConfirmMnemonic
|
||
EnterMnemonic --> ConfirmMnemonic
|
||
ConfirmMnemonic --> Idle : confirmed
|
||
View --> Idle : back
|
||
Idle --> Approval : RPC sign_event request
|
||
Approval --> Idle : decision sent
|
||
```
|
||
|
||
Screen designs (240×320, portrait):
|
||
|
||
- **Splash.** Centered "N_SIGNER", red, large font. 1 s timeout → MainMenu.
|
||
- **MainMenu.** Three large buttons (full-width minus padding, ~60 px tall): "Generate new", "Enter existing", "View current" (disabled until a phrase exists). Footer status bar with firmware version and "ready" / "no mnemonic".
|
||
- **Generate.** Show 12-word mnemonic with each word numbered, in a scrollable list. Bottom: "I wrote it down" button (large, primary). Pressing it advances to ConfirmMnemonic.
|
||
- **ConfirmMnemonic.** Pick the *N*th word from a 4-choice multiple-select to confirm the user actually wrote it down. Two random positions tested. Wrong answer → back to Generate.
|
||
- **EnterMnemonic.** Top: word count chip ("3 / 12"). Middle: list of entered words. Bottom: `lv_keyboard` in lowercase mode. User taps letters → an `lv_textarea` above the keyboard shows the prefix → autocomplete matches are shown as 3 chips above the textarea (tap to commit). This is exactly the [`12_lvgl_keyboard`](../../esp32_playground/cyb-esp32-2432s028/12_lvgl_keyboard) layout adapted for BIP-39 wordlist autocomplete.
|
||
- **Idle.** Top: "nostr:" + truncated npub (first 11, ellipsis, last 6). Middle: short mnemonic preview (first 2 words, ellipsis, last 1). Bottom: "v0.0.1 ready". This is the steady state where the firmware waits for RPC.
|
||
- **Approval.** Red header "Approve sign request?". Body: caller pubkey (truncated), event kind, content preview (3 lines, ellipsized). Bottom: three large buttons side-by-side: green "Once", amber "Always", red "Deny". A 30 s countdown bar at the very bottom — when it empties, deny is auto-selected.
|
||
|
||
Implementation steps:
|
||
|
||
1. Create `ui_lvgl.c` with `ui_show_splash()`, `ui_run_until_working(char out_mnemonic[256])`, `ui_show_idle(const char *npub, const char *mnemonic_short)`, `ui_show_approval(const char *caller, int kind, const char *content_preview, approval_decision_t *out, uint32_t timeout_ms)`.
|
||
2. The approval call is **synchronous from the dispatcher's point of view** — `main.c` calls it and blocks waiting for a decision. Internally it builds the LVGL screen, registers button callbacks that store the decision into a static, posts to a `xSemaphoreCreateBinary`, returns, and tears the screen down.
|
||
3. The mnemonic-entry flow uses a custom `lv_keyboard` event filter: only accept letters present in any BIP-39 word that matches the current prefix. After 3 unambiguous letters, auto-commit the word.
|
||
4. Replace the Phase 4 stub `wait_for_approval` with a real call into `ui_show_approval`.
|
||
|
||
### Phase 6 — build + flash plumbing
|
||
|
||
1. `scripts/flash_cyd.sh`:
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
set -e
|
||
PORT="${PORT:-/dev/ttyUSB0}"
|
||
cd firmware/cyd_esp32_2432s028
|
||
idf.py -p "$PORT" -b 460800 flash monitor
|
||
```
|
||
2. Top-level [`Makefile`](../Makefile) gets new targets paralleling the existing feather ones:
|
||
```
|
||
cyd-build:
|
||
cd firmware/cyd_esp32_2432s028 && idf.py build
|
||
cyd-flash:
|
||
./flash_cyd.sh
|
||
cyd-monitor:
|
||
cd firmware/cyd_esp32_2432s028 && idf.py -p $${PORT:-/dev/ttyUSB0} monitor
|
||
cyd-clean:
|
||
cd firmware/cyd_esp32_2432s028 && idf.py fullclean
|
||
```
|
||
3. Document driver requirements (CH340 kernel module, `dialout` group membership) in `firmware/README.md`.
|
||
|
||
### Phase 7 — host side (Web Serial)
|
||
|
||
1. Add a `transport_serial.ts` (or `.js`) to `nostr_login_lite` and to [`examples/`](../examples/) mirroring the existing WebUSB transport but using `navigator.serial`. Same length-prefixed frame protocol; the only differences are connection setup and read/write APIs.
|
||
2. Add a transport-selection shim:
|
||
```js
|
||
async function connectSigner() {
|
||
// Prefer the feather (WebUSB) if available.
|
||
try {
|
||
const dev = await navigator.usb.requestDevice({ filters: [{ vendorId: 0x303A }] });
|
||
return new WebUsbTransport(dev);
|
||
} catch (_) {}
|
||
const port = await navigator.serial.requestDevice({
|
||
filters: [{ usbVendorId: 0x1A86, usbProductId: 0x7523 }], // CH340
|
||
});
|
||
await port.open({ baudRate: 115200 });
|
||
return new WebSerialTransport(port);
|
||
}
|
||
```
|
||
3. Update [`examples/feather_webusb_demo.html`](../examples/feather_webusb_demo.html) or add a new
|
||
`examples/cyd_webserial_demo.html` that exercises `get_public_key` and `sign_event` end-to-end.
|
||
|
||
### Phase 8 — testing and docs
|
||
|
||
1. Manual test matrix on real hardware:
|
||
- First-boot calibration runs.
|
||
- Generate → confirm → idle → sign_event from host → approval prompt → signature returned.
|
||
- Enter existing mnemonic via on-screen keyboard.
|
||
- Approval timeout fires correctly (set timeout to 5 s for the test).
|
||
- "Always" approval persists for the session.
|
||
- Power cycle, re-enter mnemonic, verify same npub (proves key derivation parity with feather).
|
||
2. Cross-board parity test: same mnemonic on a feather and a CYD, both sign the same event,
|
||
signatures verify against the same npub.
|
||
3. Add a `tests/test_uart_transport.c` host-side framing test (we can't unit-test the device-side
|
||
UART, but the framing helpers are pure functions and testable).
|
||
4. Update [`firmware/README.md`](../firmware/README.md) with a "Supported boards" table and per-board flashing instructions.
|
||
|
||
## Deferred decisions
|
||
|
||
- **LVGL GUI designer.** EEZ Studio (GPL-3, mature) vs the official LVGL Editor (MIT, in beta) vs hand-written. Decision punted until the firmware is working — by then we'll know whether the screens are stable enough to be worth round-tripping through a designer.
|
||
- **Shared `firmware/common/` component.** The duplicated `bech32` / `mnemonic` / `key_derivation` / `secure_mem` files are a future refactor target once both boards are stable.
|
||
- **OTA updates.** Both boards currently use single-app partition tables. Out of scope for this port.
|
||
- **Locked-down release config.** Disabling JTAG, enabling flash encryption, secure boot — these apply equally to both boards and should be a separate hardening pass.
|
||
|
||
## Risks and mitigations
|
||
|
||
- **UART0 console contention.** Mitigated by routing logs to UART1 (Phase 3, step 4). If a dev forgets to reroute, the host will see log spam interleaved with frames and the framing layer will reject them — fail-loud, not silent corruption.
|
||
- **LVGL RAM footprint.** Classic ESP32 has 512 KB internal SRAM, no PSRAM on the CYD. LVGL 8.3 with a 240×320 single buffer at 12-row stripes is ~5.6 KB; full double-buffer would be ~150 KB. Use the partial-buffer mode from playground 11 (already proven). Watch heap with `esp_get_free_heap_size()` in `idf.py monitor` once the signer is wired in — secp256k1 verification can spike usage.
|
||
- **Touchscreen drift.** Resistive panels drift with temperature and age. The "hold BOOT during reset to recalibrate" escape hatch (Phase 2, step 5) means a stuck calibration never bricks the device.
|
||
- **CH340 driver on macOS / Windows.** Macs need a kext or DriverKit driver, Windows 10+ ships one. Document this in `firmware/README.md`; not a code problem.
|
||
- **Web Serial browser support.** Chromium-only. Firefox/Safari users fall back to the native-bridge path that the desktop signer already uses ([`plans/nsigner_browser_extension.md`](nsigner_browser_extension.md)).
|
||
|
||
## Acceptance criteria
|
||
|
||
The port is "done" when all of the following are true:
|
||
|
||
1. `make cyd-build && make cyd-flash` produces a working device from a clean checkout.
|
||
2. From the same mnemonic, a CYD and a feather produce the same npub and identical Schnorr signatures for the same event payload.
|
||
3. The Web Serial example page in `examples/` can connect to a CYD and successfully sign an event end-to-end, including the on-device approval prompt.
|
||
4. The hand-written feather firmware is unchanged by this work (no regressions on the existing board).
|