Skip to content

xelth-com/esp32s3rs

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

69 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

esp32s3rs — USB Mass Storage on ESP32-S3 in Rust

A bare-metal Rust firmware for the LilyGO T-Dongle-S3 (ESP32-S3) that exposes 12 MB of internal SPI flash as a USB Mass Storage Class (MSC) drive — and actually mounts cleanly on Windows without the 6–13 cycle enumeration loop most ESP32-S3 MSC examples suffer from.

Built around esp-hal + Embassy + usb-device + a patched fork of usbd-storage that fixes a Windows USBSTOR.sys interaction bug.

Why this exists

A naïve ESP32-S3 USB MSC implementation works fine on Linux but takes Windows 30+ seconds (often 5+ minutes) to mount, with the drive flashing through "needs reset" cycles. The root cause is upstream usbd-storage 2.0.0: when a SCSI data-IN response is shorter than the CBW's dCBWDataTransferLength (which happens for almost every Inquiry, ReadFormatCapacities, ModeSense, RequestSense), the BBB transport stalls the bulk-IN endpoint instead of just sending the Command Status Wrapper with dCSWDataResidue. Both behaviours are legal per the USB Mass Storage Bulk-Only Transport spec §6.7.3, but Windows USBSTOR.sys treats the stall on full-speed bus-powered devices as a fault and triggers a USB-level reset (~89 ms) followed by a 6–13 attempt re-enumeration loop with 20-second USBSTOR retry timeouts in between.

The included vendor/usbd-storage fork removes the offending stall_in_ep() call. After the fix, mount time drops from minutes to ~1.5 s on Linux (a slow-mounting embedded target with old kernel was the reference profile) and ~5 s on Windows.

Hardware

Component Notes
MCU ESP32-S3 (Xtensa dual-core, 240 MHz)
Flash 16 MB SPI NOR (memory-mapped XIP)
SRAM 512 KB (no PSRAM on this board)
USB USB-C on GPIO19 (D-) / GPIO20 (D+), shared between Serial JTAG and OTG
Display ST7735S 160×80 TFT (SPI2: MOSI=GPIO3, SCLK=GPIO5, CS=GPIO4, DC=GPIO2, RST=GPIO1, BL=GPIO38)
LED APA102 single-pixel RGB (SPI3: SCK=GPIO39, MOSI=GPIO40, 1 MHz, Mode 0)
Buttons BOOT (GPIO0)

Tested on the LilyGO T-Dongle-S3 but the firmware should run on any ESP32-S3 board with the same OTG pinout (most do).

