Skip to content

USB Mass Storage on SPI-SD boards, and a signal for the cable being pulled - #74

Open
jpirnay wants to merge 3 commits into
Free-Ink:mainfrom
jpirnay:pr/usb-msc-spi-sd
Open

USB Mass Storage on SPI-SD boards, and a signal for the cable being pulled#74
jpirnay wants to merge 3 commits into
Free-Ink:mainfrom
jpirnay:pr/usb-msc-spi-sd

Conversation

@jpirnay

@jpirnay jpirnay commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

USB Mass Storage was reachable only from the native-SDMMC backend, because detachFilesystemForRawAccess() lived behind FREEINK_SD_SDMMC. That kept the capability to the X4 Pro / de-link / Paper Mono class of board and locked out every SPI-SD board. This opens it to them, and adds the one signal an ESP32-S3 needs in order to notice that the cable was pulled.

Three additive commits. No board changes behaviour unless its own env sets FREEINK_CAP_USB_MSC.

1. The SPI path needs no second block-device driver

SdFat's SdCardInterface already derives from FsBlockDeviceInterface and implements the same readSector(s)/writeSector(s) contract SdmmcBlockDevice does — the card object is the block device. So the SPI path only has to drop the FsVolume while keeping the card session alive: FsVolume::end(), deliberately not SdFat::end(), which would also end the card session the USB host is about to read through. Remounting goes back through begin(), whose sd.begin() re-runs SdCard::begin() on the same factory-owned card object.

rawBlockDevice() now answers on both backends and returns the interface type rather than the SDMMC-specific one. It had no callers, so this is not a break.

2. USE_BLOCK_DEVICE_INTERFACE has to be on for that to compile

SdSpiCard derives from FsBlockDeviceInterface only when USE_BLOCK_DEVICE_INTERFACE (or HAS_SDIO_CLASS) is set; otherwise it is a plain concrete class and sd.card() cannot be returned as one. SdFat compiles as its own library, so the option has to be appended to every lib builder's env — which is exactly what the SDCardManager build hook already does for USE_UTF8_LONG_NAMES, so it grows a second, conditional define.

It is coupled to FREEINK_CAP_USB_MSC rather than turned on globally, to keep the vtable and the indirect sector calls off boards that would gain nothing — notably the C3, which has no USB-OTG peripheral and can never serve MSC at all. The raw-access functions are guarded to match and link as nullptr-returning stubs when the option is absent, so a board that never asked for USB Drive still builds unchanged.

3. An S3 device cannot see an unplug through TinyUSB

Arduino's tinyusb init passes otg_io_conf = NULL (cores/esp32/esp32-hal-tinyusb.c:140), so no VBUS line is routed to the OTG core through the GPIO matrix and IDF forces B-session-valid permanently on. The core never sees session end, no DCD_EVENT_UNPLUGGED is raised, and tud_mounted() stays true after the cable is gone — so UsbMassStorageState::Disconnected is unreachable and an MSC session has no way to end itself. Device-observed on a LilyGo T5 S3: transfers worked, then pulling the cable left the reader on its Connected screen forever.

Two signals, because no single one covers every board:

Signal Source Ambiguous?
UsbMassStorage::hostSuspended() tud_suspended() — bus idle (no SOF >3 ms), detected by the core independently of VBUS, so it survives the forced B-valid Yes — a host suspending an idle bus is identical. Exposed as a hint; callers are told to require persistence
BatteryMonitor::isExternalPowerPresent() BQ25896 REG0B: VBUS_STAT[7:5] + PG_STAT[2], out of the same register readGaugeCharging() already reads for CHRG_STAT No — a physical reading of the input rail

isExternalPowerPresent() is deliberately not isCharging(): a full battery stops charging with the cable still attached, so charge state reports "unplugged" while plugged in — the exact failure mode that makes charge-based USB inference wrong. There is deliberately no gauge fallback either: the BQ27220 measures the battery, not the input rail, so a board with a gauge but no charger IC genuinely cannot see this. It reports known = false and callers must branch on it, rather than have the SDK answer "no external power" from a sensor that cannot observe external power.

Also: a comment correction

FREEINK_CAP_USB_MSC's comment said the capability forces the build into USB-OTG mode (ARDUINO_USB_MODE=0). It does not have to: a firmware can keep ARDUINO_USB_MODE=1, so USB Serial/JTAG stays the board's normal personality for monitoring and flashing, and switch the shared PHY to OTG at runtime for the duration of a transfer. Both X4 Pro and LilyGo T5 S3 ship that way in CrossPoint. The real build requirement — which was not documented — is the platform's prebuilt Arduino core, whose TinyUSB component carries CONFIG_TINYUSB_MSC_ENABLED; an env that rebuilds the core from source (custom_sdkconfig / custom_component_remove) drops that component and USBMSC will not link.

