Architecture
PrintDrop is one device wearing two hats: a USB mass storage device for the printer, and a Wi-Fi file server for everyone else. Both want the same SD card, and that is the whole design problem.
Module layout
src/printdrop/
main.cpp startup, serial console, button/LED
config.h pins, bus, auth, OTA, LED, timeouts
storage.cpp SD card and USB mass storage arbitration (SPI/SDIO)
net.cpp Wi-Fi provisioning, mDNS + LLMNR, static IP
web.cpp HTTP server + WebSocket (port 81) + JSON file API
auth.cpp SHA-256 Basic auth, NVS store
ota.cpp HTTP + SD-card OTA (Update)
ws.cpp WebSocket broadcaster (progress/status)
led.cpp heartbeat / activity LED
button.cpp eject + factory-reset button
src/diag/ SD and USB diagnostics
src/legacy/ USB mass storage only, no networking
src/common/ shared helpers (reflash hatch)
data/ web UI, flashed to LittleFS (now WS + auth + OTA)
Sharing the card
A USB host caches the FAT. If the ESP32 writes to the card while the printer has it mounted, the host's cached allocation table goes stale and the next write from either side corrupts the filesystem. The reverse is also true — the ESP32's own FATFS cache goes stale if the host writes sectors underneath it.
storage.cpp enforces three rules:
1. One mutex. Every SD access, from either side, is serialised. The MSC read/write callbacks and the HTTP handlers contend for the same lock.
2. Withdraw before writing. Before the ESP32 modifies the card, the media is
withdrawn from the USB host (msc.mediaPresent(false)), and re-presented
afterwards. The host sees a card removal, drops its cache, and re-reads the FAT
— which is what makes an uploaded file appear in the printer's file list without
a reboot.
3. Remount when the host writes. MSC write callbacks set a flag. Before the ESP32 next trusts its own view of the filesystem, it tears down and remounts FATFS.
Any code path that touches the card goes through storage::Guard:
storage::Guard g(/*forWrite=*/true);
if (!g.ok()) return sendError(503, "Card busy");
// ... card is exclusively ours, and the host cannot see it ...
Not stalling the printer
MSC callbacks run on the TinyUSB task. They take the mutex with a 2 second timeout and fail the transfer rather than block indefinitely:
if (xSemaphoreTake(sdMutex, pdMS_TO_TICKS(kMscLockTimeoutMs)) != pdTRUE) return -1;
This matters: a 20 MB upload holds the write lock for around 80 seconds. Without the timeout, a printer mid-job would block on a read for the whole upload. With it, the host sees the media as absent (because the write path withdrew it) and does not issue reads at all.
Networking
Credentials live in NVS via Preferences, never in the source tree. On boot:
- No stored SSID, or the stored network cannot be joined within 20 s →
raise a WPA2 access point (
PrintDrop-Setup) serving the same UI. - Otherwise join as a station, optionally with a static IP.
Either way mDNS (http://printdrop.local) and LLMNR (http://printdrop, single-label for Windows) are started on the same hostname via a minimal UDP 5355 responder (net.cpp:74). See docs/discovery.md.
A serial console on UART0 (help, status, wifi, hostname, auth/passwd/clear-auth/factory-reset, forget, reboot) provides headless provisioning without putting secrets in the repo. auth and ota state are also in status (auth {user} ota {version} discovery {mdns,llmnr}).
Web layer
A synchronous WebServer on port 80 + a WebSocketsServer on port 81. The UI is static files from LittleFS, pre-compressed copies preferred when present; everything else is a small JSON API (protected by Basic auth when PRINTDROP_AUTH_REQUIRED=1).
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/status |
GET | — | device, card, USB, network, auth, discovery, ota, wsPort |
/api/list |
GET | Basic | directory listing |
/api/upload |
POST | Basic | multipart; ?path= + WS progress ws://:81 |
/api/download |
GET | Basic | streams a file |
/api/delete, /api/mkdir, /api/rename |
POST | Basic | form-encoded |
/api/eject |
POST | Basic | withdraw and re-present the media |
/api/wifi/scan, /api/wifi |
GET / POST | Basic | provisioning |
/api/auth/status |
GET | — | {required,user} |
/api/auth/set |
POST | Basic | user+pass → SHA-256 NVS |
/api/ota/status |
GET | Basic | {current,sdAvailable,sdVersion} |
/api/ota |
POST | Basic | raw .bin → Update + reboot |
/api/ota/sd |
POST | Basic | flash SD:/firmware.bin per firmware.json |
WS on :81 broadcasts {"type":"progress",...} and {"type":"status",data:{...}}. Auth is HTTP Basic, SHA-256 hex in NVS (auth_user/auth_hash), seeded from platformio.ini PRINTDROP_AUTH_* (docs/auth.md). LED idle/activity/error blink and button eject/factory-reset are handled in main.cpp + led.cpp/button.cpp.
Client-supplied paths go through safePath(), which rejects anything
containing .. and normalises separators.
Uploads are driven by repeated callbacks, so the SD lock is acquired on
UPLOAD_FILE_START and released on UPLOAD_FILE_END or UPLOAD_FILE_ABORTED
rather than scoped to one function. An aborted upload deletes its partial file
— a truncated .gcode that looks like a valid print job is worse than no file.
Query-string arguments are parsed before the multipart body
(WebServer::_parseRequest), which is why ?path= is readable inside the
upload callback.
Build environments
| Env | Sources | USB mode | Purpose |
|---|---|---|---|
printdrop |
src/printdrop/ |
TinyUSB | The product — SDIO 4-bit (feat/sdio default) |
printdrop_spi |
src/printdrop/ |
TinyUSB | The product — SPI legacy (4-wire, no re-wire) |
msc |
src/legacy/ |
TinyUSB | USB mass storage only, no networking |
ramdisk |
src/diag/msc_ramdisk.cpp |
TinyUSB | RAM-backed FAT12 volume; proves USB MSC without the SD card |
diag |
src/diag/sd_diag.cpp |
Serial/JTAG | SPI speed sweep, geometry, MBR dump, root listing |
diag_sdio |
src/diag/sd_diag.cpp |
Serial/JTAG | SDIO 4-bit bring-up, bus width + throughput sweep |
scan |
src/diag/sd_scan.cpp |
Serial/JTAG | Pin health, line voltages, pin-permutation sweep |
The diagnostic environments deliberately keep ARDUINO_USB_MODE=1 so the native
port stays a serial/JTAG device and the board remains trivially flashable while
hardware is being investigated.
ramdisk is the key isolation tool: if it produces a drive on the host, the USB
path is proven good and any remaining fault is on the SD side.
Partitions
4 MB flash — dual OTA slots so HTTP/SD update does not brick the stick.
| Partition | Offset | Size | Holds |
|---|---|---|---|
nvs |
0x9000 |
20 KB | Wi-Fi, hostname, auth_* |
otadata |
0xE000 |
8 KB | OTA slot selection |
app0 |
0x10000 |
1344 KB | firmware ota_0 (~980 KB used) |
app1 |
0x160000 |
1344 KB | firmware ota_1 |
spiffs (LittleFS) |
0x2B0000 |
1280 KB | web UI (~62 KB used) |
coredump |
0x3F0000 |
64 KB | crash dumps |
partitions_printdrop_ota.csv (platformio.ini:25 per-env) is the OTA layout. The LittleFS partition keeps label spiffs because that is what LittleFS.begin() looks for and what uploadfs targets. printdrop_spi retains the old single-app partitions_printdrop.csv for bring-up without OTA. Disable OTA on tight builds with -D PRINTDROP_ENABLE_OTA=0 (config.h:135).
Measured performance
| Path | Throughput | Bus |
|---|---|---|
| USB read (uncached) | 485 KB/s | SPI @ 20 MHz |
| USB write | 248 KB/s | SPI @ 20 MHz |
| Raw SD | ~910 KB/s | SPI @ 20 MHz |
On SDIO 4-bit the same host at the same clock is ~3-4× faster — projected 3 200 KB/s read / 2 000 KB/s write / 3 500 KB/s raw at 20 MHz (stable on jumper wiring), so a 20 MB print job drops from ~80 s to ~6 s. The SDMMC host at 40 MHz can reach ~6 000 KB/s raw on a short breakout; see hardware.md.
On SPI the path is bounded by driving the card in SPI mode rather than 4-bit SDIO — the card, not the network, is the bottleneck. SDIO removes that bound.
Verified end to end over USB: a 2 MB write survives a SHA-256 round trip
(6EA73B45…C562 identical both sides), and the ESP32's own directory listing
matches what the host sees.