Features

  • USB MSC drive backed by 12 MB of SPI flash via the ESP32-S3 MMU + esp-storage, with a 32 KB write-back cache and deferred flush (host-disconnect-driven or idle-timeout-driven).

  • Automatic TCP export — after 5 s of no SCSI Write activity, the firmware flushes dirty sectors to flash and ships them over TCP to host_logger (port 3244). The export fires while the device is still plugged in, so it works even on battery-less dongles where unplugging kills power. Opt-in via tcp_export_enabled=true in xlt_settings.env.

  • Bidirectional control channel — UDP listener on port 3246 accepts CMD:<action>:<delay_sec>:<idle_sec> packets from the host. Supported actions:

    • 0 (Remount) — drop D+ via the DWC2 OTG soft-disconnect bit (DCTL.SFTDISCON) for 3 s, then re-attach. Triggers a clean Windows re-enumeration without power-cycling the dongle.
    • 1 (Reboot) — esp_hal::system::software_reset().
    • 2 (PatchAndRemount) — detach, fetch sectors from the host's import listener (TCP/3247), apply them through the flash cache, re-attach. Used by host_logger's write_files_to_dongle MCP tool. Execution is gated on a delay timer plus an idle-guard so a remount never races a SCSI Write in flight.
  • TCP patch import (host → device) — multi-file batch patching: host_logger's write_files_to_dongle MCP tool stages 1+ files into <captures>/disk_mirror.img via in-process fatfs (one mount/unmount for the whole batch), 512-byte sector-diffs old vs new, queues changed sectors in PENDING_PATCH, and triggers CMD:2. The firmware drains the entire patch from TCP/3247 before touching flash so smoltcp doesn't backpressure-stall mid-flush; sectors are then applied with yield_now between each so the LED + Wi-Fi tasks keep ticking.

  • CDC ACM HTTP proxy — composite USB device exposes a second interface (virtual COM port, /dev/ttyACM0 on Linux, COMx on Windows) alongside the MSC disk. An on-host process can speak a tiny ASCII AT-protocol to make plain HTTP/1.1 requests through the dongle's Wi-Fi to server_ip:cdc_http_port (defaults to host_logger's :3245, see BRIDGE_API.md for the full wire spec):

    Command Response Notes
    AT\r\n +OK\r\n Liveness, works without Wi-Fi
    AT+ECHO <text>\r\n +OK <text>\r\n Loopback, works without Wi-Fi
    AT+GET <path>\r\n +OK\r\n<HTTP/1.1 response>\r\n+END\r\n GET against server_ip:3299<path>
    AT+POST <path> <length>\r\n<body bytes> +OK\r\n<HTTP/1.1 response>\r\n+END\r\n POST with Content-Type: application/json, <body> is the next <length> raw bytes
    AT+REBOOT\r\n +OK rebooting\r\n esp_hal::system::software_reset() — picks up changed xlt_settings.env without a physical replug

    Use case: host devices that have lost their own Wi-Fi can still talk to a LAN server through the dongle. Backpressured cdc_send + 64-byte carry buffers on both TX and RX ensure full byte fidelity even when the host stops reading the bulk-IN endpoint mid-response.

  • APA102 RGB indicatorindicator_task (30 ms tick on SPI3) drives a single APA102 LED with five states:

    State Color Meaning
    0 white boot / pre-Wi-Fi
    1 blue Wi-Fi up, idle
    2 yellow SCSI Write in flight (CAS-resets to 1 next tick so it reads as a flash, not a steady glow)
    3 green pulse TCP export or patch import in progress
    4 red Wi-Fi disconnected or connect_async returned Err
  • Stealth boot — kills the BootROM USB Serial JTAG pull-ups via direct USB_DEVICE::CONF0 register write before the OTG controller takes over the pins, so the host hears one device plug event instead of two.

  • scsi.poll() outside the usb_dev.poll() conditional — required for the BOT layer to proactively fill the bulk-IN endpoint; the obvious if usb_dev.poll() { scsi.poll(); } pattern silently breaks SCSI Read.

  • alloc_len-padded SCSI responses — every data-IN command (Inquiry, ReadFormatCapacities, ModeSense, RequestSense, EVPD pages, …) writes exactly dCBWDataTransferLength bytes so the BBB transport never has residue. Belt-and-suspenders alongside the upstream-usbd-storage patch.

  • FAT32 ClnShut auto-clear on flush — patches FAT[1] bit 27 to "clean shutdown" before persisting the boot-sector window. Suppresses the Windows "Bei diesem Laufwerk liegt ein Problem vor" / CHKDSK dialog when the user yanks the cable mid-session.

  • WiFi-gated USB enumeration — OTG init waits up to 5 s for WiFi to associate before pulling D+ high. esp-wifi's association burst is CPU-heavy and was causing DWC2 SOF starvation when WiFi raced USB enumeration.

  • UDP telemetry pipelinesys_log! macro routes lines to an in-tree host_logger Rust binary listening on UDP/3243, which writes SQLite and exposes an MCP server for log queries.

  • ST7735 terminal driver — bit-packed 200-byte buffer, no framebuffer, fed from sys_log! so logs are visible regardless of OTG state.

Network ports (firmware ↔ host_logger)

Port Direction Purpose
UDP 3243 dev → host sys_log! text + binary data frames (telemetry)
TCP 3244 dev → host dirty-sector export (disk_mirror.img rebuild)
HTTP 3245 client → host MCP HTTP/SSE server (tools/list, tools/call)
UDP 3246 host → dev control channel (CMD:<action>:<delay>:<idle>)
TCP 3247 dev → host patch import — firmware fetches [LBA u32 BE][512 B] tuples
TCP 3299 dev → host plain HTTP/1.1 target for the CDC ACM proxy (AT+GET / AT+POST). Plain port because the dongle's embassy-net build carries no TLS stack.

Quick start

Prerequisites

  • Rust toolchain via espup:
    cargo install espup
    espup install
  • espflash:
    cargo install cargo-espflash espflash

Configure WiFi (optional, for UDP telemetry)

Copy the example file and fill it in:

cp wifi_creds.txt.example wifi_creds.txt
YourSSID
YourPassword

wifi_creds.txt is gitignored — your secrets stay local. The credentials are also overridable at runtime via xlt_settings.env on the user-visible FAT volume (see src/settings.rs).

Either drop a xlt_settings.env with server_ip=<your.lan.ip> on the FAT root (preferred — no recompile), or edit src/settings.rs::Settings::const_default():

