Skip to content

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

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

jpirnay wants to merge 5 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.

@itsthisjustin

Copy link
Copy Markdown
Contributor

@jpirnay can you solve conflicts? I ended up having an issue I think maybe you also fixed here but maybe we fixed it differently? I'd like to get this in. I had to trigger disconnect on suspend after we upgraded to the latest TinyUSB

@jpirnay

jpirnay commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Sure, will look at it tonight

Resolves the two conflicts, both in UsbMassStorage and both where upstream
independently landed the same fix as this branch's third commit (4837c11, "Add
USB host suspend detection to UsbMassStorage") after hitting the same problem
on a newer TinyUSB.

- hostSuspended() takes upstream's expression, which adds tud_mounted() in
  front of tud_suspended(). On the ESP32-S3 that term is always true --
  B-session-valid is forced on, which is the whole reason this hint has to
  exist -- so it costs nothing there. On a board that CAN see an unplug it stops
  a never-mounted session from reporting "suspended" while state() already
  answers Disconnected.
- The declaration keeps this branch's comment, which records why tud_mounted()
  cannot answer the question on the S3 (Arduino's tinyusb init passes
  otg_io_conf = NULL, so no VBUS line reaches the OTG core) and why the result
  is a hint callers must require to persist rather than a verdict.

Re-validated on a LilyGo T5 S3 after the merge: the host still mounts the card
over the SPI raw-block-device path, and pulling the cable still ends the session
and returns the reader to Home.
…e stub

Both sides declared it in the no-capability stub class, in different positions
relative to disconnectHost(), so the textual merge kept both and every build
without FREEINK_CAP_USB_MSC failed to compile.
@jpirnay

jpirnay commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

@itsthisjustin conflicts resolved — rebased onto main at 48f5949.

Both conflicts were in UsbMassStorage, and both were exactly the overlap you described: you landed the same fix independently in 4837c11 after hitting it on the newer TinyUSB, converging on the same hostSuspended() name.

Resolution:

  • Code takes yours: _active && tud_mounted() && tud_suspended(). On the ESP32-S3 tud_mounted() is always true — B-session-valid is forced on, which is the whole reason the hint has to exist — so the extra term costs nothing there. On a board that can see an unplug it stops a never-mounted session reporting "suspended" while state() already answers Disconnected. Strictly better than what this branch had.
  • The declaration keeps this branch's comment, which records why tud_mounted() cannot answer the question on the S3 (Arduino's tinyusb init passes otg_io_conf = NULL, so no VBUS line reaches the OTG core and IDF forces B-session-valid on) and why the result is a hint callers must require to persist rather than a verdict.

One follow-up commit on top: the merge left hostSuspended() declared twice in the no-capability stub class, because both sides declared it in different positions relative to disconnectHost() and the textual merge kept both. That broke every build without FREEINK_CAP_USB_MSC; caught it on an ESP32-C3 env and dropped the duplicate.

Re-validated on a LilyGo T5 S3 after the merge: the host still mounts the card over the SPI raw-block-device path, and pulling the cable still ends the session and returns the reader to Home. Also builds clean for the X4 Pro (SDMMC backend) and a C3 env, which is the one that must not pick up USE_BLOCK_DEVICE_INTERFACE.

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