Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions libs/hardware/BatteryMonitor/include/BatteryMonitor.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions libs/hardware/BatteryMonitor/src/BatteryMonitor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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;
Expand Down
27 changes: 20 additions & 7 deletions libs/hardware/BoardConfig/include/BoardConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 12 additions & 8 deletions libs/hardware/SDCardManager/include/SDCardManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 42 additions & 15 deletions libs/hardware/SDCardManager/inject_build_flags.py
Original file line number Diff line number Diff line change
@@ -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)
42 changes: 41 additions & 1 deletion libs/hardware/SDCardManager/src/SDCardManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ bool SDCardManager::begin() {
return initialized;
}

FsBlockDeviceInterface* SDCardManager::rawBlockDevice() { return _dev; }

FsBlockDeviceInterface* SDCardManager::detachFilesystemForRawAccess() {
if (!initialized || !_dev) return nullptr;
_vol.end();
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions libs/hardware/UsbMassStorage/include/UsbMassStorage.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions libs/hardware/UsbMassStorage/src/UsbMassStorage.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down