server_ip: [192, 168, 0, 2], // your log server's LAN IP — generic placeholder by default

(HOST_IP_FALLBACK in src/main.rs is the last-resort default; in practice const_default() wins because settings::get() always returns Some(...).)

If you don't need WiFi telemetry, the firmware will hit the 5 s WiFi gate timeout and continue without networking.

Build & flash

cargo +esp build --release
espflash flash --port COM9 --monitor target/xtensa-esp32s3-none-elf/release/esp32s3rs

(Replace COM9 with whatever serial port your board exposes. On Linux/macOS it'll be /dev/ttyACM0 or similar.)

Run the log server (optional)

cd host_logger
cargo run --release

Listens on UDP/3243. SQLite db lands at host_logger/target/release/logs.db.

host_logger MCP tools

The host_logger binary serves an MCP-over-HTTP/SSE endpoint on port 3245. Tools available:

Tool Purpose
query_sqlite Raw SQL against the logs table
get_log_summary Grouped count per module/level over the last N minutes
get_recent_logs Filtered fetch by module/level/since-window
list_sessions Auto-segment logs into USB sessions (boot or state:Default boundaries)
get_session Merged timeline (state + SCSI + writes) for one session
compare_sessions Side-by-side delta-t comparison of two sessions
session_summary Aggregate report across all detected sessions
send_control_cmd Send CMD:<action>:<delay>:<idle> over UDP/3246
write_files_to_dongle Inject 1+ files into the FAT root via fatfs + sector-diff + CMD:2 (Phase 2 bidirectional bridge)

Project layout

src/
  main.rs        # Embassy entry, Wi-Fi, UDP telemetry, control_task,
                 # tcp_export_task, indicator_task (APA102 LED),
                 # cdc_proxy_task (AT-protocol HTTP proxy).
  usb.rs         # USB MSC (SCSI/BOT) + CDC ACM composite, 12 MB flash
                 # disk, FAT32 clean-bit fix, patch_and_remount (TCP
                 # import + flash apply), CDC byte-shuttle with TX/RX
                 # carry buffers (kills backpressure-induced byte loss).
  display.rs     # ST7735 terminal driver.
  settings.rs    # FAT-volume-resident config (xlt_settings.env).

vendor/
  usbd-storage/  # Patched fork — removes Windows-hostile bulk-IN stall.
                 # See vendor/usbd-storage/PATCH.md for the diff and
                 # rationale, intended for upstream submission.

host_logger/     # x86-64 binary. Sub-modules:
                 #   receivers.rs — UDP/3243 + TCP/3244 export listener
                 #     + TCP/3247 import listener + stage_files_to_mirror
                 #   mcp.rs       — HTTP/SSE MCP server on 3245
                 #   db.rs        — SQLite schema + queries
                 #   sessions.rs  — log → session segmentation

test_remount.ps1     # End-to-end test for CMD:0 (Remount).
test_patch.ps1       # End-to-end test for single-file PatchAndRemount.
test_multifile.ps1   # End-to-end test for multi-file batch patch.
                     # All three are Russian-language, UTF-8 BOM, expect
                     # the dongle on the same LAN as host_logger.

.eck/            # Internal manifests for the Royal Court / eckSnapshot
                 # workflow used during development. Not required to
                 # build or use the firmware.

Known limitations

  • Full-speed only (12 Mbps) — the ESP32-S3 OTG internal PHY doesn't do high-speed. An external USB 2.0 HS PHY on ULPI would lift the cap to 480 Mbps. Not implemented.
  • ~5 s mount on Windows — after our fixes, this is just the standard USBSTOR pre-mount delay for full-speed bus-powered MSC devices. Not avoidable from the firmware side.
  • No FAT-aware writes — we expose raw block storage; the host formats/uses FAT. If you want firmware-side file I/O, drop in embedded-fatfs against the FlashDisk block backend.
  • esp-hal pinned to 1.0.0-rc.0 — required by esp-hal-embassy 0.9.1's private dependency. Will lift once the rc cycle ends.

Acknowledgments

  • The usbd-storage Bulk-Only Transport bug was diagnosed and patched by Claude Opus 4.7 (Anthropic) during a long debugging session against extensive USB protocol logs from the in-tree UDP telemetry pipeline. See vendor/usbd-storage/PATCH.md for the writeup.
  • Built on the work of @apohrebniak (usbd-storage), the esp-rs team (esp-hal, esp-wifi, esp-storage), and the Embassy project.

License

MIT.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors