Files
n_signer/plans/cyd_signer_port.md

20 KiB
Raw Permalink Blame History

Hardware Signer Port: ESP32-2432S028 ("CYD")

Goal

Port the existing 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/ 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(). 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 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

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:
    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_initlv_disp_drv with lvgl_flush_cb calling ili9341_blit_rgb565lv_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 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:
    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 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 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:
    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:

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 Nth 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 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 viewmain.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:
    #!/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 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/ 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:
    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 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 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).

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