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.
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.
| 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).
-
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 viatcp_export_enabled=trueinxlt_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 byhost_logger'swrite_files_to_dongleMCP 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'swrite_files_to_dongleMCP tool stages 1+ files into<captures>/disk_mirror.imgvia in-processfatfs(one mount/unmount for the whole batch), 512-byte sector-diffs old vs new, queues changed sectors inPENDING_PATCH, and triggersCMD: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 withyield_nowbetween each so the LED + Wi-Fi tasks keep ticking. -
CDC ACM HTTP proxy — composite USB device exposes a second interface (virtual COM port,
/dev/ttyACM0on Linux,COMxon 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 toserver_ip:cdc_http_port(defaults tohost_logger's:3245, seeBRIDGE_API.mdfor the full wire spec):Command Response Notes AT\r\n+OK\r\nLiveness, works without Wi-Fi AT+ECHO <text>\r\n+OK <text>\r\nLoopback, works without Wi-Fi AT+GET <path>\r\n+OK\r\n<HTTP/1.1 response>\r\n+END\r\nGET against server_ip:3299<path>AT+POST <path> <length>\r\n<body bytes>+OK\r\n<HTTP/1.1 response>\r\n+END\r\nPOST with Content-Type: application/json,<body>is the next<length>raw bytesAT+REBOOT\r\n+OK rebooting\r\nesp_hal::system::software_reset()— picks up changedxlt_settings.envwithout a physical replugUse 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 indicator —
indicator_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_asyncreturnedErr -
Stealth boot — kills the BootROM USB Serial JTAG pull-ups via direct
USB_DEVICE::CONF0register write before the OTG controller takes over the pins, so the host hears one device plug event instead of two. -
scsi.poll()outside theusb_dev.poll()conditional — required for the BOT layer to proactively fill the bulk-IN endpoint; the obviousif 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 exactlydCBWDataTransferLengthbytes so the BBB transport never has residue. Belt-and-suspenders alongside the upstream-usbd-storagepatch. -
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 pipeline —
sys_log!macro routes lines to an in-treehost_loggerRust 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.
| 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. |
- Rust toolchain via
espup:cargo install espup espup install
espflash:cargo install cargo-espflash espflash
Copy the example file and fill it in:
cp wifi_creds.txt.example wifi_creds.txtYourSSID
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.
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.)
cd host_logger
cargo run --releaseListens on UDP/3243. SQLite db lands at host_logger/target/release/logs.db.
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) |
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.
- 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-fatfsagainst theFlashDiskblock backend. esp-halpinned to1.0.0-rc.0— required byesp-hal-embassy 0.9.1's private dependency. Will lift once the rc cycle ends.
- The
usbd-storageBulk-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. Seevendor/usbd-storage/PATCH.mdfor the writeup. - Built on the work of @apohrebniak (
usbd-storage), the esp-rs team (esp-hal,esp-wifi,esp-storage), and the Embassy project.
MIT.