Skip to content
29 changes: 21 additions & 8 deletions libs/hardware/BoardConfig/include/BoardConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -1561,13 +1561,23 @@ constexpr BoardProfile XTEINK_X4_PRO = {
// Frontlight: dual warm/cold LEDC PWM with color temperature (NVS lightWarmValue/
// lightColdValue/lightCT/lightBri/lightOn). Recovered from the OEM LEDC init (IROM
// 0x420a2130 → helper 0x420a20c0): two channels — GPIO8 on LEDC ch4 and GPIO9 on ch5 —
// The original bring-up dump used 10 kHz; stock 7.0.8 passes 25 kHz / 10-bit to
// the frontlight initializer on the same pins. Use that directly recovered value.
// Both channels are active-HIGH (init drives the pin LOW = off, brightness raises
// duty).
// GPIO8 is the hardware-confirmed cool channel and GPIO9 the warm channel;
// FrontlightManager mixes them for color-temperature control.
{8, 25000, 10, true, 9},
//
// 10 kHz, and NOT the 25 kHz stock 7.0.8 passes to the same initializer, because
// this build does not clock LEDC the way stock does. Under FREEINK_FRONTLIGHT_LS
// the timer runs off RC_FAST (~17.5 MHz) so the PWM survives light sleep, and LEDC
// can only divide that down while freq * 2^bits fits inside it. 25 kHz * 1024 =
// 25.6 MHz does not fit: ledc_timer_config() returns ESP_FAIL, the channels are
// never attached, and the frontlight cannot be turned on at all, with no crash and
// no failed build. That is what 25 kHz shipped as in v1.11.1. 10 kHz * 1024 =
// 10.24 MHz fits, and keeps the full 10-bit range setBrightnessLevel's level-1
// night minimum and the gamma table are both tuned for. Stock affords 25 kHz by
// clocking LEDC from APB and letting the light die in sleep.
// The static_assert in FrontlightManager.cpp holds this pair to the constraint.
{8, 10000, 10, true, 9},
NO_AUDIO,
NO_LEDS,
NO_FLIP, // panel mount transform pending hardware; native SSD1677 scan is 800x480 landscape
Expand Down Expand Up @@ -1598,11 +1608,14 @@ constexpr BoardProfile XTEINK_X4_PRO = {
// button ladder — that earlier assumption was wrong; the ladder pins remain unconfirmed.
{1},
0, // displayControllerVariant: filled by the boot probe
// Bezel overlap: the panel sits recessed, and 7px is the empirically-tuned
// side inset that keeps an edge-hugging scroll indicator visible (was the
// firmware's hardcoded X4 Pro scrollbar inset); top/bottom keep the X4
// historical values pending measurement.
{9, 7, 3, 7},
// Bezel overlap, measured on real hardware (2026-08-26) with the BEZEL
// ruler app: the glass hides 10 rows at the top, 1 column each side, and
// nothing at the bottom -- the panel sits shifted toward its bottom flex
// cable. Per-unit variance is real (upstream #618 measured 5-11 hidden
// top rows across X4 units), so a unit that still clips should be
// re-measured with the ruler rather than padded blindly. The old side
// value (7) was a scrollbar-aesthetics constant, not a measurement.
{10, 1, 0, 1},
true}; // batteryChargeStatusActiveHigh: GPIO21 STAT is driven HIGH while charging

// --- Xteink X4 Classic (X4C) — ESP32-S3, 800x480 EPD, NO touch, NO frontlight ---
Expand Down
12 changes: 10 additions & 2 deletions libs/hardware/FrontlightManager/include/FrontlightManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,16 @@

class FrontlightManager {
public:
// Bring up the PWM channel(s). No-op if the board has no frontlight.
void begin();
// Bring up the PWM channel(s), and say whether the light can now be driven.
//
// FALSE means every later setBrightness()/on()/setColorTemperature() will be
// accepted and do nothing: the board has no frontlight, or its channels
// would not configure. Check it. Until 2026-08-31 this returned void, so a
// consumer had no way to tell a light that came up from one that did not,
// and the X4 Pro shipped two releases whose LEDC channels never attached
// while the firmware logged "Frontlight up" on the way past. Every signal
// short of a human eye reported success, which is why it survived a release.
bool begin();

// Set brightness as a 0-100 percentage, mapped to duty through a perceptual
// gamma-1.6554 curve (1% is the smallest non-zero duty step, not 1% linear
Expand Down
63 changes: 49 additions & 14 deletions libs/hardware/FrontlightManager/src/FrontlightManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#ifdef FREEINK_FRONTLIGHT_LS
#include <driver/gpio.h>
#include <driver/ledc.h>
#include <soc/clk_tree_defs.h>
// esp_sleep_sub_mode_config lives in a private IDF header (no public API exists
// for balancing the refcounted RC_FAST keep-on the LEDC driver takes for
// KEEP_ALIVE channels — the driver manages it through this same header). Pinned
Expand Down Expand Up @@ -85,12 +86,38 @@ uint32_t physicalDuty(uint32_t logicalDuty, uint32_t full, bool activeHigh) {
// sleep current), mark the
// channels KEEP_ALIVE, and disable the GPIO sleep-isolation override on the
// output pins (a documented gotcha: sleep entry reconfigures the pad and kills
// the PWM even when the clock survives). RC_FAST at 10 kHz supports up to
// 10-bit resolution (17.5 MHz / 10 kHz = 1750 >= 1024), so the board profiles'
// full duty range — including setBrightnessLevel's level-1 minimum step — stays
// expressible. Uses the IDF driver directly (fixed LEDC_TIMER_0 + the channel
// ids below) because the Arduino helpers don't expose sleep_mode; safe here
// because frontlight boards using this flag have no other LEDC consumer.
// the PWM even when the clock survives).
// Uses the IDF driver directly (fixed LEDC_TIMER_0 + the channel ids below)
// because the Arduino helpers don't expose sleep_mode; safe here because
// frontlight boards using this flag have no other LEDC consumer.
//
// RC_FAST also bounds which (frequency, resolution) pairs exist at all. LEDC
// forms its divisor as (clk << 8) / (freq * 2^bits) and rejects any result at
// or below 255 (LEDC_IS_DIV_INVALID), so a pair is achievable exactly while
// freq * 2^bits stays within the clock. Miss it and ledc_timer_config()
// returns ESP_FAIL, attachChannel() below leaves the channels unattached, and
// the frontlight cannot be turned on AT ALL: no crash, no failed build, every
// UI control still moving and the sun icon still toggling. That shipped on the
// X4 Pro in v1.11.1, when an upstream profile edit took its frequency from
// 10 kHz to 25 kHz and 25 kHz * 1024 = 25.6 MHz stopped fitting in 17.5 MHz.
// Hardware is the worst place to find this, so the build refuses the pair.
constexpr bool fitsRcFast(const uint32_t freq, const uint8_t bits) {
// <= is the conservative form: equality yields divisor 256, one above the
// reject threshold. The true boundary is 256/255 higher, and the driver
// divides by a CALIBRATED RC_FAST that drifts from this nominal figure, so
// the extra 0.4% is not worth claiming.
return (static_cast<uint64_t>(freq) << bits) <= SOC_CLK_RC_FAST_FREQ_APPROX;
}
// Scoped to the profile this build ships, and only when it drives LEDC pins: a
// PMIC frontlight (viaPm1Pwm) and a board with none never reach the timer.
static_assert(BoardConfig::DEFAULT_DEVICE.frontlight.gpio == BoardConfig::PIN_UNASSIGNED ||
BoardConfig::DEFAULT_DEVICE.frontlight.viaPm1Pwm ||
fitsRcFast(BoardConfig::DEFAULT_DEVICE.frontlight.pwmFrequency,
BoardConfig::DEFAULT_DEVICE.frontlight.pwmResolutionBits),
"FREEINK_FRONTLIGHT_LS: this board's frontlight pwmFrequency * 2^pwmResolutionBits "
"exceeds RC_FAST, so LEDC cannot attach the channels and the light would never turn "
"on. Lower the frequency or the resolution in its BoardConfig profile.");

bool attachChannel(int8_t gpio, uint8_t ch, uint32_t freq, uint8_t bits) {
ledc_timer_config_t timer = {};
timer.speed_mode = LEDC_LOW_SPEED_MODE;
Expand All @@ -99,8 +126,13 @@ bool attachChannel(int8_t gpio, uint8_t ch, uint32_t freq, uint8_t bits) {
timer.freq_hz = freq;
timer.clk_cfg = LEDC_USE_RC_FAST_CLK;
if (ledc_timer_config(&timer) != ESP_OK) {
// freq/bits exceed RC_FAST — leave the light unconfigured rather than
// silently falling back to a clock that freezes in light sleep.
// Unreachable for the shipped profile (the static_assert above), so this is
// the belt for a runtime-selected board. Say WHY: begin()'s ok=0 on its own
// names no cause, which is most of why this one reached two published
// releases with a session reading the log on an affected device.
LOG_ERR("FrontlightMgr", "RC_FAST cannot clock %u Hz at %u-bit (needs %llu Hz <= %u); light stays dark", freq,
bits, static_cast<unsigned long long>(static_cast<uint64_t>(freq) << bits),
static_cast<unsigned>(SOC_CLK_RC_FAST_FREQ_APPROX));
return false;
}
ledc_channel_config_t chan = {};
Expand Down Expand Up @@ -134,14 +166,14 @@ void writeChannel(int8_t /*gpio*/, uint8_t ch, uint32_t duty) { ledcWrite(ch, du
} // namespace
#endif

void FrontlightManager::begin() {
bool FrontlightManager::begin() {
#if FREEINK_CAP_FRONTLIGHT
const auto& fl = BoardConfig::ACTIVE.frontlight;
#if FREEINK_DEVICE_EEGO_A4
const auto& i2c = BoardConfig::ACTIVE.i2cFrontlight;
if (BoardConfig::ACTIVE.board == BoardConfig::Board::EegoA4 &&
i2c.controller == BoardConfig::I2cFrontlightController::Lm3630a) {
if (i2c.sda < 0 || i2c.scl < 0 || i2c.enable < 0 || i2c.address == 0) return;
if (i2c.sda < 0 || i2c.scl < 0 || i2c.enable < 0 || i2c.address == 0) return false;
Wire.begin(i2c.sda, i2c.scl, i2c.i2cHz);
Wire.setTimeOut(256);
pinMode(i2c.enable, OUTPUT);
Expand All @@ -156,20 +188,20 @@ void FrontlightManager::begin() {
Wire.beginTransmission(i2c.address);
const bool detected = Wire.endTransmission() == 0;
digitalWrite(i2c.enable, LOW);
if (!detected) return;
if (!detected) return false;

_begun = true;
_brightness = 0;
return;
return true;
}
#endif
if (fl.viaPm1Pwm) {
pm1FrontlightAttach(fl.pwmFrequency);
_begun = true;
setBrightness(0);
return;
return true;
}
if (fl.gpio == BoardConfig::PIN_UNASSIGNED) return;
if (fl.gpio == BoardConfig::PIN_UNASSIGNED) return false;

bool attachOk = attachChannel(fl.gpio, LEDC_CH_COOL, fl.pwmFrequency, fl.pwmResolutionBits);
if (fl.gpioWarm != BoardConfig::PIN_UNASSIGNED) {
Expand Down Expand Up @@ -207,6 +239,9 @@ void FrontlightManager::begin() {
_begun = true;
setBrightness(0);
LOG_INF("FrontlightMgr", "begin: attached gpio=%d warm=%d ok=%d", fl.gpio, fl.gpioWarm, attachOk ? 1 : 0);
return attachOk;
#else
return false;
#endif
}

Expand Down
21 changes: 21 additions & 0 deletions libs/hardware/SDCardManager/include/SDCardManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,22 @@ class SDCardManager {
// Returns used space in bytes, cached with a 20-second TTL (freeClusterCount
// scans the FAT and is too slow to call on every frame). 0 if not mounted or
// the cluster count cannot be determined.
// Bytes in use. RETURNS 0 WHEN IT CANNOT ANSWER, which is also what an empty
// card returns, and caches that for the TTL -- so do not subtract it from
// sdTotalBytes() to get free space. Use sdFreeBytes(), which cannot make
// that mistake. Kept in this shape because it predates sdFreeBytes and its
// contract is upstream's; the failure now announces itself on Serial.
uint64_t sdUsedBytes();

// Free bytes on the card, and whether the volume could actually answer.
//
// Separate from sdUsedBytes() on purpose. That one returns 0 when
// freeClusterCount() fails, which is indistinguishable from a card with
// nothing on it -- so `sdTotalBytes() - sdUsedBytes()` reports a FAILURE as
// an almost-empty card. That is the one direction the error can point that
// turns "check before writing" into "write anyway", which is the whole
// reason a caller asks. Here a failure is false and out is untouched.
bool sdFreeBytes(uint64_t& out);
std::vector<String> listFiles(const char* path = "/", int maxFiles = 200);
// Read the entire file at `path` into a String. Returns empty string on failure.
String readFile(const char* path);
Expand Down Expand Up @@ -100,6 +115,12 @@ class SDCardManager {
uint64_t cachedUsedBytes = 0;
uint32_t cachedUsedBytesAt = 0;
bool cachedUsedBytesValid = false;
uint64_t cachedFreeBytes = 0;
// Whether the last refresh actually got an answer, as opposed to caching a
// zero it could not vouch for.
bool cachedFreeBytesValid = false;
// One FAT walk per TTL, shared by both accessors above.
bool refreshFreeClusters();

// All filesystem ops route through one FsVolume& so the backend is swappable.
// SPI boards: `sd` (SdFs is-a FsVolume). SDMMC boards: a bare FsVolume mounted
Expand Down
77 changes: 62 additions & 15 deletions libs/hardware/SDCardManager/src/SDCardManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -369,23 +369,70 @@ bool SDCardManager::openFileForWrite(const char* moduleName, const String& path,

uint64_t SDCardManager::sdTotalBytes() const { return cachedTotalBytes; }

uint64_t SDCardManager::sdUsedBytes() {
if (!initialized) return 0;
// Shared by sdUsedBytes() and sdFreeBytes() so both read ONE cached answer.
// freeClusterCount() walks the FAT on FAT32 without a valid FSInfo, which is
// seconds on a large card, so it must not run twice for one question.
bool SDCardManager::refreshFreeClusters() {
if (!initialized) return false;
const uint32_t now = millis();
if (!cachedUsedBytesValid || (now - cachedUsedBytesAt) >= USED_BYTES_CACHE_TTL_MS) {
const int32_t freeClusters = vol().freeClusterCount();
const uint64_t clusterCount = vol().clusterCount();
if (freeClusters < 0) {
cachedUsedBytes = 0;
} else {
const uint64_t cappedFree = (static_cast<uint64_t>(freeClusters) > clusterCount)
? clusterCount
: static_cast<uint64_t>(freeClusters);
cachedUsedBytes = (clusterCount - cappedFree) * vol().bytesPerCluster();
}
cachedUsedBytesValid = true;
cachedUsedBytesAt = now;
if (cachedUsedBytesValid && (now - cachedUsedBytesAt) < USED_BYTES_CACHE_TTL_MS) {
return cachedFreeBytesValid;
}
const int32_t freeClusters = vol().freeClusterCount();
const uint64_t clusterCount = vol().clusterCount();
cachedUsedBytesAt = now;
if (freeClusters < 0) {
// sdUsedBytes() reports this as 0 used, which is also what an empty card
// reports. Nothing in this fork reads it, and sdFreeBytes() below refuses
// to conflate the two, but a future caller reaching past it would get a
// plausible number. Say so out loud rather than leaving the trace to the
// absence of one.
//
// This used to read "and it is cached as a valid answer for the whole TTL,
// so a retry inside the window gets the same confident zero". That was
// true, and it was the bug fixed just below: the failure is no longer
// memoised, so a retry re-queries. The log line is therefore once per
// FAILED ATTEMPT rather than once per TTL -- user-paced, since only a
// button press drives it.
if (Serial) Serial.printf("[%lu] [SD] free-cluster query FAILED; used-bytes will read 0, which is a lie\n", millis());
cachedUsedBytes = 0;
cachedFreeBytes = 0;
cachedFreeBytesValid = false;
// Do NOT memoise the failure. A cache stores ANSWERS, and "the card did not
// answer" is not one -- caching it made the guard above return the stale
// false for the whole TTL without re-querying, so an app's TRY AGAIN could
// not succeed for 20 seconds. That is worst on a NO ROOM screen: the user
// deletes a book, retries inside the window, is told there is still no
// room, and reasonably concludes deleting did not help. The screen punishes
// the correct response, and no wording rescues a button that cannot work.
//
// Chosen over a force flag on the retry path: a flag puts cache knowledge
// in every caller and can be used to defeat the TTL entirely, which is the
// seconds-long walk this cache exists to prevent. Leaving a failure
// uncached costs one re-query per user-paced button press.
cachedUsedBytesValid = false;
return false;
}
cachedUsedBytesValid = true;
const uint64_t cappedFree =
(static_cast<uint64_t>(freeClusters) > clusterCount) ? clusterCount : static_cast<uint64_t>(freeClusters);
cachedUsedBytes = (clusterCount - cappedFree) * vol().bytesPerCluster();
cachedFreeBytes = cappedFree * vol().bytesPerCluster();
cachedFreeBytesValid = true;
return true;
}

bool SDCardManager::sdFreeBytes(uint64_t& out) {
if (!refreshFreeClusters()) return false;
out = cachedFreeBytes;
return true;
}

uint64_t SDCardManager::sdUsedBytes() {
// Unchanged contract: 0 when the volume cannot answer, which several callers
// already treat as "unknown or empty". sdFreeBytes() is the one that tells
// those two apart.
refreshFreeClusters();
return cachedUsedBytes;
}

Expand Down
22 changes: 21 additions & 1 deletion libs/ui/FreeInkUI/include/FreeInkApp.h
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ template <size_t MaxInteractions> class Screen {
marginBeyondSafeArea(margin.bottom, safe.bottom),
marginBeyondSafeArea(margin.left, safe.left)});
}
// Margins measured against the FULL screen frame, for hosts whose chrome is
// drawn at absolute renderer coordinates and whose margins already fold the
// device's safe area in (they derive from an insets-aware safe-area rect).
// setContentMargin() measures from safeRect(); using it with absolute
// margins applies the safe area twice. Distinct from
// setContentMarginFromScreen(), which takes max(margin, safeArea) per side:
// that is the right behaviour for a chrome reservation, but a caller passing
// a zero or sub-safe-area margin here means it deliberately wants the full
// frame, and must not be inset again.
void setContentMarginAbsolute(Insets margin) {
content_ = insetClamped(frame_.screen(), margin);
}
void insetContent(Insets margin) {
content_ = insetClamped(content_, margin);
}
Expand Down Expand Up @@ -142,6 +154,14 @@ template <size_t MaxInteractions> class Screen {

void header(const HeaderProps &props,
LayoutAnchor anchor = LayoutAnchor::Top) {
header(props, take(anchor, theme_.headerHeight));
}

// Themed header at an explicit rect, for chrome that positions the band
// itself (e.g. a band whose visible top tracks the device safe area while
// the layout below keeps its own geometry). Does not consume body space --
// pair with takeTop when the band should be reserved.
void header(const HeaderProps &props, Rect rect) {
HeaderProps themed = props;
if (textStyleUnset(themed.titleText)) {
themed.titleText = theme_.titleText;
Expand All @@ -167,7 +187,7 @@ template <size_t MaxInteractions> class Screen {
themed.styles.normal.borderWidth = theme_.headerUnderline;
}
themed.minTouchSize = theme_.minTouchSize;
ui::header(frame_, take(anchor, theme_.headerHeight), themed);
ui::header(frame_, rect, themed);
}

// Sub-screen chrome: leading back button + centered title, with an optional
Expand Down
10 changes: 10 additions & 0 deletions libs/ui/FreeInkUI/include/FreeInkUIGfxRenderer.h
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ class GfxRendererTarget final : public DrawTarget {
device.width = static_cast<int16_t>(renderer.getScreenWidth());
device.height = static_cast<int16_t>(renderer.getScreenHeight());
device.orientation = static_cast<Orientation>(renderer.getOrientation());
// The panel's bezel-covered edge pixels (BoardConfig viewableInsets,
// rotated into this orientation). Screen seeds its content rect from
// safeRect(), so everything laid out through it clears the glass;
// deliberate full-bleed paint keeps using screen().
{
int top = 0, right = 0, bottom = 0, left = 0;
renderer.getOrientedViewableTRBL(&top, &right, &bottom, &left);
device.safeArea = Insets{static_cast<int16_t>(top), static_cast<int16_t>(right),
static_cast<int16_t>(bottom), static_cast<int16_t>(left)};
}
// Same inverse-of-render-rotation selector as DisplayTarget: this makes
// touchToLogical() agree case-for-case with GfxRenderer::tapToLogical, so
// FreeInkUI components and the firmware's own tap path map taps identically.
Expand Down