Add framebuffer lending for memory-intensive builds
Temporarily release framebuffer memory during section pagination to reduce heap pressure. The framebuffer can be released before memory-intensive operations and restored afterward, allowing BLE stack startup threshold to be lowered from 80KB to 70KB. Implements RAII-style FrameBufferBuildLoan class to ensure proper cleanup and automatic restart on restoration failure.
This commit is contained in:
+1
-1
Submodule freeink-sdk updated: 12f9af14bb...2442e1d672
@@ -62,7 +62,14 @@ void FontCacheManager::resetStats() {
|
||||
bool FontCacheManager::isScanning() const { return scanMode_ == ScanMode::Scanning; }
|
||||
|
||||
void FontCacheManager::recordText(const char* text, int fontId, EpdFontFamily::Style style) {
|
||||
scanText_ += text;
|
||||
if (!text) return;
|
||||
const size_t remaining = (scanTextLen_ < SCAN_TEXT_CAPACITY - 1) ? (SCAN_TEXT_CAPACITY - 1 - scanTextLen_) : 0;
|
||||
if (remaining > 0) {
|
||||
const size_t textLen = strnlen(text, remaining);
|
||||
memcpy(scanText_ + scanTextLen_, text, textLen);
|
||||
scanTextLen_ += textLen;
|
||||
scanText_[scanTextLen_] = '\0';
|
||||
}
|
||||
if (scanFontId_ < 0) scanFontId_ = fontId;
|
||||
const uint8_t baseStyle = static_cast<uint8_t>(style) & 0x03;
|
||||
const unsigned char* p = reinterpret_cast<const unsigned char*>(text);
|
||||
@@ -80,15 +87,15 @@ FontCacheManager::PrewarmScope::PrewarmScope(FontCacheManager& manager) : manage
|
||||
manager_->scanMode_ = ScanMode::Scanning;
|
||||
manager_->clearCache();
|
||||
manager_->resetStats();
|
||||
manager_->scanText_.clear();
|
||||
manager_->scanText_.reserve(2048); // Pre-allocate to avoid heap fragmentation from repeated concat
|
||||
manager_->scanTextLen_ = 0;
|
||||
manager_->scanText_[0] = '\0';
|
||||
memset(manager_->scanStyleCounts_, 0, sizeof(manager_->scanStyleCounts_));
|
||||
manager_->scanFontId_ = -1;
|
||||
}
|
||||
|
||||
void FontCacheManager::PrewarmScope::endScanAndPrewarm() {
|
||||
manager_->scanMode_ = ScanMode::None;
|
||||
if (manager_->scanText_.empty()) return;
|
||||
if (manager_->scanTextLen_ == 0) return;
|
||||
|
||||
// Build style bitmask from all styles that appeared during the scan
|
||||
uint8_t styleMask = 0;
|
||||
@@ -97,11 +104,10 @@ void FontCacheManager::PrewarmScope::endScanAndPrewarm() {
|
||||
}
|
||||
if (styleMask == 0) styleMask = 1; // default to regular
|
||||
|
||||
manager_->prewarmCache(manager_->scanFontId_, manager_->scanText_.c_str(), styleMask);
|
||||
manager_->prewarmCache(manager_->scanFontId_, manager_->scanText_, styleMask);
|
||||
|
||||
// Free scan string memory
|
||||
manager_->scanText_.clear();
|
||||
manager_->scanText_.shrink_to_fit();
|
||||
manager_->scanTextLen_ = 0;
|
||||
manager_->scanText_[0] = '\0';
|
||||
}
|
||||
|
||||
FontCacheManager::PrewarmScope::~PrewarmScope() {
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
#include <EpdFontFamily.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
class FontDecompressor;
|
||||
class SdCardFont;
|
||||
@@ -51,7 +51,9 @@ class FontCacheManager {
|
||||
|
||||
enum class ScanMode : uint8_t { None, Scanning };
|
||||
ScanMode scanMode_ = ScanMode::None;
|
||||
std::string scanText_;
|
||||
static constexpr size_t SCAN_TEXT_CAPACITY = 2048;
|
||||
char scanText_[SCAN_TEXT_CAPACITY] = {};
|
||||
size_t scanTextLen_ = 0;
|
||||
uint32_t scanStyleCounts_[4] = {};
|
||||
int scanFontId_ = -1;
|
||||
};
|
||||
|
||||
@@ -91,6 +91,20 @@ void GfxRenderer::begin() {
|
||||
bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr);
|
||||
}
|
||||
|
||||
void GfxRenderer::releaseFrameBufferForBuild() {
|
||||
display.releaseFrameBuffers();
|
||||
frameBuffer = nullptr;
|
||||
}
|
||||
|
||||
bool GfxRenderer::restoreFrameBufferAfterBuild() {
|
||||
if (!display.reallocFrameBuffers()) {
|
||||
LOG_ERR("GFX", "Framebuffer realloc failed after build");
|
||||
return false;
|
||||
}
|
||||
frameBuffer = display.getFrameBuffer();
|
||||
return frameBuffer != nullptr;
|
||||
}
|
||||
|
||||
bool GfxRenderer::isFontCacheScanning() const { return fontCacheManager_ && fontCacheManager_->isScanning(); }
|
||||
|
||||
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
|
||||
|
||||
@@ -250,6 +250,13 @@ class GfxRenderer {
|
||||
// Font helpers
|
||||
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
|
||||
|
||||
// Lend the framebuffer to memory-hungry phases such as section pagination.
|
||||
// Nothing may draw/display while it is released. restore returns the buffer
|
||||
// white, so callers must redraw the full screen afterward.
|
||||
void releaseFrameBufferForBuild();
|
||||
bool restoreFrameBufferAfterBuild();
|
||||
bool hasFrameBuffer() const { return frameBuffer != nullptr; }
|
||||
|
||||
// Low level functions
|
||||
uint8_t* getFrameBuffer() const;
|
||||
size_t getBufferSize() const;
|
||||
|
||||
@@ -77,6 +77,10 @@ void HalDisplay::deepSleep() { einkDisplay.deepSleep(); }
|
||||
|
||||
uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
||||
|
||||
void HalDisplay::releaseFrameBuffers() { einkDisplay.releaseBuffers(); }
|
||||
|
||||
bool HalDisplay::reallocFrameBuffers() { return einkDisplay.reallocBuffers(); }
|
||||
|
||||
void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer) {
|
||||
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ class HalDisplay {
|
||||
// Access to frame buffer
|
||||
uint8_t* getFrameBuffer() const;
|
||||
|
||||
// Lend the framebuffer's RAM to a memory-hungry phase. No display calls may
|
||||
// run between release and a successful realloc; buffers come back white, so
|
||||
// callers must redraw the full screen.
|
||||
void releaseFrameBuffers();
|
||||
bool reallocFrameBuffers();
|
||||
|
||||
// X3 grayscale preconditioning (OEM "AA-pre-BW(mid)" settle pass), windowed
|
||||
// to the gray region in physical panel coordinates (no-arg = full frame).
|
||||
// Call after the BW base frame is displayed and before the grayscale planes
|
||||
|
||||
+7
-6
@@ -13,17 +13,22 @@
|
||||
namespace bleinput {
|
||||
|
||||
namespace {
|
||||
volatile bool g_lifecyclePaused = false;
|
||||
volatile bool g_startInProgress = false;
|
||||
}
|
||||
|
||||
// NimBLE controller init/deinit hang (interrupt WDT) if run at the 10 MHz low-power
|
||||
// frequency, so force normal CPU speed around both. Centralized here so every caller
|
||||
// (boot restore, settings toggle, reader toggle, sleep) is covered automatically.
|
||||
bool ensureStarted() {
|
||||
g_startInProgress = true;
|
||||
HalPowerManager::Lock powerLock;
|
||||
return BleHid.begin(kHostName);
|
||||
const bool ok = BleHid.begin(kHostName);
|
||||
g_startInProgress = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool startInProgress() { return g_startInProgress; }
|
||||
|
||||
// Full teardown (NimBLE deinit), not just a link drop, so the BLE stack's RAM is
|
||||
// returned to the heap — otherwise memory-hungry work like EPUB inflate can't
|
||||
// allocate even after the user turns Bluetooth off.
|
||||
@@ -32,10 +37,6 @@ void stop() {
|
||||
BleHid.end();
|
||||
}
|
||||
|
||||
void setLifecyclePaused(bool paused) { g_lifecyclePaused = paused; }
|
||||
|
||||
bool lifecyclePaused() { return g_lifecyclePaused; }
|
||||
|
||||
bool encodeKey(const freeink::KeyEvent& ev, uint8_t& kind, uint8_t& value) {
|
||||
if (ev.special != freeink::SpecialKey::None) {
|
||||
kind = 0;
|
||||
|
||||
+6
-15
@@ -25,16 +25,11 @@ namespace bleinput {
|
||||
// Advertised central name shown to peripherals during pairing.
|
||||
inline constexpr const char* kHostName = "CrossPoint";
|
||||
|
||||
// Heap floor for starting the NimBLE stack (measured begin() cost: ~52 KB). This must
|
||||
// be reachable in steady-state reading, not just at fresh boot: with an SD font and a
|
||||
// loaded section, mid-session idle heap is ~84 KB — a 100 KB floor was only ever
|
||||
// passed via a stale-gate bug (heap sampled before the render lock), and once that
|
||||
// was fixed the stack could never start again after a shed. 80 KB starts at ~84 KB
|
||||
// steady state and leaves ~32 KB of reading slack; the render shed floor
|
||||
// (RENDER_MIN_FREE_HEAP, 24 KB) backstops the tight sessions, and the lifecycle
|
||||
// cooldown paces any shed/restart cycle. Shared by the main-loop lifecycle gate and
|
||||
// the reader menu's toggle (which offers a defrag restart below the floor).
|
||||
inline constexpr size_t kStartMinFreeHeap = 80 * 1024;
|
||||
// Heap floor for starting the NimBLE stack (measured begin() cost: ~52-57 KB).
|
||||
// The reader now lends the framebuffer to section builds, so BLE startup no
|
||||
// longer needs to reserve the old full build headroom. Keep a modest margin and
|
||||
// let the render/build shed paths handle genuinely tight moments.
|
||||
inline constexpr size_t kStartMinFreeHeap = 56 * 1024;
|
||||
|
||||
// Lower floor for the Bluetooth settings screen, where the user has explicitly asked
|
||||
// for BLE right now (scanning/pairing is dead without the stack). No page renders or
|
||||
@@ -45,15 +40,11 @@ inline constexpr size_t kStartMinFreeHeapExplicit = 70 * 1024;
|
||||
// Start the BLE HID host (idempotent). Returns false if BLE is compiled out or
|
||||
// NimBLE init failed. Safe to call repeatedly.
|
||||
bool ensureStarted();
|
||||
bool startInProgress();
|
||||
|
||||
// Drop the active link (e.g. before deep sleep or when the user disables BT).
|
||||
void stop();
|
||||
|
||||
// Temporarily block the main-loop BLE lifecycle from auto-starting the stack.
|
||||
// Used while a render path deliberately frees BLE RAM for a large allocation.
|
||||
void setLifecyclePaused(bool paused);
|
||||
bool lifecyclePaused();
|
||||
|
||||
// Encode a decoded key event into the stable (kind, value) identity used by the
|
||||
// settings map. kind: 0 = SpecialKey, 1 = HID usage. Returns false when the event
|
||||
// carries no usable identity (no special key and no usage code).
|
||||
|
||||
@@ -48,11 +48,8 @@ class Activity {
|
||||
// covered by isReaderActivity()). The Bluetooth settings screen overrides this so
|
||||
// pairing/scanning works there. Everywhere else BLE is torn down to free heap.
|
||||
virtual bool keepsBluetoothAlive() const { return false; }
|
||||
// True while the activity is doing (or has pending) heap-heavy work that must
|
||||
// finish before the BLE stack (~52 KB) may start. The reader overrides this while
|
||||
// a section build has catch-up work inside its window: restarting BLE mid-build
|
||||
// just re-enters the heap state that forced the shed (observed as a 163 ms
|
||||
// shed -> restart -> shed flap in the field).
|
||||
// True while the current activity is doing heap-heavy work that must finish
|
||||
// before the BLE stack (~52 KB) may start.
|
||||
virtual bool deferBluetoothStart() const { return false; }
|
||||
// Ask the activity to make its next render a full ghost-cleanup (HALF) refresh rather
|
||||
// than a fast/partial one. Used after drawing a transient popup over grayscale content
|
||||
|
||||
@@ -67,6 +67,55 @@ bool isInReadFolder(const std::string& path) {
|
||||
return path.size() > n && path.compare(0, n, READ_FOLDER) == 0 && path[n] == '/';
|
||||
}
|
||||
|
||||
class FrameBufferBuildLoan {
|
||||
public:
|
||||
explicit FrameBufferBuildLoan(GfxRenderer& renderer) : renderer_(renderer) {}
|
||||
~FrameBufferBuildLoan() {
|
||||
if (active_ && !restore()) {
|
||||
ESP.restart();
|
||||
}
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (active_ || !renderer_.hasFrameBuffer()) return;
|
||||
if (bleinput::startInProgress()) {
|
||||
LOG_INF("ERS", "Framebuffer loan waiting for BLE start to settle");
|
||||
const uint32_t deadline = millis() + 1000;
|
||||
while (bleinput::startInProgress() && millis() < deadline) {
|
||||
delay(5);
|
||||
}
|
||||
}
|
||||
renderer_.releaseFrameBufferForBuild();
|
||||
active_ = true;
|
||||
LOG_DBG("ERS", "Framebuffer lent for section build (ble=%u heap=%u maxAlloc=%u)", BleHid.isRunning() ? 1 : 0,
|
||||
(unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap());
|
||||
}
|
||||
|
||||
bool restore() {
|
||||
if (!active_) return true;
|
||||
active_ = false;
|
||||
if (renderer_.restoreFrameBufferAfterBuild()) {
|
||||
LOG_DBG("ERS", "Framebuffer restored after section build");
|
||||
return true;
|
||||
}
|
||||
if (BleHid.isRunning()) {
|
||||
LOG_INF("ERS", "Framebuffer restore needs heap; freeing BLE and retrying (heap=%u maxAlloc=%u)",
|
||||
(unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap());
|
||||
bleinput::stop();
|
||||
if (renderer_.restoreFrameBufferAfterBuild()) {
|
||||
LOG_DBG("ERS", "Framebuffer restored after freeing BLE");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
LOG_ERR("ERS", "Framebuffer restore failed after section build");
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
GfxRenderer& renderer_;
|
||||
bool active_ = false;
|
||||
};
|
||||
|
||||
struct ProgressRange {
|
||||
float start;
|
||||
float end;
|
||||
@@ -263,6 +312,11 @@ bool EpubReaderActivity::buildTickHeapGate() {
|
||||
if (freeHeap >= BACKGROUND_BUILD_MIN_FREE_HEAP && maxBlock >= BACKGROUND_BUILD_MIN_MAX_ALLOC) {
|
||||
return true;
|
||||
}
|
||||
const size_t lendableFrameBuffer = renderer.hasFrameBuffer() ? renderer.getBufferSize() : 0;
|
||||
if (lendableFrameBuffer > 0 && freeHeap + lendableFrameBuffer >= BACKGROUND_BUILD_MIN_FREE_HEAP &&
|
||||
maxBlock + lendableFrameBuffer >= BACKGROUND_BUILD_MIN_MAX_ALLOC) {
|
||||
return true;
|
||||
}
|
||||
// Below the floors. If the BLE stack is what's squeezing the heap, shed it — the
|
||||
// established policy on this branch is that builds and resident BLE don't coexist,
|
||||
// and this was the one build path without that protection (field crash: a tick's
|
||||
@@ -300,6 +354,8 @@ void EpubReaderActivity::loop() {
|
||||
// mutation, so it flags this as always true.
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (section->isBuilding()) {
|
||||
FrameBufferBuildLoan buildLoan(renderer);
|
||||
buildLoan.release();
|
||||
if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) {
|
||||
LOG_ERR("ERS", "Background section build failed");
|
||||
section.reset();
|
||||
@@ -309,6 +365,9 @@ void EpubReaderActivity::loop() {
|
||||
// real page count, so re-render at the remapped page. No-op for an unchanged resume.
|
||||
requestUpdate();
|
||||
}
|
||||
if (!buildLoan.restore()) {
|
||||
ESP.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -910,15 +969,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The build-recovery path below frees the BLE stack and pauses the main-loop lifecycle
|
||||
// so it can't restart NimBLE while this render is still allocating (glyph prewarm, scan
|
||||
// strings -- an inline restart re-starved exactly that and abort()ed). Unpause only
|
||||
// when the render fully completes, on every exit path; the main-loop lifecycle then
|
||||
// brings BLE back and shows its reconnect popup.
|
||||
struct LifecycleUnpause {
|
||||
~LifecycleUnpause() { bleinput::setLifecyclePaused(false); }
|
||||
} lifecycleUnpause;
|
||||
|
||||
// Shed the BLE stack before rendering into a starved heap. Everything below —
|
||||
// page deserialization, glyph caching, catch-up build steps — allocates through
|
||||
// throwing paths that abort() on OOM under -fno-exceptions. Field data: with no
|
||||
@@ -929,7 +979,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
if (BleHid.isRunning() && ESP.getFreeHeap() < RENDER_MIN_FREE_HEAP) {
|
||||
LOG_ERR("ERS", "Render heap %u below floor %u; freeing BLE RAM", (unsigned)ESP.getFreeHeap(),
|
||||
(unsigned)RENDER_MIN_FREE_HEAP);
|
||||
bleinput::setLifecyclePaused(true);
|
||||
bleinput::stop();
|
||||
}
|
||||
|
||||
@@ -939,10 +988,16 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
GUI.drawPopup(renderer, tr(STR_SAVE_PROGRESS_FAILED));
|
||||
};
|
||||
|
||||
FrameBufferBuildLoan buildLoan(renderer);
|
||||
|
||||
// A section build failure (e.g. an invalid/corrupt EPUB that fails XML parsing) leaves the
|
||||
// "Indexing" popup on screen with no way forward. Surface an explicit error instead of hanging.
|
||||
// clearScreen first so the error popup doesn't overlay the stale "Indexing" popup.
|
||||
const auto showBuildError = [this]() {
|
||||
const auto showBuildError = [this, &buildLoan]() {
|
||||
if (!buildLoan.restore()) {
|
||||
ESP.restart();
|
||||
return;
|
||||
}
|
||||
renderer.clearScreen();
|
||||
GUI.drawPopup(renderer, tr(STR_INDEX_FAILED));
|
||||
automaticPageTurnActive = false;
|
||||
@@ -1026,10 +1081,11 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
// the build: pre-flight the floor and take the recovery path up front instead of
|
||||
// crashing mid-parse. Field data: builds succeed at ~46 KB free with BLE resident;
|
||||
// abort() observed at ~11 KB free.
|
||||
const bool heapTooLow = ESP.getFreeHeap() < BUILD_MIN_FREE_HEAP;
|
||||
const size_t lendableFrameBuffer = renderer.hasFrameBuffer() ? renderer.getBufferSize() : 0;
|
||||
const bool heapTooLow = ESP.getFreeHeap() + lendableFrameBuffer < BUILD_MIN_FREE_HEAP;
|
||||
if (heapTooLow) {
|
||||
LOG_ERR("ERS", "Pre-build heap %u below floor %u; entering build recovery", (unsigned)ESP.getFreeHeap(),
|
||||
(unsigned)BUILD_MIN_FREE_HEAP);
|
||||
LOG_ERR("ERS", "Pre-build heap %u (+fb %u) below floor %u; entering build recovery",
|
||||
(unsigned)ESP.getFreeHeap(), (unsigned)lendableFrameBuffer, (unsigned)BUILD_MIN_FREE_HEAP);
|
||||
}
|
||||
|
||||
// Building a section needs a large contiguous inflate (deflate) window that the
|
||||
@@ -1038,14 +1094,10 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
// and retry. The chapter is cached afterwards, so this recovery runs at most once
|
||||
// per uncached chapter.
|
||||
// Deliberately do NOT restart BLE inline: this render still has its own allocations
|
||||
// to make (glyph prewarm, scan strings), and an inline NimBLE restart re-starves
|
||||
// it -- field crash: std::string::reserve(2048) abort()ed at ~8 KB free right
|
||||
// after an inline restart. The lifecycle stays paused until this render fully
|
||||
// completes (unpause guard at the top of render()); the main-loop lifecycle then
|
||||
// restarts BLE behind its own heap gate and shows the reconnect popup.
|
||||
// to make. The main-loop lifecycle restarts BLE later, behind its activity,
|
||||
// render-lock, framebuffer, and heap gates.
|
||||
const auto retryWithBleFreed = [&](auto&& buildFn) {
|
||||
LOG_INF("ERS", "Section build needs heap; freeing BLE RAM and retrying");
|
||||
bleinput::setLifecyclePaused(true);
|
||||
bleinput::stop();
|
||||
return buildFn();
|
||||
};
|
||||
@@ -1080,7 +1132,12 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
// The popup's own refresh is a plain FAST, so force the page that replaces it onto the HALF
|
||||
// ghost-cleanup path -- otherwise the "INDEXING" text ghosts under the rendered page.
|
||||
pagesUntilFullRefresh = 1;
|
||||
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
|
||||
const auto popupFn = [this]() {
|
||||
if (renderer.hasFrameBuffer()) {
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
}
|
||||
};
|
||||
buildLoan.release();
|
||||
const auto buildSection = [&]() {
|
||||
return section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
@@ -1131,6 +1188,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
|
||||
pagesUntilFullRefresh = 1;
|
||||
}
|
||||
buildLoan.release();
|
||||
// startBuild does the zip inflate (the big contiguous allocation), so it gets
|
||||
// the BLE free-and-retry fallback too; it cleans up fully on failure, making a
|
||||
// retry safe.
|
||||
@@ -1206,6 +1264,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
// ahead of the background builder; pages already built do no work here.
|
||||
while (section->isPartial() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||
// Start a build to extend a partial toward the requested page.
|
||||
buildLoan.release();
|
||||
if (!section->isBuilding() &&
|
||||
!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight,
|
||||
@@ -1228,6 +1287,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
}
|
||||
// For an in-progress incremental build, make sure the page we're about to show has been laid out.
|
||||
if (section->isBuilding()) {
|
||||
buildLoan.release();
|
||||
while (!section->isBuildComplete() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
||||
LOG_ERR("ERS", "Failed during incremental section build");
|
||||
@@ -1238,6 +1298,14 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
}
|
||||
}
|
||||
|
||||
const auto restoreFramebufferForDraw = [&buildLoan]() {
|
||||
if (!buildLoan.restore()) {
|
||||
ESP.restart();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// The requested page is now as built as it will get. If it still lands past the end,
|
||||
// clamp to the last real page: the UINT16_MAX "last page" sentinel from backward chapter
|
||||
// navigation, an explicit jump beyond a finished chapter, or a stale saved position.
|
||||
@@ -1252,10 +1320,10 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
// a plain resume / unchanged pagination). If still building, this defers to loop() on completion.
|
||||
applyDeferredReposition();
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
if (section->pageCount == 0) {
|
||||
LOG_DBG("ERS", "No pages to render");
|
||||
if (!restoreFramebufferForDraw()) return;
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_CHAPTER), true, EpdFontFamily::BOLD);
|
||||
renderStatusBar();
|
||||
renderer.displayBuffer();
|
||||
@@ -1266,6 +1334,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
|
||||
if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
|
||||
LOG_DBG("ERS", "Page out of bounds: %d (max %d)", section->currentPage, section->pageCount);
|
||||
if (!restoreFramebufferForDraw()) return;
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_OUT_OF_BOUNDS), true, EpdFontFamily::BOLD);
|
||||
renderStatusBar();
|
||||
renderer.displayBuffer();
|
||||
@@ -1294,6 +1364,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
if (giveUp) {
|
||||
LOG_ERR("ERS", "Page load retry limit reached, aborting");
|
||||
pageLoadRetryCount = 0; // Reset so a later user-initiated navigation can try afresh
|
||||
if (!restoreFramebufferForDraw()) return;
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_PAGE_LOAD_ERROR), true, EpdFontFamily::BOLD);
|
||||
renderer.displayBuffer();
|
||||
@@ -1309,6 +1380,9 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
// Collect footnotes from the loaded page
|
||||
currentPageFootnotes = std::move(p->footnotes);
|
||||
|
||||
if (!restoreFramebufferForDraw()) return;
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto start = millis();
|
||||
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
|
||||
@@ -1559,6 +1633,11 @@ void EpubReaderActivity::renderStatusBar() const {
|
||||
title = epub->getTitle();
|
||||
}
|
||||
|
||||
if (SETTINGS.bluetoothEnabled && !BleHid.isConnected()) {
|
||||
const std::string btStatus = tr(STR_BT_CONNECTING_POPUP);
|
||||
title = title.empty() ? btStatus : btStatus + " " + title;
|
||||
}
|
||||
|
||||
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked,
|
||||
section->isBuilding());
|
||||
}
|
||||
|
||||
@@ -156,15 +156,6 @@ class EpubReaderActivity final : public Activity {
|
||||
void loop() override;
|
||||
void render(RenderLock&& lock) override;
|
||||
bool isReaderActivity() const override { return true; }
|
||||
// Hold BLE off only while the background build has catch-up work pending inside
|
||||
// its window (same condition loop() uses to tick it). Gating on isBuilding() alone
|
||||
// would hold BLE off for the rest of the chapter — a windowed build stays
|
||||
// "building" until the reader walks the whole spine. Unlocked read, same pattern
|
||||
// as the background-build check in loop().
|
||||
bool deferBluetoothStart() const override {
|
||||
return section && section->isBuilding() &&
|
||||
static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD;
|
||||
}
|
||||
void requestGhostCleanup() override { pagesUntilFullRefresh = 1; }
|
||||
ScreenshotInfo getScreenshotInfo() const override;
|
||||
CrossPointPosition getCurrentPosition() const;
|
||||
|
||||
@@ -92,11 +92,10 @@ void EpubReaderMenuActivity::loop() {
|
||||
|
||||
if (selectedAction == MenuAction::TOGGLE_BLUETOOTH) {
|
||||
// Just flip the preference and stay in the menu. The main-loop lifecycle check
|
||||
// brings the BLE stack up/down to match (and shows the "BT Connecting..." popup),
|
||||
// so start/stop has a single owner.
|
||||
// brings the BLE stack up/down to match, so start/stop has a single owner.
|
||||
SETTINGS.bluetoothEnabled = SETTINGS.bluetoothEnabled ? 0 : 1;
|
||||
SETTINGS.saveToFile();
|
||||
// Turning BT on below the lifecycle's heap floor would otherwise sit in PAUSED
|
||||
// Turning BT on below the lifecycle's heap floor would otherwise wait
|
||||
// until the heap happens to recover -- which a long session's fragmentation never
|
||||
// gives back. The user asked for BT *now*: silent-restart into this book to
|
||||
// defrag (fresh boot is ~118 KB free, comfortably above the floor), and BT
|
||||
@@ -161,10 +160,9 @@ void EpubReaderMenuActivity::render(RenderLock&&) {
|
||||
// Render current page turn value on the right edge of the content area.
|
||||
return pageTurnLabels[selectedPageTurnOption];
|
||||
} else if (value == MenuAction::TOGGLE_BLUETOOTH) {
|
||||
// Render current Bluetooth state on the right edge. "Enabled but the stack
|
||||
// isn't running" is the low-memory deferral -- show PAUSED, not a lie.
|
||||
if (SETTINGS.bluetoothEnabled) {
|
||||
return BleHid.isRunning() ? tr(STR_STATE_ON) : tr(STR_STATE_PAUSED);
|
||||
if (!BleHid.isRunning()) return tr(STR_CONNECTING);
|
||||
return BleHid.isConnected() ? tr(STR_STATE_ON) : tr(STR_CONNECTING);
|
||||
}
|
||||
return tr(STR_STATE_OFF);
|
||||
} else {
|
||||
|
||||
@@ -31,5 +31,6 @@ class ReaderActivity final : public Activity {
|
||||
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath)
|
||||
: Activity("Reader", renderer, mappedInput), initialBookPath(std::move(initialBookPath)) {}
|
||||
void onEnter() override;
|
||||
bool isReaderActivity() const override { return true; }
|
||||
bool isReaderActivity() const override { return false; }
|
||||
bool deferBluetoothStart() const override { return true; }
|
||||
};
|
||||
|
||||
+32
-71
@@ -497,68 +497,49 @@ void setup() {
|
||||
|
||||
// Bring the BLE stack up or down to match the current context. BLE is only resident
|
||||
// while a reader (page-turner input) or the Bluetooth settings screen (pairing) is on
|
||||
// the stack AND WiFi is off — WiFi and BLE share the one C3 radio and can't both fit in
|
||||
// heap. Called every loop; ensureStarted()/stop() are no-ops when already in the right
|
||||
// state, so this only does work on a transition.
|
||||
// the stack AND WiFi is off. Heap-heavy reader phases may stop BLE directly; this
|
||||
// lifecycle then restarts it only after the normal activity/render/heap gates pass.
|
||||
void updateBluetoothLifecycle() {
|
||||
const bool wanted =
|
||||
SETTINGS.bluetoothEnabled && activityManager.bluetoothShouldBeActive() && WiFi.getMode() == WIFI_MODE_NULL;
|
||||
// Track recovery teardowns: while the reader's build recovery holds the lifecycle
|
||||
// paused it has taken BLE down deliberately. After that, hold a cool-down before any
|
||||
// restart -- an immediate restart re-enters the exact heap state that just failed and
|
||||
// the lifecycle becomes an oscillator (restart -> connect popup -> build fails ->
|
||||
// teardown -> restart...), observed in the field as a "BT Connecting..." loop.
|
||||
static uint32_t recoveryTeardownMs = 0;
|
||||
if (bleinput::lifecyclePaused()) recoveryTeardownMs = millis();
|
||||
if (wanted && !BleHid.isRunning() && bleinput::lifecyclePaused()) return;
|
||||
// Never start while the reader has build catch-up work pending: restarting into a
|
||||
// pending build just re-enters the heap state that forced the shed (field: a
|
||||
// 163 ms shed -> restart -> shed flap at a chapter watermark). Builds tick fast;
|
||||
// the deferral clears itself within seconds and no popup is warranted.
|
||||
if (wanted && !BleHid.isRunning() && activityManager.bluetoothStartDeferred()) {
|
||||
static uint32_t lastBuildDeferLogMs = 0;
|
||||
if (millis() - lastBuildDeferLogMs > 10000) {
|
||||
lastBuildDeferLogMs = millis();
|
||||
LOG_INF("BLELC", "start deferred: section build in progress heap=%u", ESP.getFreeHeap());
|
||||
static uint32_t lastActivityDeferLogMs = 0;
|
||||
if (millis() - lastActivityDeferLogMs > 10000) {
|
||||
lastActivityDeferLogMs = millis();
|
||||
LOG_INF("BLELC", "start deferred: activity busy heap=%u maxAlloc=%u", ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
}
|
||||
return;
|
||||
}
|
||||
static constexpr uint32_t BLE_RESTART_COOLDOWN_MS = 30 * 1000;
|
||||
const bool inCooldown = recoveryTeardownMs != 0 && millis() - recoveryTeardownMs < BLE_RESTART_COOLDOWN_MS;
|
||||
// Heap gate: NimBLE needs ~57 KB, and the section-build pre-flight needs 40 KB free
|
||||
// AFTER the stack is up -- so anything below the floor just re-enters build recovery
|
||||
// and tears BLE straight back down. (A first cut used 70 KB for restarts; 70-57=13 KB
|
||||
// left for builds, guaranteeing the oscillation above.) Defer and retry next loop;
|
||||
// the reader menu's toggle offers a defrag restart, and the build path's silent
|
||||
// restart also yields ~118 KB and passes this gate.
|
||||
static bool deferralAnnounced = false;
|
||||
// Heap gate: NimBLE needs ~57 KB. If the reader cannot spare that yet, retry
|
||||
// on the next loop without entering any separate BLE hold state.
|
||||
// The BT settings screen is explicit user intent to run BLE right now (scan/pair is
|
||||
// dead without the stack). Its floor only needs to cover NimBLE itself — the 100 KB
|
||||
// reader floor reserves build/render headroom that never gets used there — and the
|
||||
// cooldown protects the reader's build-recovery loop, which can't occur in settings.
|
||||
// reader floor reserves build/render headroom that never gets used there.
|
||||
const bool explicitBtContext = activityManager.currentKeepsBluetoothAlive();
|
||||
const size_t startFloor = explicitBtContext ? bleinput::kStartMinFreeHeapExplicit : bleinput::kStartMinFreeHeap;
|
||||
if (wanted && !BleHid.isRunning() && ((inCooldown && !explicitBtContext) || ESP.getFreeHeap() < startFloor)) {
|
||||
if (wanted && !BleHid.isRunning() && activityManager.isReaderActivity() && !renderer.hasFrameBuffer()) {
|
||||
static uint32_t lastFramebufferLoanDeferLogMs = 0;
|
||||
if (millis() - lastFramebufferLoanDeferLogMs > 10000) {
|
||||
lastFramebufferLoanDeferLogMs = millis();
|
||||
LOG_INF("BLELC", "start deferred: framebuffer lent heap=%u maxAlloc=%u", ESP.getFreeHeap(),
|
||||
ESP.getMaxAllocHeap());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (wanted && !BleHid.isRunning() && activityManager.isReaderActivity() && RenderLock::peek()) {
|
||||
static uint32_t lastReaderRenderDeferLogMs = 0;
|
||||
if (millis() - lastReaderRenderDeferLogMs > 10000) {
|
||||
lastReaderRenderDeferLogMs = millis();
|
||||
LOG_INF("BLELC", "start deferred: reader render in progress heap=%u maxAlloc=%u", ESP.getFreeHeap(),
|
||||
ESP.getMaxAllocHeap());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (wanted && !BleHid.isRunning() && ESP.getFreeHeap() < startFloor) {
|
||||
static uint32_t lastGateLogMs = 0;
|
||||
if (millis() - lastGateLogMs > 10000) {
|
||||
lastGateLogMs = millis();
|
||||
LOG_INF("BLELC", "start deferred: heap %u floor %u cooldown=%d", ESP.getFreeHeap(), (unsigned)startFloor,
|
||||
inCooldown ? 1 : 0);
|
||||
}
|
||||
// Tell the reader once per deferral episode that the remote is paused -- otherwise
|
||||
// the only symptom is a remote that silently stopped working. Draws into the
|
||||
// existing framebuffer (no heap); the next page render clears it via ghost cleanup.
|
||||
if (!deferralAnnounced && activityManager.isReaderActivity() && BleHid.pairedCount() > 0) {
|
||||
deferralAnnounced = true;
|
||||
{
|
||||
RenderLock renderLock;
|
||||
GUI.drawPopup(renderer, tr(STR_BT_PAUSED_LOW_MEM_POPUP));
|
||||
activityManager.requestGhostCleanup();
|
||||
}
|
||||
// Toast semantics: request a redraw so the popup clears after the (~2 s) page
|
||||
// re-render instead of lingering until the next page turn. E-ink has no free
|
||||
// timers -- clearing costs one refresh whenever it happens, so do it now.
|
||||
activityManager.requestUpdate();
|
||||
LOG_INF("BLELC", "start deferred: heap %u floor %u", ESP.getFreeHeap(), (unsigned)startFloor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -566,35 +547,15 @@ void updateBluetoothLifecycle() {
|
||||
LOG_INF("BLELC", "start requested enabled=%u reader=%d settings=%d wifi=%d paired=%u heap=%u maxAlloc=%u",
|
||||
SETTINGS.bluetoothEnabled, activityManager.isReaderActivity(), activityManager.currentKeepsBluetoothAlive(),
|
||||
WiFi.getMode(), BleHid.pairedCount(), ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
RenderLock renderLock;
|
||||
// Re-check the floor under the lock: acquiring the RenderLock waits out any
|
||||
// in-flight render, and a chapter open during that render consumes tens of KB —
|
||||
// observed in the field as the gate passing at 116 KB free and begin() then
|
||||
// landing on 44 KB, where the session ground down and aborted. Defer to the next
|
||||
// tick; the gate re-evaluates against the settled heap.
|
||||
if (ESP.getFreeHeap() < startFloor || activityManager.bluetoothStartDeferred() || bleinput::lifecyclePaused()) {
|
||||
LOG_INF("BLELC", "start aborted under render lock: heap %u floor %u", ESP.getFreeHeap(), (unsigned)startFloor);
|
||||
return;
|
||||
}
|
||||
// Start immediately once the lifecycle gates pass. Do not draw a reconnect
|
||||
// popup here: that schedules a reader redraw while BLE has just consumed ~53 KB,
|
||||
// which can immediately trip the render heap shed path and create a start/stop loop.
|
||||
if (!bleinput::ensureStarted()) {
|
||||
LOG_ERR("BLELC", "start failed heap=%u maxAlloc=%u", ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
return;
|
||||
}
|
||||
LOG_INF("BLELC", "started paired=%u heap=%u maxAlloc=%u", BleHid.pairedCount(), ESP.getFreeHeap(),
|
||||
ESP.getMaxAllocHeap());
|
||||
recoveryTeardownMs = 0; // stack is back; clear the cool-down
|
||||
deferralAnnounced = false; // re-announce if a later episode defers again
|
||||
HalPowerManager::Lock powerLock;
|
||||
const bool showReconnectPopup =
|
||||
activityManager.isReaderActivity() && !activityManager.currentKeepsBluetoothAlive() && BleHid.pairedCount() > 0;
|
||||
if (showReconnectPopup) {
|
||||
// Single place BLE starts for reader reconnect. Then clear it with a
|
||||
// ghost-cleanup (HALF) refresh so a grayscale page doesn't ghost over the
|
||||
// popup.
|
||||
bleinput::showConnectingUntilLinked(renderer, mappedInputManager);
|
||||
activityManager.requestGhostCleanup();
|
||||
activityManager.requestUpdate();
|
||||
}
|
||||
} else if (!wanted && BleHid.isRunning()) {
|
||||
LOG_INF("BLELC", "stop requested enabled=%u active=%d wifi=%d heap=%u maxAlloc=%u", SETTINGS.bluetoothEnabled,
|
||||
activityManager.bluetoothShouldBeActive(), WiFi.getMode(), ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||
|
||||
Reference in New Issue
Block a user