diff --git a/.gitignore b/.gitignore index 6bd12537..bd08bb37 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,9 @@ lib/EpdFont/scripts/output/ # (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out. .claude/* !.claude/skills/ +/managed_components +/.dummy +dependencies.lock +sdkconfig.default +sdkconfig.defaults +CMakeLists.txt diff --git a/platformio.ini b/platformio.ini index 8080b93a..a17bea75 100644 --- a/platformio.ini +++ b/platformio.ini @@ -60,6 +60,48 @@ board_build.flash_mode = dio board_build.flash_size = 16MB board_build.partitions = partitions.csv +; Shrink the NimBLE footprint for a 1-connection HID host moving 3-6 byte reports. +; Field-measured: begin() costs ~52 KB with these trims vs ~68 KB with the prebuilt +; framework defaults — and that 15 KB is the difference between the stack landing +; above the reader's render shed floor (stable coexistence) and below it (a +; guaranteed shed/restart flap). Rebuilds the Arduino core libs on first build +; (slower once, cached after; needs the CMake pin in platformio.local.ini on macOS). +custom_sdkconfig = + CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=n + CONFIG_BT_NIMBLE_ROLE_BROADCASTER=n + CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1 + CONFIG_BT_NIMBLE_MAX_CCCDS=2 + CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=23 + CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=6 + CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=6 ; was 24 x 320 B + CONFIG_BT_NIMBLE_ACL_BUF_COUNT=6 ; was 24 x 255 B + CONFIG_BT_NIMBLE_HCI_EVT_HI_BUF_COUNT=12 ; was 30 x 70 B; only scan bursts need many + ; IDF 5.5 sizes the HCI transport pools under TRANSPORT_* names; pin both spellings. + CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=6 + CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=12 + CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=4 ; was 64; a HID host never does prepared writes + CONFIG_BT_CTRL_BLE_MAX_ACT=3 ; was 6; need conn + scan + initiate only + CONFIG_BT_CTRL_BLE_ADV_REPORT_FLOW_CTRL_NUM=50 ; was 100; pairing-time scan only + CONFIG_BT_CTRL_ADV_DUP_FILT_MAX=10 ; was 30 + ; Keep the Arduino wrappers for the removed cloud components (below) out of + ; the core source list; all other bundled libraries default to enabled. + CONFIG_ARDUINO_SELECTIVE_COMPILATION=y + CONFIG_ARDUINO_SELECTIVE_RainMaker=n + CONFIG_ARDUINO_SELECTIVE_Insights=n + +; Drop unused cloud components from the core rebuild. esp_insights/rainmaker +; require embedded server certs the lib builder can't generate +; ("https_server.crt.S not found"); this firmware uses none of them. +custom_component_remove = + espressif/esp_insights + espressif/esp_rainmaker + espressif/esp_diagnostics + espressif/esp_diag_data_store + espressif/esp_schedule + espressif/esp_rcp_update + espressif/esp_secure_cert_mgr + espressif/cbor + extra_scripts = pre:scripts/build_html.py pre:scripts/gen_i18n.py diff --git a/src/activities/Activity.h b/src/activities/Activity.h index 42e3375b..3aa55e8c 100644 --- a/src/activities/Activity.h +++ b/src/activities/Activity.h @@ -48,6 +48,12 @@ 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). + 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 // (e.g. the "BT Connecting..." popup over a reader page) so it clears without ghosting. diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 6ded5478..4fb5c9c2 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -271,6 +271,10 @@ bool ActivityManager::bluetoothShouldBeActive() const { return std::any_of(stackActivities.begin(), stackActivities.end(), wants) || wants(currentActivity); } +bool ActivityManager::bluetoothStartDeferred() const { + return currentActivity && currentActivity->deferBluetoothStart(); +} + bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); } ScreenshotInfo ActivityManager::getScreenshotInfo() const { diff --git a/src/activities/ActivityManager.h b/src/activities/ActivityManager.h index 02855305..647d6d0d 100644 --- a/src/activities/ActivityManager.h +++ b/src/activities/ActivityManager.h @@ -106,6 +106,12 @@ class ActivityManager { // True if BLE should be resident for the current context: any reader (page-turner // input) or the Bluetooth settings screen (pairing) is on the stack. bool bluetoothShouldBeActive() const; + // True while the CURRENT activity is mid heap-heavy work that must complete before + // NimBLE may start (see Activity::deferBluetoothStart). Current only, not the + // stack: a reader stacked under a menu has its loop() paused, so its build never + // advances — a stack-wide check would hold BLE off for as long as the menu stays + // open. + bool bluetoothStartDeferred() const; bool skipLoopDelay() const; ScreenshotInfo getScreenshotInfo() const; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index ed4f9860..605b730a 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -292,8 +292,7 @@ void EpubReaderActivity::loop() { // Skip while the render mutex is busy so we never delay a pending render; re-check // isBuilding() under the lock since render() may have just finished it. if (section && section->isBuilding() && !RenderLock::peek() && - static_cast(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD && - buildTickHeapGate()) { + static_cast(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD && buildTickHeapGate()) { RenderLock lock; // Re-check under the lock: render() (which also holds the RenderLock) may have finalized the // build between the outer isBuilding() check and acquiring the lock here, in which case diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index f086fa7b..e40c9af1 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -89,13 +89,16 @@ class EpubReaderActivity final : public Activity { // background tick with the BLE stack resident). The tick is deferrable work: // page-turn transients free up between turns and the build resumes; the render // path still builds the page it actually needs regardless of this floor. - static constexpr size_t BACKGROUND_BUILD_MIN_FREE_HEAP = 32 * 1024; - // Fragmentation floor for the same gate: a tick passed the free-heap floor at - // 34.7 KB free but the largest block was ~11 KB, and a parse allocation inside the - // tick aborted anyway. Free heap says how much memory exists; maxAlloc says whether - // any single allocation can actually have it. 16 KB also keeps the advance-table - // batch path (16 KB scratch) viable during builds. - static constexpr size_t BACKGROUND_BUILD_MIN_MAX_ALLOC = 16 * 1024; + // Calibrated BETWEEN the measured states: steady reading with BLE resident runs at + // ~29.4 KB free / ~16.4 KB largest block (ticks are safe there — a 2-page parse + // transient is a few KB), while the field crash happened at 34.7 KB free with an + // ~11 KB largest block. A first cut at 32 KB/16 KB sat just ABOVE the healthy + // steady state, guaranteeing a pointless BLE shed the moment any build work was + // pending (the maxAlloc floor fired on a 12-byte shortfall). + static constexpr size_t BACKGROUND_BUILD_MIN_FREE_HEAP = 26 * 1024; + // Fragmentation floor for the same gate: free heap says how much memory exists; + // maxAlloc says whether any single allocation can actually have it. + static constexpr size_t BACKGROUND_BUILD_MIN_MAX_ALLOC = 13 * 1024; // Gate for a background build tick: true when the heap can take parse allocations. // When BLE is what's squeezing the heap, sheds it (build-pending deferral in the // lifecycle then holds restarts off until the window is caught up) instead of @@ -149,6 +152,15 @@ 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/main.cpp b/src/main.cpp index ce7ba38c..3bafc59c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -511,6 +511,18 @@ void updateBluetoothLifecycle() { 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()); + } + 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 @@ -560,7 +572,7 @@ void updateBluetoothLifecycle() { // 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 || bleinput::lifecyclePaused()) { + 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; }