Merge branch 'pr-2527' into feat-bluetooth

# Conflicts:
#	src/activities/reader/EpubReaderActivity.cpp
This commit is contained in:
Justin Mitchell
2026-07-06 02:10:55 -04:00
8 changed files with 136 additions and 18 deletions
+2
View File
@@ -302,6 +302,8 @@ STR_BT_NO_PAIRED: "No paired devices"
STR_BT_MAP_BUTTONS: "Map Remote Buttons"
STR_BT_PRESS_REMOTE: "Press a button on your remote"
STR_BT_CONNECTING_POPUP: "BT Connecting..."
STR_BT_PAUSED_LOW_MEM_POPUP: "BT paused (low memory)"
STR_STATE_PAUSED: "PAUSED"
STR_BT_PAGE_FORWARD: "Page Forward"
STR_BT_PAGE_BACK: "Page Back"
STR_BT_FORGET_PROMPT: "Hold Confirm to forget"
+1
View File
@@ -97,6 +97,7 @@ build_flags =
-DLOG_LEVEL=2 ; Set log level to debug for development builds
-DFREEINK_CAP_BLE_HID_HOST=1 ; BLE HID page-turner host (NimBLE)
-DFREEINK_BLE_HID_SCAN_DEBUG=1 ; verbose BLE scan lifecycle/advertisement logs for bring-up
-DFREEINK_BLE_HID_REPORT_DEBUG=1 ; raw HID report hex dumps + report-map hints (bring-up)
[env:gh_release]
+6
View File
@@ -25,6 +25,12 @@ namespace bleinput {
// Advertised central name shown to peripherals during pairing.
inline constexpr const char* kHostName = "CrossPoint";
// Heap floor for starting the NimBLE stack (~57 KB) while leaving the reader's
// section-build pre-flight (40 KB) satisfiable afterwards. Shared by the main-loop
// lifecycle gate and the reader menu's toggle (which offers a defrag restart when a
// user turns BT on below the floor).
inline constexpr size_t kStartMinFreeHeap = 100 * 1024;
// Start the BLE HID host (idempotent). Returns false if BLE is compiled out or
// NimBLE init failed. Safe to call repeatedly.
bool ensureStarted();
+4
View File
@@ -6,3 +6,7 @@
void silentRestart(); // home screen
void silentRestartToReader(); // currently-open EPUB (APP_STATE.openEpubPath)
// True when this boot itself came from a silent restart. Callers that restart
// as a last-resort defrag must check this so a failure that survives the
// restart degrades to an error instead of a reboot loop.
bool bootWasSilentRestart();
+48 -16
View File
@@ -33,6 +33,7 @@
#include "QrDisplayActivity.h"
#include "ReaderUtils.h"
#include "RecentBooksStore.h"
#include "SilentRestart.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookmarkUtil.h"
@@ -888,6 +889,15 @@ 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;
const auto showPendingSyncSaveError = [this]() {
if (!pendingSyncSaveError) return;
pendingSyncSaveError = false;
@@ -967,25 +977,45 @@ void EpubReaderActivity::render(RenderLock&& lock) {
LOG_DBG("ERS", "Cache not found, building...");
}
// The layout code (line-break DP arrays, CSS lookups, glyph buffers) allocates freely
// and abort()s on OOM under -fno-exceptions, so a starved heap must be handled BEFORE
// 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;
if (heapTooLow) {
LOG_ERR("ERS", "Pre-build heap %u below floor %u; entering build recovery", (unsigned)ESP.getFreeHeap(),
(unsigned)BUILD_MIN_FREE_HEAP);
}
// Building a section needs a large contiguous inflate (deflate) window that the
// resident NimBLE stack fragments out of existence (~16 KB max block with BT on).
// On build failure with BT enabled: free the BLE stack, retry the build, then
// restore it. The inflated HTML is cached after a successful build, so this
// recovery runs at most once per uncached chapter; BT reconnects in a few s.
// On build failure (or a pre-flight floor miss) with BT enabled: free the BLE stack
// 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.
const auto retryWithBleFreed = [&](auto&& buildFn) {
LOG_INF("ERS", "Section build failed with Bluetooth on; freeing BLE RAM and retrying");
LOG_INF("ERS", "Section build needs heap; freeing BLE RAM and retrying");
bleinput::setLifecyclePaused(true);
bleinput::stop();
const bool built = buildFn();
const bool bleOk = bleinput::ensureStarted();
bleinput::setLifecyclePaused(false);
LOG_INF("ERS", "BLE restart after build: begin=%d", bleOk);
// Hold the "BT Connecting..." popup until the remote re-links, then force the
// page render below onto the ghost-cleanup (HALF) path so the popup clears
// without ghosting the grayscale page.
bleinput::showConnectingUntilLinked(renderer, mappedInput);
requestGhostCleanup();
return built;
return buildFn();
};
// Even with BLE freed, the build can fail when this session's parse churn has
// fragmented the heap beyond in-place recovery. A silent restart is the only
// real defrag on this heap (no compaction); it resumes into this book and
// rebuilds the section on a fresh heap. Guarded by bootWasSilentRestart() so a
// build that fails again after the restart degrades to the error popup below
// instead of reboot-looping.
const auto silentRestartDefrag = [&]() {
if (bootWasSilentRestart()) return;
LOG_ERR("ERS", "Section build failed after BLE recovery; silent restart to defrag heap");
silentRestartToReader();
};
// Jumps that need the final pagination or the anchor map -- explicit page jumps,
@@ -1013,11 +1043,12 @@ void EpubReaderActivity::render(RenderLock&& lock) {
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn);
};
bool built = buildSection();
bool built = !heapTooLow && buildSection();
if (!built && SETTINGS.bluetoothEnabled) {
built = retryWithBleFreed(buildSection);
}
if (!built) {
silentRestartDefrag();
LOG_ERR("ERS", "Failed to persist page data to SD");
section.reset();
showPendingSyncSaveError();
@@ -1065,11 +1096,12 @@ void EpubReaderActivity::render(RenderLock&& lock) {
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled);
};
bool started = beginBuild();
bool started = !heapTooLow && beginBuild();
if (!started && SETTINGS.bluetoothEnabled) {
started = retryWithBleFreed(beginBuild);
}
if (!started) {
silentRestartDefrag();
LOG_ERR("ERS", "Failed to start section build");
section.reset();
showPendingSyncSaveError();
@@ -59,6 +59,14 @@ class EpubReaderActivity final : public Activity {
SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH] = {};
int footnoteDepth = 0;
// Heap floor for entering a section build. The layout code allocates freely (line-break DP
// arrays sized by word count, CSS rule lookups, glyph buffers) and under -fno-exceptions an
// OOM there abort()s the firmware instead of failing cleanly -- so a starved heap must be
// handled *before* the build, not after. Field data: builds succeed at ~46 KB free with BLE
// resident; abort() observed at ~11 KB free. CSS styling already degrades below 48 KB
// (MIN_FREE_HEAP_FOR_CSS), so 40 KB trades a few early BLE teardowns for not crashing.
static constexpr size_t BUILD_MIN_FREE_HEAP = 40 * 1024;
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar() const;
@@ -2,9 +2,12 @@
#include <GfxRenderer.h>
#include <I18n.h>
#include <Logging.h>
#include "BleInput.h"
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "SilentRestart.h"
#include "components/UITheme.h"
#include "fontIds.h"
@@ -93,6 +96,15 @@ void EpubReaderMenuActivity::loop() {
// 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
// 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
// auto-starts on the way back in.
if (SETTINGS.bluetoothEnabled && !BleHid.isRunning() && ESP.getFreeHeap() < bleinput::kStartMinFreeHeap) {
LOG_INF("ERM", "BT enabled below heap floor (%u); silent restart to defrag", ESP.getFreeHeap());
silentRestartToReader();
}
requestUpdate();
return;
}
@@ -149,8 +161,12 @@ 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 on/off state on the right edge.
return SETTINGS.bluetoothEnabled ? tr(STR_STATE_ON) : tr(STR_STATE_OFF);
// 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);
}
return tr(STR_STATE_OFF);
} else {
return "";
}
+49
View File
@@ -128,6 +128,12 @@ enum class BootResume : uint8_t {
QuickResume, // wake from a quick-resume deep sleep (SD flag; survives power loss)
};
// Latched in setup() from the read-and-clear of the RTC flag, so the reboot-loop
// guard in bootWasSilentRestart() has the answer for the whole session.
static bool bootWasSilentRestartFlag = false;
bool bootWasSilentRestart() { return bootWasSilentRestartFlag; }
// Latched true once enterDeepSleep() commits to sleeping, before it tears down
// the current activity. WiFi activities call silentRestart() in onExit() to
// clear heap fragmentation on the way out, but deep sleep is a full chip reset
@@ -330,6 +336,7 @@ void setup() {
(isSilentReboot && silentRebootTarget <= SILENT_REBOOT_TARGET_READER) ? silentRebootTarget : 0;
silentRebootMagic = 0;
silentRebootTarget = 0;
bootWasSilentRestartFlag = isSilentReboot;
gpio.begin();
powerManager.begin();
@@ -496,7 +503,47 @@ void setup() {
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;
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;
if (wanted && !BleHid.isRunning() && (inCooldown || ESP.getFreeHeap() < bleinput::kStartMinFreeHeap)) {
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)bleinput::kStartMinFreeHeap, 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();
}
return;
}
if (wanted && !BleHid.isRunning()) {
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(),
@@ -508,6 +555,8 @@ void updateBluetoothLifecycle() {
}
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;