diff --git a/libs/hardware/BatteryMonitor/include/BatteryMonitor.h b/libs/hardware/BatteryMonitor/include/BatteryMonitor.h index bcef6f80..2ec37d38 100644 --- a/libs/hardware/BatteryMonitor/include/BatteryMonitor.h +++ b/libs/hardware/BatteryMonitor/include/BatteryMonitor.h @@ -71,6 +71,21 @@ class BatteryMonitor { // a board with a gauge but no charger IC (e.g. X3) still reports it. bool isCharging() const; + // True when external power (a USB cable or charger) is physically present. + // + // This is NOT isCharging(): a full battery stops charging while still + // plugged in, so isCharging() goes false with the cable still attached. + // Only a source that reports the input rail itself can answer this, so it + // is currently the BQ25896's REG0B — VBUS_STAT[7:5] plus PG_STAT — and + // nothing else. Boards without that charger IC cannot observe it. + // + // `known` (optional, out) is set false when the board has no way to tell, + // or the read failed. Callers MUST branch on it: a bare false means "no + // opinion" just as often as it means "unplugged", and treating the two the + // same is how you get a device that thinks the cable is gone because it + // never had a way to see it. + bool isExternalPowerPresent(bool* known = nullptr) const; + // Percentage from a millivolt value, off a standard 1S Li-ion discharge // curve. The result is always a multiple of 10: voltage cannot resolve a // Li-ion pack any finer than that, and pretending otherwise just produces a diff --git a/libs/hardware/BatteryMonitor/src/BatteryMonitor.cpp b/libs/hardware/BatteryMonitor/src/BatteryMonitor.cpp index 1687668c..bb556fe0 100644 --- a/libs/hardware/BatteryMonitor/src/BatteryMonitor.cpp +++ b/libs/hardware/BatteryMonitor/src/BatteryMonitor.cpp @@ -290,6 +290,35 @@ bool readGaugeCharging(bool& known) { known = false; return false; } + +// External-power presence from the charger IC, out of the SAME REG0B read that +// readGaugeCharging() uses for CHRG_STAT: +// VBUS_STAT [7:5] — 000 no input, 001 USB host SDP, 010 USB CDP, 011 adapter, +// 111 OTG (we are SOURCING power, not receiving it) +// PG_STAT [2] — power good +// Anything but "no input" and "OTG" means a cable is supplying us. Unlike +// charge state this stays true at 100%, which is exactly the case that makes +// charge-based USB inference wrong. +// +// Only the BQ25896 answers this. The BQ27220 gauge measures the BATTERY, not +// the input rail, so there is deliberately no gauge fallback here — reporting +// "no external power" from a gauge that cannot see the input would be a lie. +bool readChargerExternalPower(bool& known) { + const auto& g = BoardConfig::ACTIVE.batteryGauge; + if (g.chargerAddr == 0) { + known = false; + return false; + } + uint8_t status = 0; + if (!readReg8(g.chargerAddr, BQ25896_REG_STATUS, status)) { + known = false; + return false; + } + known = true; + const uint8_t vbus = (status >> 5) & 0x07; + const bool powerGood = (status & 0x04) != 0; + return (vbus != 0x00 && vbus != 0x07) || powerGood; +} } // namespace #endif // FREEINK_BATTERY_I2C_GAUGE @@ -506,6 +535,29 @@ bool BatteryMonitor::isCharging() const { return digitalRead(_chargeStatusPin) == chargeActiveLevel(); } +bool BatteryMonitor::isExternalPowerPresent(bool* known) const { + bool observed = false; +#if FREEINK_BATTERY_I2C_GAUGE + const bool present = readChargerExternalPower(observed); + if (observed) { + if (known) *known = true; + return present; + } +#endif + if (hasM5Pm1Backend()) { + // The M5 PMIC reports its supply source directly (PWR_SRC 5VIN/5VINOUT), + // which readM5Pm1Status() already decodes into externalPower — the right + // field here, rather than the `charging` one it derives from it. + Status status; + if (readM5Pm1Status(status) && status.externalPowerKnown) { + if (known) *known = true; + return status.externalPower; + } + } + if (known) *known = false; + return false; +} + bool BatteryMonitor::readM5Pm1Status(Status& status) const { status.supported = true; if (!hasM5Pm1Backend()) return false; diff --git a/libs/hardware/BoardConfig/include/BoardConfig.h b/libs/hardware/BoardConfig/include/BoardConfig.h index 5489a653..85a415dd 100644 --- a/libs/hardware/BoardConfig/include/BoardConfig.h +++ b/libs/hardware/BoardConfig/include/BoardConfig.h @@ -211,13 +211,26 @@ #define FREEINK_CAP_WARMLIGHT (FREEINK_DEVICE_X4PRO || FREEINK_DEVICE_MURPHY_M4 || FREEINK_DEVICE_EEGO_A4) #endif // USB Mass Storage ("USB Transfer" mode): exposes the SD card to a host over -// USB-MSC. OPT-IN (default off), NOT board-derived: it forces the build into -// USB-OTG mode (ARDUINO_USB_MODE=0 + CONFIG_TINYUSB_MSC_ENABLED), which changes -// how the USB serial console works — so a board enables it in its OWN env -// alongside those flags (e.g. X4 Pro adds -DFREEINK_CAP_USB_MSC=1 -// -DARDUINO_USB_MODE=0 -DARDUINO_USB_CDC_ON_BOOT=1). Native-USB (ESP32-S3/C3 -// OTG) targets only. When 0, UsbMassStorage links stub bodies and pulls in no -// TinyUSB/MSC code. Requires SDMMC/SPI storage exposing a block device. +// USB-MSC. OPT-IN (default off), NOT board-derived, so a board enables it in +// its OWN env (e.g. -DFREEINK_CAP_USB_MSC=1). Native-USB (ESP32-S3/C3 OTG) +// targets only. When 0, UsbMassStorage links stub bodies and pulls in no +// TinyUSB/MSC code. +// +// ARDUINO_USB_MODE=0 is one way to reach the OTG PHY, not a requirement of this +// library. A firmware can equally keep ARDUINO_USB_MODE=1 — so USB Serial/JTAG +// stays the board's normal USB personality for monitoring and flashing — and +// switch the shared PHY to OTG at runtime for the duration of a transfer, +// handing it back before it reboots out of the mode. X4 Pro and LilyGo T5 S3 +// both ship that way in CrossPoint. +// +// What this DOES require is the platform's prebuilt Arduino core, whose TinyUSB +// component is built with 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. +// +// Storage must expose a 512-byte-sector block device. Both SDCardManager +// backends do — SDMMC natively, and SPI through SdFat's own SdCard — so this is +// no longer an SDMMC-only capability. #ifndef FREEINK_CAP_USB_MSC #define FREEINK_CAP_USB_MSC 0 #endif diff --git a/libs/hardware/SDCardManager/include/SDCardManager.h b/libs/hardware/SDCardManager/include/SDCardManager.h index 4fe8df8a..d14b8377 100644 --- a/libs/hardware/SDCardManager/include/SDCardManager.h +++ b/libs/hardware/SDCardManager/include/SDCardManager.h @@ -75,16 +75,20 @@ class SDCardManager { using PowerHook = void (*)(); void setPowerHook(PowerHook hook) { _powerHook = hook; } -#if FREEINK_SD_SDMMC - // The raw SDMMC block device (512-byte sector I/O) backing the volume, for - // exposing the card over USB-MSC ("USB Transfer" mode). Null until begin() - // succeeds. The returned pointer implements SdFat's FsBlockDeviceInterface. + // The raw block device (512-byte sector I/O) backing the volume, for exposing + // the card over USB-MSC ("USB Transfer" mode). Null until begin() succeeds. // Do NOT touch the filesystem while the card is handed to the USB host. - freeink::SdmmcBlockDevice* rawBlockDevice() { return _dev; } - // End the FsVolume mount while keeping the native block device alive for a - // raw USB-MSC owner. The caller must reinitialize the manager after the - // owner releases the card. + // + // Both backends answer this: SDMMC boards hand back their native esp-idf + // device, SPI boards hand back SdFat's own SdCard (SdCardInterface derives + // from FsBlockDeviceInterface, so the card object IS a block device — no + // second driver is needed for the SPI path). + FsBlockDeviceInterface* rawBlockDevice(); + // End the FsVolume mount while keeping the block device alive for a raw + // USB-MSC owner. The caller must reinitialize the manager (begin()) after the + // owner releases the card. Returns null when nothing is mounted. FsBlockDeviceInterface* detachFilesystemForRawAccess(); +#if FREEINK_SD_SDMMC // Stop the card for deep sleep: unmount the volume, stop the SDMMC host, and // float the bus pads so their pull-ups stop back-feeding the card's VDD net // through sleep. Idempotent; call only after all file users have stopped. A diff --git a/libs/hardware/SDCardManager/inject_build_flags.py b/libs/hardware/SDCardManager/inject_build_flags.py index e55b08b4..3ea34027 100644 --- a/libs/hardware/SDCardManager/inject_build_flags.py +++ b/libs/hardware/SDCardManager/inject_build_flags.py @@ -1,23 +1,50 @@ -# SDCardManager build hook: force SdFat's UTF-8 long-filename support on for -# the WHOLE build (SdFat compiles as its own library, so a define in our -# library.json "flags" would not reach it). +# SDCardManager build hook: force two SdFat options on for the WHOLE build. +# SdFat compiles as its own library, so a define in our library.json "flags" +# would not reach it — it has to be appended to every lib builder's env here. # -# Without USE_UTF8_LONG_NAMES, SdFat returns mangled names for any file with -# a non-ASCII character ("The 7½ Deaths..." listed but unopenable — no -# metadata, no cover, no reading). There is no situation where a FreeInk -# firmware wants that, so this is not a user-facing option. +# 1. USE_UTF8_LONG_NAMES, always. Without it, SdFat returns mangled names for +# any file with a non-ASCII character ("The 71/2 Deaths..." listed but +# unopenable — no metadata, no cover, no reading). There is no situation +# where a FreeInk firmware wants that, so this is not a user-facing option. +# +# 2. USE_BLOCK_DEVICE_INTERFACE, but only when the build enables USB Mass +# Storage (FREEINK_CAP_USB_MSC). It is what makes SdSpiCard derive from +# FsBlockDeviceInterface, which is how SDCardManager hands the SPI-attached +# card to a USB host as a raw block device — without it SdSpiCard is a plain +# concrete class and detachFilesystemForRawAccess() has nothing to return. +# The SDMMC backend needs the same option for its FsVolume, so coupling both +# to the capability keeps it off the boards that pay for it in vtables and +# indirect calls for nothing. Import("env") -_DEFINE = ("USE_UTF8_LONG_NAMES", "1") +_ALWAYS = [("USE_UTF8_LONG_NAMES", "1")] +_IF_USB_MSC = [("USE_BLOCK_DEVICE_INTERFACE", "1")] + + +def _defines(e): + return {d[0] if isinstance(d, (tuple, list)) else d for d in e.get("CPPDEFINES", [])} + + +def _usb_msc_enabled(e): + for d in e.get("CPPDEFINES", []): + if isinstance(d, (tuple, list)) and d[0] == "FREEINK_CAP_USB_MSC": + return str(d[1]) not in ("0", "") + return False + +def _append(e, wanted): + have = _defines(e) + missing = [d for d in wanted if d[0] not in have] + if missing: + e.Append(CPPDEFINES=missing) -def _append(e): - defines = {d[0] if isinstance(d, tuple) else d for d in e.get("CPPDEFINES", [])} - if _DEFINE[0] not in defines: - e.Append(CPPDEFINES=[_DEFINE]) +_wanted = list(_ALWAYS) +# Read the capability off the project env: a lib builder's env may not carry it. +if _usb_msc_enabled(env) or _usb_msc_enabled(DefaultEnvironment()): + _wanted += _IF_USB_MSC -_append(env) -_append(DefaultEnvironment()) +_append(env, _wanted) +_append(DefaultEnvironment(), _wanted) for lb in env.GetLibBuilders(): - _append(lb.env) + _append(lb.env, _wanted) diff --git a/libs/hardware/SDCardManager/src/SDCardManager.cpp b/libs/hardware/SDCardManager/src/SDCardManager.cpp index eafd25ac..a57c5502 100644 --- a/libs/hardware/SDCardManager/src/SDCardManager.cpp +++ b/libs/hardware/SDCardManager/src/SDCardManager.cpp @@ -65,6 +65,8 @@ bool SDCardManager::begin() { return initialized; } +FsBlockDeviceInterface* SDCardManager::rawBlockDevice() { return _dev; } + FsBlockDeviceInterface* SDCardManager::detachFilesystemForRawAccess() { if (!initialized || !_dev) return nullptr; _vol.end(); @@ -195,7 +197,45 @@ bool SDCardManager::begin() { return initialized; } -#endif + +// SdFat's card object already IS a block device: with USE_BLOCK_DEVICE_INTERFACE +// (or HAS_SDIO_CLASS) set, SdSpiCard derives from FsBlockDeviceInterface and +// implements the same readSector(s)/writeSector(s) contract SdmmcBlockDevice +// does. So the SPI path needs no second driver for USB-MSC — it only needs the +// volume dropped while the card session stays up. +// +// Without that option SdSpiCard is a plain concrete class with no such base, so +// there is nothing to hand out. The build hook (inject_build_flags.py) turns the +// option on whenever FREEINK_CAP_USB_MSC is set, so the stubs below are what a +// board that never asked for USB Drive links. +#if USE_BLOCK_DEVICE_INTERFACE || HAS_SDIO_CLASS + +FsBlockDeviceInterface* SDCardManager::rawBlockDevice() { return initialized ? sd.card() : nullptr; } + +FsBlockDeviceInterface* SDCardManager::detachFilesystemForRawAccess() { + if (!initialized) return nullptr; + auto* const card = sd.card(); + if (!card) return nullptr; + // FsVolume::end() ONLY — deliberately not sd.end(), which would also call + // SdCard::end() and tear down the card session the USB host is about to read + // through. The card stays initialized and selected; remounting later goes + // back through begin(), whose sd.begin() re-runs SdCard::begin() on the same + // (factory-owned, statically allocated) card object. + sd.FsVolume::end(); + initialized = false; + cachedTotalBytes = 0; + cachedUsedBytes = 0; + cachedUsedBytesValid = false; + return card; +} + +#else // USE_BLOCK_DEVICE_INTERFACE || HAS_SDIO_CLASS + +FsBlockDeviceInterface* SDCardManager::rawBlockDevice() { return nullptr; } +FsBlockDeviceInterface* SDCardManager::detachFilesystemForRawAccess() { return nullptr; } + +#endif // USE_BLOCK_DEVICE_INTERFACE || HAS_SDIO_CLASS +#endif // FREEINK_SD_SDMMC bool SDCardManager::ready() const { return initialized; diff --git a/libs/hardware/UsbMassStorage/include/UsbMassStorage.h b/libs/hardware/UsbMassStorage/include/UsbMassStorage.h index 3c2b0269..210daa52 100644 --- a/libs/hardware/UsbMassStorage/include/UsbMassStorage.h +++ b/libs/hardware/UsbMassStorage/include/UsbMassStorage.h @@ -35,6 +35,20 @@ class UsbMassStorage { bool active() const { return _active; } UsbMassStorageState state() const; bool hostConnected() const; + // True while the USB bus is idle (no SOF for >3 ms), which is what the device + // sees when the cable is pulled. + // + // Needed because tud_mounted() CANNOT report an unplug on the ESP32-S3: + // Arduino's tinyusb init passes otg_io_conf = NULL (esp32-hal-tinyusb.c), so + // no VBUS line is routed to the OTG core and IDF forces B-session-valid on. + // The core therefore never detects session end, and state() stays Connected + // forever after the cable is gone. Bus suspend is detected by the core itself + // and is unaffected. + // + // A host suspending an idle bus looks identical, so this is a HINT, not a + // verdict: callers must require it to persist before acting on it, and should + // prefer a physical VBUS reading where the board has one. + bool hostSuspended() const; // Soft-disconnect the USB device from the host. Call from application/task // context, never from an MSC callback; end() still owns final teardown. bool disconnectHost() const; @@ -64,6 +78,7 @@ class UsbMassStorage { UsbMassStorageState state() const { return UsbMassStorageState::Idle; } bool hostConnected() const { return false; } bool disconnectHost() const { return false; } + bool hostSuspended() const { return false; } }; } // namespace freeink diff --git a/libs/hardware/UsbMassStorage/src/UsbMassStorage.cpp b/libs/hardware/UsbMassStorage/src/UsbMassStorage.cpp index eef550c9..4cbbcab3 100644 --- a/libs/hardware/UsbMassStorage/src/UsbMassStorage.cpp +++ b/libs/hardware/UsbMassStorage/src/UsbMassStorage.cpp @@ -12,6 +12,7 @@ extern "C" bool tud_mounted(void); extern "C" bool tud_disconnect(void); +extern "C" bool tud_suspended(void); namespace freeink { namespace { @@ -190,6 +191,8 @@ bool UsbMassStorage::hostConnected() const { bool UsbMassStorage::disconnectHost() const { return _active && tud_disconnect(); } +bool UsbMassStorage::hostSuspended() const { return _active && tud_suspended(); } + void UsbMassStorage::markAccessed() const { auto current = _state.load(); while (current != UsbMassStorageState::Ejected && current != UsbMassStorageState::IoError) {