diff --git a/libs/hardware/BoardConfig/include/BoardConfig.h b/libs/hardware/BoardConfig/include/BoardConfig.h index 1b9935fc..5c306989 100644 --- a/libs/hardware/BoardConfig/include/BoardConfig.h +++ b/libs/hardware/BoardConfig/include/BoardConfig.h @@ -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 @@ -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 --- diff --git a/libs/hardware/FrontlightManager/include/FrontlightManager.h b/libs/hardware/FrontlightManager/include/FrontlightManager.h index bdcb0e0f..295b1270 100644 --- a/libs/hardware/FrontlightManager/include/FrontlightManager.h +++ b/libs/hardware/FrontlightManager/include/FrontlightManager.h @@ -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 diff --git a/libs/hardware/FrontlightManager/src/FrontlightManager.cpp b/libs/hardware/FrontlightManager/src/FrontlightManager.cpp index 1ee59e94..264fd424 100644 --- a/libs/hardware/FrontlightManager/src/FrontlightManager.cpp +++ b/libs/hardware/FrontlightManager/src/FrontlightManager.cpp @@ -8,6 +8,7 @@ #ifdef FREEINK_FRONTLIGHT_LS #include #include +#include // 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 @@ -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(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; @@ -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(static_cast(freq) << bits), + static_cast(SOC_CLK_RC_FAST_FREQ_APPROX)); return false; } ledc_channel_config_t chan = {}; @@ -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); @@ -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) { @@ -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 } diff --git a/libs/hardware/SDCardManager/include/SDCardManager.h b/libs/hardware/SDCardManager/include/SDCardManager.h index 136442c5..c96e70f7 100644 --- a/libs/hardware/SDCardManager/include/SDCardManager.h +++ b/libs/hardware/SDCardManager/include/SDCardManager.h @@ -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 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); @@ -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 diff --git a/libs/hardware/SDCardManager/src/SDCardManager.cpp b/libs/hardware/SDCardManager/src/SDCardManager.cpp index 8a2af346..4dc970a5 100644 --- a/libs/hardware/SDCardManager/src/SDCardManager.cpp +++ b/libs/hardware/SDCardManager/src/SDCardManager.cpp @@ -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(freeClusters) > clusterCount) - ? clusterCount - : static_cast(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(freeClusters) > clusterCount) ? clusterCount : static_cast(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; } diff --git a/libs/ui/FreeInkUI/include/FreeInkApp.h b/libs/ui/FreeInkUI/include/FreeInkApp.h index 2c755f40..af03b514 100644 --- a/libs/ui/FreeInkUI/include/FreeInkApp.h +++ b/libs/ui/FreeInkUI/include/FreeInkApp.h @@ -75,6 +75,18 @@ template 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); } @@ -142,6 +154,14 @@ template 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; @@ -167,7 +187,7 @@ template 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 diff --git a/libs/ui/FreeInkUI/include/FreeInkUIGfxRenderer.h b/libs/ui/FreeInkUI/include/FreeInkUIGfxRenderer.h index a649aad6..453ba6ce 100644 --- a/libs/ui/FreeInkUI/include/FreeInkUIGfxRenderer.h +++ b/libs/ui/FreeInkUI/include/FreeInkUIGfxRenderer.h @@ -50,6 +50,16 @@ class GfxRendererTarget final : public DrawTarget { device.width = static_cast(renderer.getScreenWidth()); device.height = static_cast(renderer.getScreenHeight()); device.orientation = static_cast(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(top), static_cast(right), + static_cast(bottom), static_cast(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.