diff --git a/freeink-sdk b/freeink-sdk index 12f9af14..2442e1d6 160000 --- a/freeink-sdk +++ b/freeink-sdk @@ -1 +1 @@ -Subproject commit 12f9af14bbd2e7fd245556111fbceb90cd38675f +Subproject commit 2442e1d67231cf1f1c84db2fa7143de776b175f3 diff --git a/lib/GfxRenderer/FontCacheManager.cpp b/lib/GfxRenderer/FontCacheManager.cpp index 19b1a5c1..578f9a56 100644 --- a/lib/GfxRenderer/FontCacheManager.cpp +++ b/lib/GfxRenderer/FontCacheManager.cpp @@ -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(style) & 0x03; const unsigned char* p = reinterpret_cast(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() { diff --git a/lib/GfxRenderer/FontCacheManager.h b/lib/GfxRenderer/FontCacheManager.h index 27dde4a0..067ae3d1 100644 --- a/lib/GfxRenderer/FontCacheManager.h +++ b/lib/GfxRenderer/FontCacheManager.h @@ -2,9 +2,9 @@ #include +#include #include #include -#include 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; }; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 1e9ad982..04436729 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -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) { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 5aa9ae72..fb673ca5 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -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; diff --git a/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp index f90e85c0..4c0a13bf 100644 --- a/lib/hal/HalDisplay.cpp +++ b/lib/hal/HalDisplay.cpp @@ -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); } diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h index 7b21a72f..504553d8 100644 --- a/lib/hal/HalDisplay.h +++ b/lib/hal/HalDisplay.h @@ -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 diff --git a/src/BleInput.cpp b/src/BleInput.cpp index 7e61bed3..9c8ac46b 100644 --- a/src/BleInput.cpp +++ b/src/BleInput.cpp @@ -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; diff --git a/src/BleInput.h b/src/BleInput.h index cadb6e7b..f83facb5 100644 --- a/src/BleInput.h +++ b/src/BleInput.h @@ -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). diff --git a/src/activities/Activity.h b/src/activities/Activity.h index 3aa55e8c..f94e3f86 100644 --- a/src/activities/Activity.h +++ b/src/activities/Activity.h @@ -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 diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index f1441491..2f26a95f 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -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(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(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()); } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index e9ee40e2..9253ad55 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -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(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD; - } void requestGhostCleanup() override { pagesUntilFullRefresh = 1; } ScreenshotInfo getScreenshotInfo() const override; CrossPointPosition getCurrentPosition() const; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 477c859c..4bc4485d 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -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 { diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index 251030f3..cabab3d8 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -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; } }; diff --git a/src/main.cpp b/src/main.cpp index 3bafc59c..ba056951 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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());