Testing

  • LilyGo T5 S3 (SPI backend): validated on hardware, 2026-09-03. The host mounts the card, transfers work over the raw-block-device path, and pulling the cable now ends the session and returns the reader to Home.
  • X4 Pro (SDMMC backend): builds, not run on hardware. Its behaviour is unchanged by this PR except for the rawBlockDevice() return type and the guard restructure, but I want to be explicit that I have not exercised the SDMMC path.
  • Builds clean for x4pro, lilygo_t5s3 and the C3 default env (the last is the one that must not pick up USE_BLOCK_DEVICE_INTERFACE).

Branched from main; the work originally sat on top of #51 and was rebased off it, so this PR carries no LilyGo board changes and is independent of that one.

Attribution

Builds on the USB-MSC work by @uxjulia (#36 feat/x4-pro-usb-support, #53, #57) and @itsthisjustin (79a82d5, the capability flag). Co-authored-by trailers are on the commits.

AI usage

PARTIALLY. An AI coding assistant was used for the investigation, for drafting the code comments, and for this description. The hardware bring-up and the device validation on the LilyGo were done by hand on the board.

jpirnay and others added 3 commits September 3, 2026 17:15
USB Mass Storage was reachable only from the native-SDMMC backend, because
detachFilesystemForRawAccess() lived behind FREEINK_SD_SDMMC. That kept the
capability limited to the X4 Pro / de-link / Paper Mono class of board and
locked out every SPI-SD board — notably the LilyGo T5 S3, whose card is on the
shared SPI bus (SCLK14 MISO21 MOSI13 CS12, vendor pinmap docs/pinmap.md).

No second driver is needed for that: SdFat's SdCardInterface already derives
from FsBlockDeviceInterface and implements the same readSector(s)/writeSector(s)
contract SdmmcBlockDevice does, so the card object IS the block device. The SPI
path only has to drop the FsVolume while keeping the card session alive, which
is FsVolume::end() rather than SdFat::end() (the latter also ends the card).
Remounting goes back through begin(), whose sd.begin() re-runs SdCard::begin()
on the same factory-owned card object.

rawBlockDevice() now answers on both backends and returns the interface type
rather than the SDMMC-specific one; it had no callers.

Also corrects the FREEINK_CAP_USB_MSC comment in BoardConfig.h, which claimed
the capability forces ARDUINO_USB_MODE=0. It does not: the shipped
implementation keeps USB Serial/JTAG as the board's normal USB personality and
switches the shared PHY to OTG at runtime for the transfer only. The real build
requirement is the prebuilt Arduino core (CONFIG_TINYUSB_MSC_ENABLED), which a
custom_sdkconfig core rebuild drops.

Builds on the USB-MSC work by Julia Nguyen and Justin Mitchell:
freeink-sdk Free-Ink#36 (feat/x4-pro-usb-support), Free-Ink#53, Free-Ink#57, and 79a82d5
("Add USB Mass Storage capability flag").

Co-authored-by: Julia Nguyen <julia@uxj.io>
Co-authored-by: Justin Mitchell <justin@jmitch.com>
The SPI raw-block-device path added in the previous commit does not compile on
its own: SdFat's SdSpiCard only derives from FsBlockDeviceInterface when
USE_BLOCK_DEVICE_INTERFACE (or HAS_SDIO_CLASS) is set — otherwise it is a plain
concrete class with no such base and sd.card() cannot be returned as one.

SdFat compiles as its own library, so the option has to be appended to every lib
builder's env; the SDCardManager build hook already does exactly that for
USE_UTF8_LONG_NAMES, so it grows a second, conditional define. Coupling it to
FREEINK_CAP_USB_MSC rather than turning it on globally keeps the vtable and the
indirect sector calls off the boards that would gain nothing from them — notably
the C3, which has no USB-OTG peripheral and can never serve MSC at all.

The raw-access functions are guarded to match and link as nullptr-returning
stubs when the option is absent, so a board that never asked for USB Drive still
builds.

Co-authored-by: Julia Nguyen <julia@uxj.io>
Co-authored-by: Justin Mitchell <justin@jmitch.com>
An ESP32-S3 device cannot detect an unplug through TinyUSB. Arduino's tinyusb
init passes otg_io_conf = NULL (cores/esp32/esp32-hal-tinyusb.c:140), so no VBUS
line is routed to the OTG core through the GPIO matrix and IDF forces
B-session-valid permanently on. The core never sees session end, no
DCD_EVENT_UNPLUGGED is raised, and tud_mounted() stays true after the cable is
gone — so UsbMassStorage::state() never reaches Disconnected and a USB-MSC
session has no way to end itself. Device-observed on a LilyGo T5 S3.

Two signals, because no single one covers every board:

UsbMassStorage::hostSuspended() wraps tud_suspended(). Bus suspend is detected
by the OTG core from bus idle (no SOF for >3 ms), independent of VBUS, so it
survives the forced B-valid. It is a HINT rather than a verdict — a host
suspending an idle bus is indistinguishable — so it is exposed raw and the
caller is told to require persistence.

BatteryMonitor::isExternalPowerPresent() reads the BQ25896's REG0B VBUS_STAT
[7:5] and PG_STAT [2], out of the same register readGaugeCharging() already uses
for CHRG_STAT. This is a physical reading of the input rail and is unambiguous.
It is deliberately NOT isCharging(): a full battery stops charging with the
cable still attached, so charge state reports "unplugged" while plugged in —
the exact failure mode that makes charge-based USB inference wrong.

There is no gauge fallback for it. The BQ27220 measures the battery, not the
input rail, so a board with a gauge but no charger IC genuinely cannot see this;
it reports `known = false` and callers must branch on that. Answering "no
external power" from a sensor that cannot observe external power would be worse
than admitting ignorance. The M5 PMIC path uses the externalPower field
readM5Pm1Status() already decodes from PWR_SRC, not the charging flag derived
from it.
jpirnay added a commit to jpirnay/freeink-sdk that referenced this pull request Sep 3, 2026
Both changes were made while preparing the PRs and only ever existed on the PR
branches, which were cut from Free-Ink main rather than from here -- so our copy
had the weaker version of each.

The two pinch tests (Free-Ink#75) assert what the commit message
claims and nothing did before: that rotation and pinch can never both accept one
gesture, checked from both sides, and that a gesture converging by exactly 20%
while both contacts travel 80 px is a pinch rather than the two-finger swipe the
translation path would also accept. Host suite goes 35 -> 43 checks.

The FREEINK_CAP_USB_MSC comment (Free-Ink#74) no longer talks about "an earlier revision
of this comment", which meant nothing outside our own history, and states the
requirement positively: ARDUINO_USB_MODE=0 is one way to reach the OTG PHY, not
a requirement, and the actual constraint is the prebuilt Arduino core carrying
CONFIG_TINYUSB_MSC_ENABLED.

Deliberately NOT synced: the stray clang-format reflow in gslUploadFirmware()
that Free-Ink#75 drops. The SDK ships no .clang-format, so our pre-commit hook formats
these files to the firmware's 120-column limit and would simply re-split that
line on the next commit that touches the file. It stays a fork-local artifact.
jpirnay added a commit to jpirnay/freeink-sdk that referenced this pull request Sep 3, 2026
Brings in OnePage ESP32-C61 board support (Free-Ink#68), the OnePage shared-SD-rail fix
(Free-Ink#72), the driver deepSleep() rework that skips power-off when the screen is
already off, and FreeInkUI inline list section headings.

One conflict, in SDCardManager.h, and it is the mirror of the one that had to be
resolved to get Free-Ink#74 onto main. Our side moved
rawBlockDevice()/detachFilesystemForRawAccess() OUT of the #if FREEINK_SD_SDMMC
guard (both backends answer them now), which left main's new shutdown() and its
#else/#endif orphaned. Resolved by giving shutdown() its own guard.

prepareForSleep() (PR Free-Ink#51) and shutdown() (main) now BOTH exist and both stay:
on the SDMMC path shutdown() is a superset -- same unmount plus floating the bus
pads -- so upstream will want to reconcile them, but that is a decision for Free-Ink#51
and not something to settle inside a merge. Neither is lost and neither changed.

Nothing else conflicted: main's deepSleep() rework is confined to the Ssd1677 /
Uc8179 / Uc8279 / Uc8279X4 drivers and does not touch LgfxEpdDriver, where our
powerControl()/_pwr_known work lives.
@itsthisjustin

Copy link
Copy Markdown
Contributor

Interesting. Will this allow it to work on the sticky or no?

1 similar comment
@itsthisjustin

Copy link
Copy Markdown
Contributor

Interesting. Will this allow it to work on the sticky or no?

@jpirnay

jpirnay commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Interesting. Will this allow it to work on the sticky or no?

I don't have a sticky device to check, but I am doubting it would support it: looking at the schematics :

  • The Type-C connector's only D+/D− pair (USB_DP/USB_DN) goes to the CH343P, pins UD+/UD−; its TXD/RXD land on UART0.
  • GPIO19/20 — the S3's native USB pins — are spent on the PDM microphone (PDM_CLK / PDM_DATA). So there's nothing to reconnect even in principle.
  • One USB connector, and the pogo pins carry no USB nets.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants