diff --git a/lib/hal/BluetoothHIDManager.cpp b/lib/hal/BluetoothHIDManager.cpp index 25382130..3f26a6de 100644 --- a/lib/hal/BluetoothHIDManager.cpp +++ b/lib/hal/BluetoothHIDManager.cpp @@ -1,4 +1,5 @@ #include "BluetoothHIDManager.h" +#include #include #include #include @@ -161,7 +162,10 @@ class ScanCallbacks : public NimBLEScanCallbacks { void onScanEnd(const NimBLEScanResults& results, int reason) override { (void)results; - (void)reason; + // Async scan finished: clear _scanning so the UI shows results. + if (g_instance) { + g_instance->onScanComplete(reason); + } } }; @@ -371,26 +375,18 @@ void BluetoothHIDManager::startScan(uint32_t durationMs) { pScan->setInterval(100); pScan->setWindow(99); - // In NimBLE 2.x, duration=0 means scan continuously until stop() is called - // Parameter 1: 0 = continuous scan - // Parameter 2: isContinue (false = clear old results) - bool started = pScan->start(0, false); - + // Async: NimBLE auto-stops after durationMs (ms) and fires onScanEnd. Was a + // blocking delay() that froze the UI for the whole scan. (false = clear results) + bool started = pScan->start(durationMs, false); + if (!started) { LOG_ERR("BT", "Failed to start scan!"); _scanning = false; lastError = "Scan failed"; return; } - - // Wait for the specified duration - delay(durationMs); - - // Stop the scan - pScan->stop(); - - _scanning = false; - LOG_INF("BT", "Scan complete, found %d devices", _discoveredDevices.size()); + + LOG_INF("BT", "Scan started (async, %lu ms)", durationMs); } void BluetoothHIDManager::stopScan() { @@ -466,10 +462,25 @@ void BluetoothHIDManager::onScanResult(NimBLEAdvertisedDevice* advertisedDevice) _discoveredDevices.push_back(device); + // Named devices first, then stronger RSSI; stable to avoid jitter as results stream in. + std::stable_sort(_discoveredDevices.begin(), _discoveredDevices.end(), + [](const BluetoothDevice& a, const BluetoothDevice& b) { + const bool aNamed = a.name != "Unknown"; + const bool bNamed = b.name != "Unknown"; + if (aNamed != bNamed) return aNamed; // named first + return a.rssi > b.rssi; // stronger signal first + }); + LOG_DBG("BT", "Found device: %s (%s) RSSI:%d HID:%d", device.name.c_str(), device.address.c_str(), rssi, isHID); } +void BluetoothHIDManager::onScanComplete(int reason) { + _scanning = false; + LOG_INF("BT", "Scan ended (reason=%d), found %d devices", reason, + static_cast(_discoveredDevices.size())); +} + bool BluetoothHIDManager::connectToDevice(const std::string& address) { if (!_enabled) { LOG_ERR("BT", "Cannot connect: Bluetooth not enabled"); diff --git a/lib/hal/BluetoothHIDManager.h b/lib/hal/BluetoothHIDManager.h index b5a7943b..b3c35c55 100644 --- a/lib/hal/BluetoothHIDManager.h +++ b/lib/hal/BluetoothHIDManager.h @@ -140,6 +140,8 @@ class BluetoothHIDManager { // BLE callbacks (public for NimBLE callbacks) void onScanResult(NimBLEAdvertisedDevice* advertisedDevice); + // Called from onScanEnd when an async scan finishes; clears _scanning. + void onScanComplete(int reason); static void onHIDNotify(NimBLERemoteCharacteristic* pChar, uint8_t* pData, size_t length, bool isNotify); // CrumBLE: called from the NimBLE disconnect callback with the HCI reason. diff --git a/lib/hal/DeviceProfiles.cpp b/lib/hal/DeviceProfiles.cpp index 2619e3c6..1214e702 100644 --- a/lib/hal/DeviceProfiles.cpp +++ b/lib/hal/DeviceProfiles.cpp @@ -161,18 +161,20 @@ const DeviceProfile* findDeviceProfile(const char* macAddress, const char* devic // Then try to find by device name (flexible matching) if (deviceName && strlen(deviceName) > 0) { + // Exact name match wins over the fuzzy patterns below. Otherwise a + // mode-specific name like "Free3-R" gets hijacked by the broad "Free3" + // pattern and mapped to the wrong (Free3-M) report layout. for (int i = 0; i < KNOWN_DEVICES_COUNT; i++) { - const char* profileName = KNOWN_DEVICES[i].name; - - // Try exact match first - if (strcmp(deviceName, profileName) == 0) { - LOG_INF("DEV", "Matched device profile by exact name: %s", profileName); + if (strcmp(deviceName, KNOWN_DEVICES[i].name) == 0) { + LOG_INF("DEV", "Matched device profile by exact name: %s", KNOWN_DEVICES[i].name); return &KNOWN_DEVICES[i]; } - + } + + for (int i = 0; i < KNOWN_DEVICES_COUNT; i++) { // Try case-insensitive substring match for common patterns // This allows "Game Brick", "GameBrick", "IINE Game Brick", etc. - if (strstr(deviceName, "Game") || strstr(deviceName, "game") || + if (strstr(deviceName, "Game") || strstr(deviceName, "game") || strstr(deviceName, "GAME")) { if (strstr(deviceName, "Brick") || strstr(deviceName, "brick") || strstr(deviceName, "BRICK")) { diff --git a/lib/hal/DeviceProfiles.h b/lib/hal/DeviceProfiles.h index d1565e7f..c201ca56 100644 --- a/lib/hal/DeviceProfiles.h +++ b/lib/hal/DeviceProfiles.h @@ -76,6 +76,10 @@ constexpr DeviceProfile KNOWN_DEVICES[] = { // Free3-M page turner (confirmed working keycodes from setup wizard) {"Free3-M", nullptr, 0x02, 0x01, false, 2, false}, + + // Free3-R (R + sound mode): code in byte[0], clean 0x00 releases. + // LEFT=0x01 (back), SELECT/RIGHT=0x02 (forward). + {"Free3-R", nullptr, 0x01, 0x02, false, 0, false}, }; constexpr int KNOWN_DEVICES_COUNT = sizeof(KNOWN_DEVICES) / sizeof(KNOWN_DEVICES[0]); diff --git a/platformio.ini b/platformio.ini index 519effb7..d996a1d8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -35,7 +35,7 @@ crossink_version = 1.3.0 ; the last book page in as a background unless you slept from a book. ; Also adds X4/X3 simulator envs + tooling (dev-only; X3 runtime support ; already shipped in 3.0.0). -crumble_version = 4.5.3 +crumble_version = 4.5.4 [base] platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip @@ -109,6 +109,8 @@ build_flags = ; deep into long call chains in our usage (single conn, no peripheral ; role). Reclaims 1 KB of task stack -> general heap. -DCONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=3072 +; Expose BT Debug Capture menu + raw HID hex dump (BTDBG) for remote bring-up. + -DENABLE_BT_DEBUG_MONITOR=1 # https://libexpat.github.io/doc/api/latest/#XML_GE -DXML_GE=0 -DXML_CONTEXT_BYTES=1024 diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index aa926395..16ec0ef3 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -551,6 +551,15 @@ class CrossPointSettings { // pass stays on, matching v3.7.3 behaviour). Only affects the cycle // path -- cover sleep, custom sleep, and end-of-book sleep keep the // grayscale pass either way. + // + // CrumBLE 4.5.4: kept default at 0. Considered flipping to 1 since X4 + // cycling can feel sluggish vs X3, but field testing showed that the + // sluggish-feeling X4 sessions had grayscale sleep images (where the + // 4-level rendering is the entire reason to use the image), and X3 + // sessions with snappy cycling typically used B/W sleep images + // already. Users with grayscale-heavy collections would regress on a + // default-on flip; they can opt in via the Settings toggle if they + // prefer the speed-over-fidelity trade. uint8_t sleepCycleSkipGrayscale = 0; // CrumBLE prebake — master switch for the off-device chapter-index optimizer. diff --git a/src/SilentRestart.h b/src/SilentRestart.h index 26427b7d..51ba5e18 100644 --- a/src/SilentRestart.h +++ b/src/SilentRestart.h @@ -16,6 +16,15 @@ void silentRestartToFileTransfer();// goes straight back to File Transfer activi void setSilentRebootFtModeHint(uint32_t mode); uint32_t consumeSilentRebootFtModeHint(); +// CrumBLE 4.5.4: panic-recovery flag for FT WS uploads. Called by the WS +// upload handler on START accept (true) and on DONE / abort / disconnect / +// FT exit (false). If a panic-reboot happens while true, setup() detects +// it on the next boot and silent-restart-to-FT so the browser's WS retry +// + RESUME protocol can naturally continue the interrupted upload. The +// false-call also resets the consecutive-fail counter, so a clean run +// restores full auto-resume budget for the next session. +void setFtUploadInProgress(bool active); + // CrumBLE 4.4 task #48: quick-restart on natural pauses. The pre-boot // action runs once the activity stack lands back on the reader, giving // the operation a fresh post-defrag heap to work with. @@ -137,6 +146,15 @@ void silentRestartToOtaUpdate(); void silentRestartToBluetoothSettings(); extern bool g_postBtSilentReboot; +// CrumBLE 4.5.4: same pattern as BT, for KOReader auth + OPDS browser. +// Both flip true on a post-recovery boot so the activity's pre-flight +// knows not to re-arm the silent-restart loop (one attempt then real +// error). False on any other entry path. +void silentRestartToKoreaderAuth(); +extern bool g_postKoreaderSilentReboot; +void silentRestartToOpdsBrowser(); +extern bool g_postOpdsSilentReboot; + // CrumBLE 4.6: silent restart that resumes mid-OTA -- after the user has // confirmed install on the "New update available" screen. Skips the check // phase; URL + size + version are persisted to RTC and pulled back into diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 8a9ef391..42c328a1 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -19,6 +19,7 @@ #include "network/CrossPointWebServerActivity.h" #include "reader/ReaderActivity.h" #include "settings/OpdsServerListActivity.h" +#include "settings/KOReaderAuthActivity.h" #include "settings/BluetoothSettingsActivity.h" #include "settings/OtaUpdateActivity.h" #include "settings/SettingsActivity.h" @@ -204,6 +205,12 @@ void ActivityManager::goToBluetoothSettings() { renderer, mappedInput, [this] { popActivity(); }, /*exitOnSuccessfulConnect=*/false)); } +void ActivityManager::goToKoreaderAuth() { + // CrumBLE 4.5.4: replaceActivity post-silent-restart so the auth + // wizard is the root activity, matching the BT pattern. + replaceActivity(std::make_unique(renderer, mappedInput)); +} + void ActivityManager::goToSettings() { replaceActivity(std::make_unique(renderer, mappedInput)); } void ActivityManager::goToFileBrowser(std::string path) { diff --git a/src/activities/ActivityManager.h b/src/activities/ActivityManager.h index e68df376..8c6efeb2 100644 --- a/src/activities/ActivityManager.h +++ b/src/activities/ActivityManager.h @@ -97,6 +97,10 @@ class ActivityManager { // by the silent-restart dispatch when the BT enable/scan pre-flight // tripped and we rebooted to recover heap. void goToBluetoothSettings(); + // CrumBLE 4.5.4: same shape as goToBluetoothSettings, but for the + // KOReader auth wizard. Used by the silent-restart-to-koreader-auth + // dispatch when the activity's WiFi+HTTPS pre-flight tripped. + void goToKoreaderAuth(); void goToSettings(); void goToFileBrowser(std::string path = {}); void goToRecentBooks(); diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 1a9f7bc2..5398cdd5 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -26,6 +26,25 @@ constexpr size_t OPDS_BROWSER_ENTRY_CAPACITY = MAX_OPDS_FEED_ENTRIES + 2; void OpdsBookBrowserActivity::onEnter() { Activity::onEnter(); + // CrumBLE 4.5.4: heap pre-flight before WiFi + OPDS-feed parse. WiFi + // begin = ~58 KB, HTTPS handshake = ~40-50 KB contiguous, plus the + // streaming OPDS XML parser eats ~10-15 KB through its chunk buffer + // on large catalogs. Mid-reading sessions sit ~50 KB free; without + // the silent-restart the user used to hit ERROR with no recovery + // path. Pattern mirrors KOReader auth + BT scan: bail to a clean + // ~150 KB heap, then continue. g_postOpdsSilentReboot guards loop. + constexpr uint32_t kOpdsMinFreeHeap = 66u * 1024u; + constexpr uint32_t kOpdsMinMaxAlloc = 48u * 1024u; + const uint32_t freeHeapPre = ESP.getFreeHeap(); + const uint32_t maxAllocPre = ESP.getMaxAllocHeap(); + if ((freeHeapPre < kOpdsMinFreeHeap || maxAllocPre < kOpdsMinMaxAlloc) && !g_postOpdsSilentReboot) { + LOG_INF("OPDS", + "OPDS browser pre-flight low (free=%u maxAlloc=%u, need %u/%u) -- silent-restart to recover heap", + freeHeapPre, maxAllocPre, kOpdsMinFreeHeap, kOpdsMinMaxAlloc); + silentRestartToOpdsBrowser(); + return; // never returns; appease the linter + } + state = BrowserState::CHECK_WIFI; entryCount = 0; navigationHistory.clear(); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 927d1c05..a4b298ca 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -967,6 +967,18 @@ void HomeActivity::loadRecentCovers(int coverHeight) { } } } + // CrumBLE 4.5.4: same fix as loadShelfCovers -- if we drew the Loading + // popup over the framebuffer during this pass, flag it so the end-of- + // render handler invalidates caches + schedules a clean repaint that + // erases the popup. Without this, the CAROUSEL cover-load path would + // leave 'Loading' stuck on screen until the next user input forced a + // re-render (the 4.5.3 fix only covered the loadShelfCovers path). + // Reproduces reliably on a fresh-flash + first-boot Home where every + // carousel slot needs cover gen. + if (showingLoading) { + homeRenderPopupShown = true; + requestUpdate(); + } } void HomeActivity::enrichActiveCollectionForSeries() { diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index c2ca2aec..52f29241 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -260,6 +260,13 @@ void CrossPointWebServerActivity::onExit() { LOG_DBG("WEBACT", "Free heap at onExit start: %d bytes", ESP.getFreeHeap()); + // CrumBLE 4.5.4: user explicitly exited FT -- clear the panic-recovery + // flag so a later unrelated panic doesn't auto-restart back into FT + // they no longer want to be in. Reset is idempotent if no upload was + // active. Counter resets too, restoring full auto-resume budget for + // the next FT session. + setFtUploadInProgress(false); + // CrumBLE: books may have just been uploaded over the file-transfer // web UI (USB or hotspot). Mark the LibraryIndex stale so the next // visit to Recently Added / All Books re-walks SD and discovers them. diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 11941795..5d599059 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -884,10 +884,71 @@ bool EpubReaderActivity::checkAndFirePrebakePromptIfNeeded() { // Option 0 (default) keeps the user's current settings; Back/Cancel // maps to that same outcome because "do nothing destructive" is the // less surprising fallback when someone backs out of the prompt. - const std::string promptBody = - "This book was prepared with different reader settings. Keep your current " - "settings (chapters will rebuild against the live cache), or restore the " - "prepared layout for instant chapter loads?"; + // + // CrumBLE 4.5.4: also list WHICH fields differ in the prompt body. Users + // hit this prompt without knowing what they did wrong; the generic + // "different reader settings" text made it impossible to align the + // device's settings to the prebake without trial-and-error toggling. + // Now the prompt names each drifted field (Font Size: 14pt -> 18pt, + // Hyphenation: off -> on, etc.) so the user can decide informed. + auto onoff = [](bool b) -> const char* { return b ? "on" : "off"; }; + std::string diffLines; + auto append = [&diffLines](const std::string& line) { + if (!diffLines.empty()) diffLines += "\n"; + diffLines += " - "; + diffLines += line; + }; + if (pm.fontId != curFontId) { + // CrumBLE 4.5.4 follow-up: show the human-readable font name (e.g. + // "Bitter 14pt" / "LXGWWenKai 18pt") instead of the raw uint32 hash + // -- the prebake manifest already carries fontFamily / fontSize / + // sdFontFamilyName for exactly this display path, and the BT-path + // prompt has used fontLabel() since 4.5.4 for the same reason. + append("Font: " + + fontLabel(readerSettingsCache_, SETTINGS.fontFamily, SETTINGS.fontSize, SETTINGS.sdFontSizeRange, + SETTINGS.sdFontFamilyName) + + " -> " + + fontLabel(readerSettingsCache_, pm.fontFamily, pm.fontSize, pm.sdFontSizeRange, + std::string(pm.sdFontFamilyName))); + } + if (pm.viewportWidth != curViewportW || pm.viewportHeight != curViewportH) { + append("Viewport: " + std::to_string(curViewportW) + "x" + std::to_string(curViewportH) + + " (device) vs " + std::to_string(pm.viewportWidth) + "x" + std::to_string(pm.viewportHeight) + " (prebake)"); + } + if (pm.lineCompression != curLineComp) { + char buf[80]; + snprintf(buf, sizeof(buf), "Line spacing: %.2f -> %.2f", static_cast(curLineComp), + static_cast(pm.lineCompression)); + append(buf); + } + if (pm.extraParagraphSpacing != SETTINGS.extraParagraphSpacing) { + append(std::string("Paragraph spacing: ") + onoff(SETTINGS.extraParagraphSpacing) + " -> " + onoff(pm.extraParagraphSpacing)); + } + if (pm.forceParagraphIndents != SETTINGS.forceParagraphIndents) { + append(std::string("Force indents: ") + onoff(SETTINGS.forceParagraphIndents) + " -> " + onoff(pm.forceParagraphIndents)); + } + if (pm.paragraphAlignment != SETTINGS.paragraphAlignment) { + append("Alignment: " + std::to_string(SETTINGS.paragraphAlignment) + " -> " + std::to_string(pm.paragraphAlignment)); + } + if (pm.hyphenationEnabled != SETTINGS.hyphenationEnabled) { + append(std::string("Hyphenation: ") + onoff(SETTINGS.hyphenationEnabled) + " -> " + onoff(pm.hyphenationEnabled)); + } + if (pm.embeddedStyle != SETTINGS.embeddedStyle) { + append(std::string("Embedded CSS: ") + onoff(SETTINGS.embeddedStyle) + " -> " + onoff(pm.embeddedStyle)); + } + if (pm.imageRendering != SETTINGS.imageRendering) { + append("Images: " + std::to_string(SETTINGS.imageRendering) + " -> " + std::to_string(pm.imageRendering)); + } + if (pm.bionicReadingEnabled != SETTINGS.bionicReadingEnabled) { + append(std::string("Bionic reading: ") + onoff(SETTINGS.bionicReadingEnabled) + " -> " + onoff(pm.bionicReadingEnabled)); + } + if (pm.guideReadingEnabled != SETTINGS.guideReadingEnabled) { + append(std::string("Guide reading: ") + onoff(SETTINGS.guideReadingEnabled) + " -> " + onoff(pm.guideReadingEnabled)); + } + std::string promptBody = + "This book's chapter cache was prepared with different reader settings:\n\n" + + diffLines + + "\n\nKeep your current settings (rebuild chapters on demand), or restore the prepared layout (apply the book's prepared settings to your device)?"; prebakePromptShowing_ = true; startActivityForResult( std::make_unique( @@ -919,6 +980,14 @@ bool EpubReaderActivity::checkAndFirePrebakePromptIfNeeded() { finish(); return; } + // CrumBLE 4.5.4 follow-up: suppress the BT-path PxcManifest prompt + // for this session -- the user just answered the open-book + // PrebakeManifest prompt, and the BT prompt would show nearly the + // same mismatch in slightly different wording. The 'Restore' + // branch below applies fontFamily/fontSize/sdFontFamilyName, so + // any residual fontId difference the BT prompt would still surface + // comes from a missing SD font (BT prompt can't fix that either). + btManifestPromptAnsweredThisSession_ = true; const bool keepCurrent = chosen != 1; if (keepCurrent) { // User declined -- keep their current settings. Don't delete the diff --git a/src/activities/settings/BluetoothSettingsActivity.cpp b/src/activities/settings/BluetoothSettingsActivity.cpp index 6cf4899c..51381eec 100644 --- a/src/activities/settings/BluetoothSettingsActivity.cpp +++ b/src/activities/settings/BluetoothSettingsActivity.cpp @@ -165,6 +165,16 @@ void BluetoothSettingsActivity::loop() { } } + // Re-render on a throttle to animate the "Searching..." dots during a scan. + // ~700 ms: faster would strobe the e-ink panel. + if (btMgr && viewMode == ViewMode::DEVICE_LIST && btMgr->isScanning()) { + constexpr unsigned long kScanAnimIntervalMs = 700; + if (millis() - lastScanAnimMs > kScanAnimIntervalMs) { + lastScanAnimMs = millis(); + requestUpdate(); + } + } + // Check if scan completed if (btMgr && viewMode == ViewMode::DEVICE_LIST && !btMgr->isScanning() && lastScanTime > 0) { if (millis() - lastScanTime > 500) { // Small delay to see final results @@ -326,17 +336,37 @@ void BluetoothSettingsActivity::handleMainMenuInput() { } requestUpdate(); } else if (selectedIndex == kScanForDevicesIndex) { - // Start scan and switch to device list - if (btMgr->isEnabled()) { - if (checkScanHeapOrError(lastError)) { - btMgr->startScan(10000); - lastScanTime = millis(); - viewMode = ViewMode::DEVICE_LIST; - selectedIndex = 0; - lastError = ""; + // CrumBLE 4.5.4 follow-up: auto-enable BT if it's off when user hits + // Scan. Previously the activity bounced with 'Enable BT first', + // forcing the user to back up, toggle Enable, then re-pick Scan -- + // and many users perceived this as 'scan turned BT off' since the + // BT label flipped back to Enable. One-tap scan keeps the affordance + // obvious. Persist SETTINGS so the post-NimBLE-init silent-restart + // path also lands with BT auto-restored. + if (!btMgr->isEnabled()) { + LOG_INF("BT", "Auto-enabling Bluetooth before scan (user hit Scan from BT-off state)"); + EpubReaderActivity::prewarmReaderTextBuffer(renderer); + if (btMgr->enable()) { + SETTINGS.bluetoothEnabled = 1; + SETTINGS.saveToFile(); + } else if (!g_postBtSilentReboot && ESP.getFreeHeap() < 70u * 1024u) { + LOG_INF("BT", "Auto-enable for scan failed under low heap -- silent-restart to recover"); + SETTINGS.bluetoothEnabled = 1; + SETTINGS.saveToFile(); + silentRestartToBluetoothSettings(); + // never returns + } else { + lastError = btMgr->lastError.empty() ? "Failed to enable BT" : btMgr->lastError; + requestUpdate(); + return; } - } else { - lastError = "Enable BT first"; + } + if (btMgr->isEnabled() && checkScanHeapOrError(lastError)) { + btMgr->startScan(10000); + lastScanTime = millis(); + viewMode = ViewMode::DEVICE_LIST; + selectedIndex = 0; + lastError = ""; } requestUpdate(); } else if (selectedIndex == kRemoteSetupWizardIndex) { @@ -538,6 +568,17 @@ void BluetoothSettingsActivity::handleLearnInput() { void BluetoothSettingsActivity::handleDeviceListInput() { if (!btMgr) return; + // Don't index the device list mid-scan (mutated on the BLE task); only cancel. + if (btMgr->isScanning()) { + if (mappedInput.wasPressed(MappedInputManager::Button::Left)) { + btMgr->stopScan(); + viewMode = ViewMode::MAIN_MENU; + selectedIndex = 0; + requestUpdate(); + } + return; + } + const auto& devices = btMgr->getDiscoveredDevices(); const auto& connectedDevices = btMgr->getConnectedDevices(); @@ -631,7 +672,14 @@ void BluetoothSettingsActivity::handleDeviceListInput() { SETTINGS.saveToFile(); btMgr->setBondedDevice(device.address, device.name); - lastError = "Bluetooth enabled"; + // CrumBLE 4.5.4 follow-up: explicit 'connected and saved' message + // instead of the misleading 'Bluetooth enabled' (BT was already + // enabled to scan -- the meaningful new state is that THIS remote + // is now bonded). Truncate device name so the bottom status line + // doesn't overflow on long remote names. + std::string shortName = device.name.empty() ? std::string("remote") : device.name; + if (shortName.size() > 24) shortName = shortName.substr(0, 21) + "..."; + lastError = "Connected: " + shortName + " (saved)"; LOG_INF("BT", "Successfully connected to %s", device.name.c_str()); if (exitOnSuccessfulConnect) { MenuResult result; @@ -795,7 +843,10 @@ void BluetoothSettingsActivity::renderDeviceList() { // Subheader with scan status std::string subheaderText; if (btMgr->isScanning()) { - subheaderText = "Searching for devices..."; + // Animated trailing dots; trailing spaces keep the width fixed so it doesn't reflow. + const int dotCount = static_cast((millis() / 700) % 4); + subheaderText = "Searching for devices" + std::string(dotCount, '.') + + std::string(3 - dotCount, ' '); } else { if (devices.empty()) { subheaderText = "No devices found"; @@ -809,54 +860,60 @@ void BluetoothSettingsActivity::renderDeviceList() { GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, subheaderText.c_str()); - // Build device list labels. `GUI.drawList()` already paginates based on - // `selectedIndex`, so keep the full device list here and let the user scroll - // through every discovered device instead of truncating after the first page. - std::vector deviceLabels; - std::vector deviceValues; - char buf[128]; - - if (!devices.empty()) { - for (const auto& device : devices) { - const bool connected = btMgr->isConnected(device.address.c_str()); - - // Device name with indicators - const char* connSymbol = connected ? "[*] " : ""; - const char* hidSymbol = device.isHID ? "[HID] " : ""; - snprintf(buf, sizeof(buf), "%s%s%s", connSymbol, hidSymbol, device.name.c_str()); - deviceLabels.push_back(buf); - - // RSSI/signal strength - const std::string signalBars = getSignalStrengthIndicator(device.rssi); - snprintf(buf, sizeof(buf), "%s (%d dBm)", signalBars.c_str(), device.rssi); - deviceValues.push_back(buf); + // During a scan the list is mutated on the BLE task; don't iterate it here + // (race). Show a hint; build the interactive list once the scan finishes. + if (btMgr->isScanning()) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Looking for nearby remotes..."); + } else { + // Build device list labels. `GUI.drawList()` already paginates based on + // `selectedIndex`, so keep the full device list here and let the user scroll + // through every discovered device instead of truncating after the first page. + std::vector deviceLabels; + std::vector deviceValues; + char buf[128]; + + if (!devices.empty()) { + for (const auto& device : devices) { + const bool connected = btMgr->isConnected(device.address.c_str()); + + // Device name with indicators + const char* connSymbol = connected ? "[*] " : ""; + const char* hidSymbol = device.isHID ? "[HID] " : ""; + snprintf(buf, sizeof(buf), "%s%s%s", connSymbol, hidSymbol, device.name.c_str()); + deviceLabels.push_back(buf); + + // RSSI/signal strength + const std::string signalBars = getSignalStrengthIndicator(device.rssi); + snprintf(buf, sizeof(buf), "%s (%d dBm)", signalBars.c_str(), device.rssi); + deviceValues.push_back(buf); + } } - } - // Add action buttons after the full device list. - deviceLabels.push_back("< Rescan >"); - deviceValues.push_back(""); - - if (!connectedDevices.empty()) { - deviceLabels.push_back("< Disconnect All >"); + // Add action buttons after the full device list. + deviceLabels.push_back("< Rescan >"); deviceValues.push_back(""); + + if (!connectedDevices.empty()) { + deviceLabels.push_back("< Disconnect All >"); + deviceValues.push_back(""); + } + + // Render the list using GUI.drawList for consistency + GUI.drawList( + renderer, + Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth, + pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight + + metrics.verticalSpacing * 2)}, + deviceLabels.size(), selectedIndex, + [&deviceLabels](int index) { return deviceLabels[index]; }, nullptr, nullptr, + [&deviceValues](int i) { return i < (int)deviceValues.size() ? deviceValues[i] : std::string(""); }, + true); } - - // Render the list using GUI.drawList for consistency - GUI.drawList( - renderer, - Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth, - pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight + - metrics.verticalSpacing * 2)}, - deviceLabels.size(), selectedIndex, - [&deviceLabels](int index) { return deviceLabels[index]; }, nullptr, nullptr, - [&deviceValues](int i) { return i < (int)deviceValues.size() ? deviceValues[i] : std::string(""); }, - true); // Help text GUI.drawHelpText(renderer, Rect{0, pageHeight - metrics.buttonHintsHeight - metrics.contentSidePadding - 15, pageWidth, 20}, - "Up/Down: Scroll | Right: Rescan"); + btMgr->isScanning() ? "Left/Back: Cancel scan" : "Up/Down: Scroll | Right: Rescan"); const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_CONNECT), tr(STR_DIR_LEFT), tr(STR_RETRY)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); diff --git a/src/activities/settings/BluetoothSettingsActivity.h b/src/activities/settings/BluetoothSettingsActivity.h index c48deeec..c65f304e 100644 --- a/src/activities/settings/BluetoothSettingsActivity.h +++ b/src/activities/settings/BluetoothSettingsActivity.h @@ -28,6 +28,8 @@ class BluetoothSettingsActivity : public Activity { BluetoothHIDManager* btMgr = nullptr; std::string lastError = ""; unsigned long lastScanTime = 0; + // Throttles the e-ink re-render for the animated "Searching..." dots. + unsigned long lastScanAnimMs = 0; LearnStep learnStep = LearnStep::WAIT_PREV; uint8_t pendingLearnKey = 0; uint8_t pendingLearnIndex = 0xFF; diff --git a/src/activities/settings/KOReaderAuthActivity.cpp b/src/activities/settings/KOReaderAuthActivity.cpp index 4c8571dc..2cee4504 100644 --- a/src/activities/settings/KOReaderAuthActivity.cpp +++ b/src/activities/settings/KOReaderAuthActivity.cpp @@ -52,6 +52,26 @@ void KOReaderAuthActivity::performAuthentication() { void KOReaderAuthActivity::onEnter() { Activity::onEnter(); + // CrumBLE 4.5.4: heap pre-flight. WiFi.begin alone needs ~58 KB free + + // ~30 KB MaxAlloc, and the mbedtls HTTPS handshake for the KOReader + // sync server's auth POST needs another ~40-50 KB contiguous on top. + // Mid-reading session, free heap is often ~50 KB / MaxAlloc ~25 KB -- + // the user used to see "Memory low. Restart device." and have to + // power-cycle. Now: silent-restart to come back on a clean ~150 KB + // free heap, then the auth completes. g_postKoreaderSilentReboot + // guards against an infinite loop if even a fresh boot is somehow + // under the floor. + constexpr uint32_t kAuthMinFreeHeap = 66u * 1024u; + constexpr uint32_t kAuthMinMaxAlloc = 48u * 1024u; + const uint32_t freeHeap = ESP.getFreeHeap(); + const uint32_t maxAlloc = ESP.getMaxAllocHeap(); + if ((freeHeap < kAuthMinFreeHeap || maxAlloc < kAuthMinMaxAlloc) && !g_postKoreaderSilentReboot) { + LOG_INF("KOR", "KOReader auth pre-flight low (free=%u maxAlloc=%u, need %u/%u) -- silent-restart to recover heap", + freeHeap, maxAlloc, kAuthMinFreeHeap, kAuthMinMaxAlloc); + silentRestartToKoreaderAuth(); + return; // never returns, but appease the linter + } + // Check if already connected if (WiFi.status() == WL_CONNECTED) { onWifiSelectionComplete(true); diff --git a/src/main.cpp b/src/main.cpp index be0b946f..9247ed76 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -468,6 +468,56 @@ constexpr uint32_t SILENT_REBOOT_TARGET_OTA_INSTALL = 4; // proceeds. g_postBtSilentReboot below guards against an infinite loop // if even a fresh boot is somehow under the floor. constexpr uint32_t SILENT_REBOOT_TARGET_BT_SETTINGS = 5; +// CrumBLE 4.5.4: KOReader auth needs WiFi (+58 KB) + HTTPS handshake +// (~40-50 KB contiguous for mbedtls cert chain) -- same heap profile as +// BT enable. Field reports of "Memory low. Restart device." in mid- +// reading sessions traced to ~30-50 KB MaxAlloc after a book had been +// open. This target self-restarts the activity onto a ~150 KB free heap +// so the auth POST completes. g_postKoreaderSilentReboot guards against +// looping if a fresh boot is also under floor (e.g. wifi-enabled-at- +// boot eats too much). +constexpr uint32_t SILENT_REBOOT_TARGET_KOREADER_AUTH = 6; +// CrumBLE 4.5.4: OPDS browser hits the same wifi+https heap profile, +// plus the OPDS feed parse which can be 100+ KB of XML through the +// parser's chunk buffer. Pre-flight + silent-restart-to-self with the +// same shape as KOReader. Post-restart dispatch goes back through +// goToBrowser() which picks single-server-direct OR server-picker as +// appropriate, so we don't have to persist server selection across the +// restart. +constexpr uint32_t SILENT_REBOOT_TARGET_OPDS_BROWSER = 7; + +// CrumBLE 4.5.4: auto-recover from a panic mid-WS-upload by silent-restarting +// straight back to FT instead of cold-booting into Home. Without this, a +// device that crashes while serving a long upload (heap pressure, panic, etc.) +// comes back into Home with no web server -- the user has no way to know +// they need to manually re-enter FT to continue, and the browser's auto- +// retry loop just spins on a closed port. With this: +// +// 1. WS upload START accept sets ftUploadInProgressFlag = MAGIC. +// 2. WS upload DONE / abort / FT exit clears it back to 0. +// 3. On boot, if flag == MAGIC and we're NOT in any other silent-restart +// path, increment ftUploadResumeFailCount + silentRestartToFileTransfer. +// Browser's WS retry naturally reconnects + the server's RESUME protocol +// picks up at the saved byte offset, so no progress is lost. +// 4. Counter guards against infinite-panic loops: after MAX consecutive +// auto-resume attempts (FT mode itself crashes), we clear the flag and +// fall through to normal Home boot so user can at least navigate. +// +// Counter resets to 0 on every clean upload completion / FT exit, so a one- +// off panic doesn't permanently burn the retry budget. +RTC_NOINIT_ATTR uint32_t ftUploadInProgressFlag; +RTC_NOINIT_ATTR uint32_t ftUploadResumeFailCount; +constexpr uint32_t FT_UPLOAD_FLAG_MAGIC = 0xF7AB1234; +constexpr uint32_t FT_UPLOAD_MAX_RESUME_TRIES = 2; + +void setFtUploadInProgress(bool active) { + if (active) { + ftUploadInProgressFlag = FT_UPLOAD_FLAG_MAGIC; + } else { + ftUploadInProgressFlag = 0; + ftUploadResumeFailCount = 0; // clean exit -- restore full retry budget + } +} // How the device is coming back to life, resolved once at boot. Both resume // flows suppress the splash and leave the panel holding its pre-boot frame; a @@ -537,6 +587,31 @@ void silentRestartToBluetoothSettings() { ESP.restart(); } +// CrumBLE 4.5.4: mirror the BT pre-flight pattern for the two other auth- +// heavy entry points users hit mid-session. +bool g_postKoreaderSilentReboot = false; +bool g_postOpdsSilentReboot = false; + +void silentRestartToKoreaderAuth() { + if (deepSleepInProgress) return; + silentRebootTarget = SILENT_REBOOT_TARGET_KOREADER_AUTH; + silentRebootMagic = SILENT_REBOOT_MAGIC; + LOG_INF("MAIN", "Silent restart (target=koreader-auth) — heap pre-flight tripped"); + GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + delay(50); + ESP.restart(); +} + +void silentRestartToOpdsBrowser() { + if (deepSleepInProgress) return; + silentRebootTarget = SILENT_REBOOT_TARGET_OPDS_BROWSER; + silentRebootMagic = SILENT_REBOOT_MAGIC; + LOG_INF("MAIN", "Silent restart (target=opds-browser) — heap pre-flight tripped"); + GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + delay(50); + ESP.restart(); +} + void silentRestartToOtaInstall(const char* url, uint32_t size, const char* version) { if (deepSleepInProgress) return; silentRebootTarget = SILENT_REBOOT_TARGET_OTA_INSTALL; @@ -1271,8 +1346,10 @@ void setup() { const bool isSilentReboot = (silentRebootMagic == SILENT_REBOOT_MAGIC); // CrumBLE 4.5.4 fix: bound was OTA_INSTALL (4) which snapped BT_SETTINGS (5) // to 0 -- silent-restart-from-BT landed on home instead of BT Settings. + // Bumped again to OPDS_BROWSER (7) for the same reason: any new target we + // add below the bound silently routes to home if we forget to widen it. const uint32_t snapshotTarget = - (isSilentReboot && silentRebootTarget <= SILENT_REBOOT_TARGET_BT_SETTINGS) ? silentRebootTarget : 0; + (isSilentReboot && silentRebootTarget <= SILENT_REBOOT_TARGET_OPDS_BROWSER) ? silentRebootTarget : 0; // Snapshot the FT mode hint into a normal variable before clearing // RTC state, so the FT activity's onEnter can read it via // consumeSilentRebootFtModeHint(). Only honour it on a confirmed @@ -1331,6 +1408,34 @@ void setup() { silentRebootOtaVersion[0] = '\0'; silentRebootOtaSize = 0; + // CrumBLE 4.5.4: auto-resume an interrupted FT WS upload. If the prior + // boot's WS upload set the flag and we just panic-rebooted (cold/non- + // silent boot), silent-restart back into FT so the browser's WS retry + // naturally reconnects + the server's RESUME protocol picks up at the + // saved byte offset. Counter caps consecutive auto-resumes so FT-mode- + // itself crashes can't loop forever -- after MAX tries, fall through + // to normal Home boot. Skip when this IS already a silent reboot to a + // different target (OTA install / BT settings etc.) -- those take + // precedence so the user isn't yanked away from their explicit choice. + if (!isSilentReboot && ftUploadInProgressFlag == FT_UPLOAD_FLAG_MAGIC) { + if (ftUploadResumeFailCount < FT_UPLOAD_MAX_RESUME_TRIES) { + ftUploadResumeFailCount++; + LOG_INF("BOOT", "FT upload was in-progress before reboot -- silent-restarting to FT (try %u/%u)", + static_cast(ftUploadResumeFailCount), + static_cast(FT_UPLOAD_MAX_RESUME_TRIES)); + // Don't clear the flag here -- if THIS resume attempt also panics, + // the next boot increments the counter again and eventually gives + // up. Successful upload completion clears the flag via + // setFtUploadInProgress(false) which also resets the counter. + silentRestartToFileTransfer(); // never returns + } else { + LOG_ERR("BOOT", "FT upload flag set for %u consecutive boots -- giving up, booting Home normally", + static_cast(ftUploadResumeFailCount)); + ftUploadInProgressFlag = 0; + ftUploadResumeFailCount = 0; + } + } + // CrumBLE 4.5: lean-boot path for silent-restart-to-OTA. The mbedtls SSL // handshake to api.github.com needs ~40-50 KB contiguous on top of WiFi's // ~58 KB share, and a normal boot's cover/library/recent/koreader/opds @@ -1685,6 +1790,20 @@ void setup() { LOG_INF("BOOT", "Lean-boot BT dispatch: heap=%u maxAlloc=%u", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); g_postBtSilentReboot = true; activityManager.goToBluetoothSettings(); + } else if (resume == BootResume::Silent && snapshotTarget == SILENT_REBOOT_TARGET_KOREADER_AUTH) { + // CrumBLE 4.5.4: same pattern as BT, scoped to the KOReader auth flow. + LOG_INF("BOOT", "Lean-boot KOReader auth dispatch: heap=%u maxAlloc=%u", + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + g_postKoreaderSilentReboot = true; + activityManager.goToKoreaderAuth(); + } else if (resume == BootResume::Silent && snapshotTarget == SILENT_REBOOT_TARGET_OPDS_BROWSER) { + // CrumBLE 4.5.4: same pattern as BT, scoped to OPDS feed-fetch entry. + // goToBrowser() handles the single-server-direct vs picker fork so + // we don't have to persist which server was being accessed. + LOG_INF("BOOT", "Lean-boot OPDS dispatch: heap=%u maxAlloc=%u", + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + g_postOpdsSilentReboot = true; + activityManager.goToBrowser(); } else if (resume == BootResume::Silent && snapshotTarget == SILENT_REBOOT_TARGET_READER && !APP_STATE.openEpubPath.empty()) { activityManager.goToReader(APP_STATE.openEpubPath); diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index b0ba361f..41665482 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -1,4 +1,5 @@ #include "CrossPointWebServer.h" +#include "SilentRestart.h" #include #ifdef SIMULATOR @@ -410,6 +411,9 @@ void CrossPointWebServer::abortWsUpload(const char* tag) { wsUploadInProgress = false; wsUploadClientNum = 255; wsLastProgressSent = 0; + // CrumBLE 4.5.4: explicit abort -- clear the panic-recovery flag so we + // don't auto-restart-to-FT on a subsequent unrelated panic. + setFtUploadInProgress(false); } void CrossPointWebServer::stop() { @@ -2371,6 +2375,11 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* wsUploadClientNum = num; wsUploadInProgress = true; + // CrumBLE 4.5.4: arm panic-recovery flag. If the device hard- + // crashes during the rest of this upload, setup() detects this + // on the next boot and silent-restart-to-FT so the browser's + // WS retry + RESUME picks up at the saved byte offset. + setFtUploadInProgress(true); if (resumeFrom > 0) { char resumeMsg[48]; snprintf(resumeMsg, sizeof(resumeMsg), "RESUME:%lu", static_cast(resumeFrom)); @@ -2486,6 +2495,10 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* wsUploadFile.close(); wsUploadInProgress = false; wsUploadClientNum = 255; + // CrumBLE 4.5.4: clean DONE -- clear the panic-recovery flag and + // reset its consecutive-fail counter so the next upload starts + // with full auto-resume budget. + setFtUploadInProgress(false); wsLastCompleteName = wsUploadFileName; wsLastCompleteSize = wsUploadSize; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 1ebcf0dd..294a7273 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -1076,22 +1076,69 @@ background-color: #e0e0e0; border-radius: 10px; overflow: hidden; + position: relative; } #progress-fill { height: 100%; background-color: #27ae60; width: 0%; transition: width 0.3s; + position: relative; + overflow: hidden; } #progress-fill.no-transition { transition: none !important; } + /* CrumBLE 4.5.4: alive-but-slow heartbeat. The numeric progress can sit + at the same value for 30-60s during chapter-cache upload (12/703 -> + 13/703 etc.), making the modal look frozen. Inner shimmer slides + left-to-right across the filled portion every 1.6s so the user has + continuous motion confirming the device is still processing. Pure + CSS, no JS tick cost. */ + #progress-fill::after { + content: ''; + position: absolute; + top: 0; left: 0; bottom: 0; + width: 80px; + background: linear-gradient(90deg, rgba(255,255,255,0) 0%, + rgba(255,255,255,0.6) 50%, + rgba(255,255,255,0) 100%); + animation: progress-shimmer 1.6s linear infinite; + } + /* Hide the shimmer when the bar is parked (no progress activity). The + .done class is applied by the upload modal when the run finishes. */ + #progress-fill.done::after { + display: none; + } + @keyframes progress-shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(800px); } + } #progress-text { text-align: center; margin-top: 5px; font-size: 0.9em; color: var(--label-color); } + /* CrumBLE 4.5.4: animated ellipsis suffix. Add class 'pulsing' to + progress-text and the trailing dots cycle 1->2->3->1... so the + status line itself reads as still-alive even when the body text + (12/703 etc.) hasn't ticked over. JS toggles the class while a + run is in-flight and removes it on done/cancel. */ + #progress-text.pulsing::after { + content: '\00A0\00A0\00A0'; + display: inline-block; + width: 1.5em; + text-align: left; + animation: progress-ellipsis 1.2s steps(4, end) infinite; + } + @keyframes progress-ellipsis { + 0% { content: '\00A0\00A0\00A0'; } + 25% { content: '.\00A0\00A0'; } + 50% { content: '..\00A0'; } + 75% { content: '...'; } + 100% { content: '\00A0\00A0\00A0'; } + } .folder-form { margin-top: 10px; } @@ -1872,6 +1919,13 @@

📤 Upload file

+ +
@@ -2150,7 +2204,21 @@

📂 Move File

let lastErrMsg = null; for (let attempt = 0; attempt <= MAX_RECONNECT_ATTEMPTS; attempt++) { try { - const response = await fetch('/api/files?path=' + encodeURIComponent(currentPath) + '&_=' + Date.now()); + // CrumBLE 4.5.4: hard 8s deadline. If the device silent-restarts to + // recover heap mid-request, the TCP connection just hangs (no 503, + // no error) and the previous code would sit on a single fetch + // forever -- the retry loop never fires because catch only runs + // when fetch actually errors. AbortController forces a timeout + // path so we cycle through the retries during the restart window. + const ctrl = new AbortController(); + const tid = setTimeout(() => ctrl.abort(), 8000); + let response; + try { + response = await fetch('/api/files?path=' + encodeURIComponent(currentPath) + '&_=' + Date.now(), + { signal: ctrl.signal }); + } finally { + clearTimeout(tid); + } if (response.status === 503) { // Device is in low-heap recovery; back off and retry. throw new Error('Device busy (503) — likely silent-restarting'); @@ -2162,6 +2230,7 @@

📂 Move File

lastErrMsg = null; break; // success } catch (e) { + if (e.name === 'AbortError') e.message = 'Request timed out (8s) — device likely silent-restarting'; lastErrMsg = e.message || String(e); console.warn('[FilesPage] load attempt ' + (attempt + 1) + '/' + (MAX_RECONNECT_ATTEMPTS + 1) + ' failed:', lastErrMsg); if (attempt < MAX_RECONNECT_ATTEMPTS) { @@ -2372,6 +2441,9 @@

📂 Move File

const progressText = document.getElementById('progress-text'); progressFill.style.width = '0%'; progressFill.style.backgroundColor = '#e74c3c'; + // CrumBLE 4.5.4: terminal state -- stop the heartbeat (shimmer + ellipsis). + progressFill.classList.add('done'); + progressText.classList.remove('pulsing'); progressText.style.color = '#e74c3c'; progressText.textContent = 'Upload cancelled by User!'; // Re-enable the action button (uploadBtn is always visible at this point; @@ -2422,6 +2494,11 @@

📂 Move File

if (convertOptionsReset) convertOptionsReset.style.display = ''; const titleResetEl = document.getElementById('uploadModalTitle'); if (titleResetEl) titleResetEl.textContent = '📤 Upload file'; + // optimizeSelectedOnDevice() also hides the file-info subtitle since + // 'Select a file to upload to ...' is wrong for that flow. Restore it + // for the next fresh upload. + const subtitleReset = document.querySelector('#uploadModal .file-info'); + if (subtitleReset) subtitleReset.style.display = ''; const uploadBtn = document.getElementById('uploadBtn'); uploadBtn.disabled = true; uploadBtn.style.display = 'block'; @@ -2715,11 +2792,76 @@

📂 Move File

// follows the same grey/red/green state machine. Failures on any // single file are non-fatal: the batch continues with the next file // and the failed entries surface in the retry banner. - async function optimizeSelectedOnDevice() { - const sel = getSelectedItems(); + // CrumBLE 4.5.4: generic Resume action. Any code path that bottoms out + // with recoverable state can call setPendingResumeAction({label, run}) + // and the in-modal Resume button surfaces, labeled appropriately. Resume + // click consumes the pending action and invokes run(). Used by both + // Optimize Selected (resume from prebake or full pipeline) AND regular + // WS upload (resume after probe-exhaustion meaning the device probably + // rebooted out of FT mode and the user needs to re-enter FT manually). + let pendingResumeAction = null; + function setPendingResumeAction(action) { + pendingResumeAction = action; + const btn = document.getElementById('resumeBtn'); + if (!btn) return; + if (action && typeof action.run === 'function') { + btn.textContent = action.label || 'Resume'; + btn.style.display = ''; + } else { + btn.style.display = 'none'; + } + } + function clearPendingResumeAction() { + pendingResumeAction = null; + const btn = document.getElementById('resumeBtn'); + if (btn) btn.style.display = 'none'; + } + async function runResume() { + if (!pendingResumeAction) return; + const action = pendingResumeAction; + pendingResumeAction = null; // consume; re-set on next failure + const btn = document.getElementById('resumeBtn'); + if (btn) btn.style.display = 'none'; + await action.run(); + } + // Back-compat shims so the existing Optimize Selected catch path keeps + // working without surgery. setOptimizeResumeState builds the run() + // callback that re-enters optimizeSelectedOnDevice with the right + // prebakeOnly + resumeSelection. + function setOptimizeResumeState(ctx) { + if (!ctx || !Array.isArray(ctx.pendingFiles) || ctx.pendingFiles.length === 0) { + clearPendingResumeAction(); + return; + } + const n = ctx.pendingFiles.length; + const what = ctx.prebakeOnly ? 'prebake' : 'optimize'; + setPendingResumeAction({ + label: `Resume — ${what} ${n} file${n === 1 ? '' : 's'}`, + run: () => optimizeSelectedOnDevice({ + prebakeOnly: !!ctx.prebakeOnly, + resumeSelection: ctx.pendingFiles, + }), + }); + } + function clearOptimizeResumeState() { clearPendingResumeAction(); } + + async function optimizeSelectedOnDevice(opts = {}) { + // CrumBLE 4.5.4: prebakeOnly skips the image-optimize + re-upload steps + // and runs ONLY chapter prebake on each selected file. Used either + // directly (no callers today -- standalone 'Prebake Selected' button + // was removed for clarity) or via the in-modal Resume button when the + // last failure left the SD copy already optimized. + // + // resumeSelection overrides getSelectedItems() so the Resume button + // can replay against the remaining items even when the user has + // cleared the DOM selection or navigated away. + const prebakeOnly = !!opts.prebakeOnly; + clearOptimizeResumeState(); // fresh run -- discard any prior stuck state + const sel = opts.resumeSelection || getSelectedItems(); const epubs = sel.filter(s => !s.isFolder && /\.epub$/i.test(s.name)); if (epubs.length === 0) { - alert('Select one or more EPUB files to optimize. Folders and non-EPUB files are ignored.'); + alert((prebakeOnly ? 'Select one or more EPUB files to prebake.' : 'Select one or more EPUB files to optimize.') + + ' Folders and non-EPUB files are ignored.'); return; } if (epubs.length < sel.length) { @@ -2742,6 +2884,10 @@

📂 Move File

const progressText = document.getElementById('progress-text'); progressFill.style.width = '0%'; progressFill.style.backgroundColor = '#27ae60'; + // CrumBLE 4.5.4: start the alive heartbeat. Shimmer in the bar + ellipsis + // after the status text. Cleared in the terminal-state branches below. + progressFill.classList.remove('done'); + progressText.classList.add('pulsing'); // Hide the file picker chrome since this flow's source is the SD card. const fileInput = document.getElementById('fileInput'); const uploadBtn = document.getElementById('uploadBtn'); @@ -2752,7 +2898,12 @@

📂 Move File

if (convertOptions) convertOptions.style.display = 'none'; if (startConvBtn) startConvBtn.style.display = 'none'; const titleEl = document.getElementById('uploadModalTitle'); - if (titleEl) titleEl.textContent = 'Optimize selected'; + if (titleEl) titleEl.textContent = prebakeOnly ? 'Prebake selected' : 'Optimize selected'; + // The shared upload-modal subtitle reads 'Select a file to upload to ...' + // which is wrong for Optimize Selected (source is the device, not disk). + // Hide while running; the openUploadModal() flow restores it for next time. + const subtitleEl = document.querySelector('#uploadModal .file-info'); + if (subtitleEl) subtitleEl.style.display = 'none'; // Three-state Cancel/Close: red while running. isUploadInProgress = true; uploadGeneration++; @@ -2829,40 +2980,74 @@

📂 Move File

const fileOriginPct = (idx) => idx * fileSliceSize; const fileScale = (idx, localPct) => fileOriginPct(idx) + (localPct * fileSliceSize / 100); + // Per-phase progress text helper: always shows file, batch index, current + // phase, and a live percent so the user can see *something* changing + // during long phases like the image-optimize / chapter-prebake passes + // that previously had no visible heartbeat. + const setPhase = (i, phase, localPct, suffix) => { + const overall = Math.round(fileScale(i, localPct)); + const batch = epubs.length > 1 ? ` (${i + 1}/${epubs.length})` : ''; + const tail = suffix ? ` -- ${suffix}` : ''; + progressText.textContent = `${phase}${batch} -- ${overall}%${tail}`; + }; + + // Phase budgets. prebakeOnly skips convert+upload so prebake gets the + // full bar; otherwise we keep the original 5/25/80/100 split. + const prebakeStartPct = prebakeOnly ? 15 : 80; for (let i = 0; i < epubs.length; i++) { const item = epubs[i]; const itemPath = item.path; // e.g. "/Books/Foo.epub" const parentPath = itemPath.substring(0, itemPath.lastIndexOf('/') + 1) || '/'; const fileName = item.name; - progressText.textContent = `Optimizing ${fileName} (${i + 1}/${epubs.length})...`; + let convertedFile; // populated below; prebake uses this + // CrumBLE 4.5.4: Resume state tracking. True once this item's SD copy + // is known to be the optimized version -- either we just uploaded it + // (post POST /upload success) or the caller declared prebakeOnly + // (SD copy already optimized from a prior run). On catch this drives + // whether the Resume button replays prebake-only or the full pipeline. + let itemUploadedThisRun = prebakeOnly; try { - // 1. Download the EPUB from device back to browser. + // 1. Download the EPUB from device back to browser. We need the + // bytes locally to feed prebakeChapters regardless of mode. log(`[${i + 1}/${epubs.length}] Downloading ${fileName}...`, '', 'INFO'); + setPhase(i, `Downloading ${fileName}`, 0); const dlResp = await fetch('/download?path=' + encodeURIComponent(itemPath)); if (!dlResp.ok) { throw new Error(`download failed: HTTP ${dlResp.status}`); } const epubBlob = await dlResp.blob(); const epubFile = new File([epubBlob], fileName, { type: 'application/epub+zip' }); - progressFill.style.width = fileScale(i, 5) + '%'; + progressFill.style.width = fileScale(i, prebakeOnly ? 15 : 5) + '%'; + setPhase(i, `Downloaded ${fileName}`, prebakeOnly ? 15 : 5); + if (prebakeOnly) { + // Skip convert + upload: SD copy is already what we want; just + // run chapter prebake on the freshly-downloaded bytes. + convertedFile = epubFile; + } else { // 2. Run the BT pass in the browser (image processing, .pxc bake). log(`[${i + 1}/${epubs.length}] Optimizing images...`, '', 'INFO'); const convertedBlob = await convertEpubFile(epubFile, (pct) => { - progressFill.style.width = fileScale(i, 5 + pct * 0.20) + '%'; // 5%->25% local + const local = 5 + pct * 0.20; + progressFill.style.width = fileScale(i, local) + '%'; // 5%->25% local + setPhase(i, `Optimizing images in ${fileName}`, local); }); - const convertedFile = new File([convertedBlob], fileName, { type: 'application/epub+zip' }); + convertedFile = new File([convertedBlob], fileName, { type: 'application/epub+zip' }); // 3. Upload the optimized EPUB back to its same SD path // (overwrites the original). log(`[${i + 1}/${epubs.length}] Uploading optimized EPUB...`, '', 'INFO'); + setPhase(i, `Uploading ${fileName}`, 25); await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const uploadPct = (e.loaded / e.total) * 100; - progressFill.style.width = fileScale(i, 25 + uploadPct * 0.55) + '%'; // 25%->80% + const local = 25 + uploadPct * 0.55; + progressFill.style.width = fileScale(i, local) + '%'; // 25%->80% + setPhase(i, `Uploading ${fileName}`, local, + `${(e.loaded / 1024 / 1024).toFixed(1)}/${(e.total / 1024 / 1024).toFixed(1)} MB`); } }); xhr.addEventListener('load', () => { @@ -2876,17 +3061,20 @@

📂 Move File

formData.append('file', convertedFile, fileName); xhr.send(formData); }); + // Upload-back landed: SD copy is now the optimized version. From + // here, any failure is recoverable via prebake-only Resume. + itemUploadedThisRun = true; + } // end !prebakeOnly // 4. Chapter prebake: produces book.bin + sections-prebake/ + // manifest and uploads them to /.crosspoint/epub_/. log(`[${i + 1}/${epubs.length}] Building chapter index...`, '', 'INFO'); + setPhase(i, `Building chapter index for ${fileName}`, prebakeStartPct); + const prebakeSpan = 100 - prebakeStartPct; await prebakeChapters(convertedFile, itemPath, (pct, status) => { - const local = 80 + pct * 0.20; // 80%->100% + const local = prebakeStartPct + pct * (prebakeSpan / 100); progressFill.style.width = fileScale(i, local) + '%'; - if (status) { - progressText.textContent = - `Optimizing ${fileName} (${i + 1}/${epubs.length})... ${status}`; - } + setPhase(i, `Building chapter index for ${fileName}`, local, status || ''); }); okCount++; @@ -2894,10 +3082,32 @@

📂 Move File

} catch (err) { console.error(`Optimization failed for ${fileName}:`, err); log(`[${i + 1}/${epubs.length}] ${fileName}: FAILED -- ${err.message || err}`, 'warning', 'OPT-FAIL'); - failed.push({ name: fileName, path: itemPath, error: err.message || String(err) }); + // CrumBLE 4.5.4: stash the per-item Resume hint alongside the + // failure record. We need the original item object (not just + // {name,path}) so a later Resume click can re-enter the pipeline + // with the same selection-style shape getSelectedItems returns. + failed.push({ + name: fileName, path: itemPath, error: err.message || String(err), + _item: item, _uploadedThisRun: itemUploadedThisRun, + }); } } + // CrumBLE 4.5.4: build Resume state from accumulated failures, not + // per-iteration. Setting inside the catch overwrote earlier failures + // with each subsequent catch -- losing items from the Resume list. + // Now: collect every failed item across the whole batch and pick the + // safest replay mode (full pipeline if any failed BEFORE upload-back + // landed; prebake-only if every failure happened after the SD copy + // was already updated). + if (failed.length > 0) { + const allUploaded = failed.every(f => f._uploadedThisRun); + setOptimizeResumeState({ + pendingFiles: failed.map(f => f._item), + prebakeOnly: allUploaded, + }); + } + // Terminal state -- mirror the upload modal's three-state Close. progressFill.style.width = '100%'; isUploadInProgress = false; @@ -2907,6 +3117,9 @@

📂 Move File

cancelBtn.classList.remove('in-progress'); cancelBtn.classList.add('done'); } + // CrumBLE 4.5.4: terminal state -- stop the heartbeat (shimmer + ellipsis). + progressFill.classList.add('done'); + progressText.classList.remove('pulsing'); if (failed.length === 0) { progressFill.style.backgroundColor = '#4caf50'; progressText.textContent = 'Finished, close when ready.'; @@ -3264,12 +3477,20 @@

📂 Move File

const MAX_RETRIES = 10; let attempt = 0; let lastError = null; + // CrumBLE 4.5.4: capture progress bytes so the probe-exhaustion path can + // tell the user how many MB they've already pushed -- and so the in-modal + // Resume action knows where to resume from in its label. + let lastSentBytes = 0; + const trackProgress = (sent, total) => { + if (typeof sent === 'number') lastSentBytes = sent; + if (onProgress) onProgress(sent, total); + }; while (attempt <= MAX_RETRIES) { try { // CrumBLE 4.4: surface devicePath (returned by DONE: upgrade) so // the caller can skip the post-upload /api/files lookup. - const devicePath = await uploadFileWebSocketOnce(file, onProgress, onComplete, onError); + const devicePath = await uploadFileWebSocketOnce(file, trackProgress, onComplete, onError); return devicePath; } catch (err) { lastError = err; @@ -3280,18 +3501,82 @@

📂 Move File

throw err; // out of retries } attempt++; - const delayMs = Math.min(2000 * Math.pow(2, attempt - 1), 32000); + // CrumBLE 4.5.4: device-restart-aware backoff. The device's START + // handler does a heap pre-flight and silentRestarts to recover when + // fragmented -- the user sees the upload sit at the same percent for + // 15-30s while the device reboots + reconnects WiFi + comes back up + // serving. The old code just exponential-backed-off with no + // indication that this is what's happening. Now: detect the explicit + // "Heap too fragmented" message OR a generic socket-close after + // we'd been making progress, and probe /api/status until the device + // confirms it's back BEFORE retrying the WS reconnect. + const isRestart = /restart|fragment/i.test(err.message || '') || + /WebSocket closed/i.test(err.message || ''); console.warn('[WS] Upload failed (attempt ' + attempt + '/' + MAX_RETRIES + - '), auto-resuming in ' + (delayMs / 1000) + 's:', err.message); - // Surface retry status via the onError callback so the UI can show - // "Retrying..." instead of "Failed". The actual onError fires only - // when retries are exhausted. - if (onError) { - try { onError('Upload paused (attempt ' + attempt + '/' + MAX_RETRIES + - ') — auto-resuming in ' + (delayMs / 1000) + 's...'); } - catch (e) { /* ignore */ } + '): ' + err.message); + if (isRestart) { + if (onError) { + try { onError('Device is restarting to recover heap. Waiting for it to come back...'); } + catch (e) { /* ignore */ } + } + // Probe /api/status every 2s with a 1.5s deadline, up to 60s. The + // first ok response means the device is serving again -- short + // breather to let WS bring-up settle, then resume. + let probedBack = false; + for (let probe = 1; probe <= 30; probe++) { + await new Promise(r => setTimeout(r, 2000)); + try { + const ctrl = new AbortController(); + const tid = setTimeout(() => ctrl.abort(), 1500); + const r = await fetch('/api/status', { cache: 'no-store', signal: ctrl.signal }); + clearTimeout(tid); + if (r.ok) { + probedBack = true; + if (onError) { + try { onError('Device back online. Resuming upload...'); } + catch (e) { /* ignore */ } + } + await new Promise(r => setTimeout(r, 500)); + break; + } + } catch (_) { + if (onError) { + try { onError('Waiting for device to finish restart... (' + (probe * 2) + 's, attempt ' + attempt + '/' + MAX_RETRIES + ')'); } + catch (e) { /* ignore */ } + } + } + } + if (!probedBack) { + // CrumBLE 4.5.4: probe budget exhausted -- /api/status never + // responded for 60s. Most likely scenario: device hard-crashed + // (panic) and rebooted into Home (not FT), so the web server + // isn't running. We can't keep blindly retrying WS connects; + // the user has to manually re-enter FT mode for the server to + // come back. Set up a Resume action with a clear actionable + // message + abort the auto-retry loop with a sentinel error + // that the catch handler doesn't treat as a normal failure. + const resumeFromMb = (lastSentBytes / 1024 / 1024).toFixed(2); + const totalMb = (file.size / 1024 / 1024).toFixed(2); + if (onError) { + try { + onError(`Device may have rebooted out of File Transfer mode (no response for 60s). To continue from ${resumeFromMb}/${totalMb} MB: re-enter File Transfer on your device (Settings -> Sync & Network -> File Transfer), then click Resume below.`); + } catch (e) { /* ignore */ } + } + setPendingResumeAction({ + label: `Resume upload (from ${resumeFromMb} MB)`, + run: () => uploadFileWebSocket(file, onProgress, onComplete, onError), + }); + throw new Error('Upload paused -- re-enter File Transfer mode and click Resume below.'); + } + } else { + const delayMs = Math.min(2000 * Math.pow(2, attempt - 1), 32000); + if (onError) { + try { onError('Upload paused (attempt ' + attempt + '/' + MAX_RETRIES + + ') — auto-resuming in ' + (delayMs / 1000) + 's...'); } + catch (e) { /* ignore */ } + } + await new Promise(r => setTimeout(r, delayMs)); } - await new Promise(r => setTimeout(r, delayMs)); } } @@ -3537,6 +3822,9 @@

📂 Move File

async function uploadNextFile() { if (currentIndex >= files.length) { // All files processed - show summary + // CrumBLE 4.5.4: terminal state -- stop the heartbeat (shimmer + ellipsis). + progressFill.classList.add('done'); + progressText.classList.remove('pulsing'); if (failedFiles.length === 0) { progressFill.style.backgroundColor = '#4caf50'; progressFill.style.width = '100%'; @@ -3616,6 +3904,11 @@

📂 Move File

progressFill.classList.add('no-transition'); progressFill.style.width = '0%'; progressFill.style.backgroundColor = '#27ae60'; + // CrumBLE 4.5.4: start the alive heartbeat (shimmer + ellipsis) for + // this file. Cleared in the terminal-state branches above (success / + // error / cancelled). + progressFill.classList.remove('done'); + progressText.classList.add('pulsing'); // Re-enable transition after a brief delay setTimeout(() => progressFill.classList.remove('no-transition'), 50); @@ -3969,8 +4262,13 @@

📂 Move File

📄 ${escapeHtml(failedFile.name)}
Error: ${escapeHtml(failedFile.error)}
- - + + `; filesList.appendChild(item); }); @@ -4360,9 +4658,12 @@

📂 Move File

// from the last successfully-received byte, no progress lost. const MAX_ATTEMPTS = 16; let lastErr; + let lastSentBytes = 0; + const statusEl = document.getElementById('modal-status'); for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { await lanotaUploadWs(file, (sent, total) => { + lastSentBytes = sent; lanotaSetProgress(sent / total * 100, 'Uploading: ' + (sent / 1024 / 1024).toFixed(2) + ' / ' + (total / 1024 / 1024).toFixed(2) + ' MB' + (attempt > 1 ? ' (attempt ' + attempt + ')' : '')); @@ -4372,8 +4673,37 @@

📂 Move File

} catch (e) { lastErr = e; if (attempt >= MAX_ATTEMPTS) break; - document.getElementById('modal-status').textContent = 'Connection dropped (' + e.message + '). Auto-retrying in 2s... (attempt ' + (attempt + 1) + '/' + MAX_ATTEMPTS + ')'; - await new Promise(r => setTimeout(r, 2000)); + // CrumBLE 4.5.4: adaptive backoff. If the device asked for a + // restart-to-defrag-heap mid-upload, the WS server is offline + // for ~8s while it reboots and ~5s more for WiFi to come back. + // Detect that case + probe /api/status until the device confirms + // it's serving again before reconnecting. Server's RESUME picks + // up at lastSentBytes automatically. + const isRestart = /restart|fragment/i.test(e.message || ''); + const resumeMb = (lastSentBytes / 1024 / 1024).toFixed(2); + const totalMb = (file.size / 1024 / 1024).toFixed(2); + if (isRestart) { + statusEl.textContent = `Device is restarting to recover heap (${resumeMb}/${totalMb} MB saved). Waiting for it to come back...`; + for (let probe = 1; probe <= 30; probe++) { + await new Promise(r => setTimeout(r, 2000)); + try { + const ctrl = new AbortController(); + const tid = setTimeout(() => ctrl.abort(), 1500); + const r = await fetch('/api/status', { cache: 'no-store', signal: ctrl.signal }); + clearTimeout(tid); + if (r.ok) { + statusEl.textContent = `Device back online. Resuming upload from ${resumeMb} MB...`; + await new Promise(r => setTimeout(r, 500)); + break; + } + } catch (_) { + statusEl.textContent = `Waiting for device to finish restart... (${probe * 2}s)`; + } + } + } else { + statusEl.textContent = `Connection dropped (${e.message}). Resuming from ${resumeMb} MB in 3s... (attempt ${attempt + 1}/${MAX_ATTEMPTS})`; + await new Promise(r => setTimeout(r, 3000)); + } } } if (lastErr) throw lastErr; @@ -4397,7 +4727,7 @@

📂 Move File

} } if (installErr) throw installErr; - lanotaSetProgress(100, 'Installing... device will restart in about 1 minute. This page will reload when v' + latestVersion + ' is detected.'); + lanotaSetProgress(100, 'Installing... device will restart in about 1 minute. The new firmware boots into Home, not File Transfer -- to verify v' + latestVersion + ' is running, re-enter File Transfer mode on your device after the screen returns to Home.'); lanotaPollReboot(latestVersion); } catch (e) { document.getElementById('modal-status').textContent = 'Update failed after retries: ' + e.message; diff --git a/src/network/html/HomePage.html b/src/network/html/HomePage.html index 01e9852c..18c8c6d7 100644 --- a/src/network/html/HomePage.html +++ b/src/network/html/HomePage.html @@ -249,9 +249,13 @@

Device Status

// progress on a mid-stream disconnect. const MAX_ATTEMPTS = 16; let lastErr; + // Tracks how far the server has acknowledged so the "Resuming from + // X MB" status text on retry reflects the actual saved progress. + let lastSentBytes = 0; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { await uploadFirmwareWs(file, (sent, total) => { + lastSentBytes = sent; setModalProgress(sent / total * 100, 'Uploading: ' + (sent / 1024 / 1024).toFixed(2) + ' / ' + (total / 1024 / 1024).toFixed(2) + ' MB' + (attempt > 1 ? ' (attempt ' + attempt + ')' : '')); @@ -261,8 +265,41 @@

Device Status

} catch (e) { lastErr = e; if (attempt >= MAX_ATTEMPTS) break; - setModalStatus('Connection dropped (' + e.message + '). Auto-retrying in 2s... (attempt ' + (attempt + 1) + '/' + MAX_ATTEMPTS + ')'); - await new Promise(r => setTimeout(r, 2000)); + // CrumBLE 4.5.4: adaptive backoff. If the device asked for a + // restart-to-defrag-heap mid-upload, the WS server is offline for + // ~8s while it reboots and ~5s more for WiFi to come back. The + // old fixed 2s retry just slammed a dead socket repeatedly. Now: + // detect the restart message + wait long enough, AND poll + // /api/status until the device confirms it's actually serving + // again before reconnecting. Server's RESUME protocol picks + // back up at lastSentBytes automatically. + const isRestart = /restart|fragment/i.test(e.message || ''); + const resumeMb = (lastSentBytes / 1024 / 1024).toFixed(2); + const totalMb = (file.size / 1024 / 1024).toFixed(2); + if (isRestart) { + setModalStatus(`Device is restarting to recover heap (${resumeMb}/${totalMb} MB saved). Waiting for it to come back...`); + // Probe /api/status until the device responds. Adds visible + // progress text per check so the user knows we're alive. + for (let probe = 1; probe <= 30; probe++) { + await new Promise(r => setTimeout(r, 2000)); + try { + const ctrl = new AbortController(); + const tid = setTimeout(() => ctrl.abort(), 1500); + const r = await fetch('/api/status', { cache: 'no-store', signal: ctrl.signal }); + clearTimeout(tid); + if (r.ok) { + setModalStatus(`Device back online. Resuming upload from ${resumeMb} MB...`); + await new Promise(r => setTimeout(r, 500)); + break; + } + } catch (_) { + setModalStatus(`Waiting for device to finish restart... (${probe * 2}s)`); + } + } + } else { + setModalStatus(`Connection dropped (${e.message}). Resuming from ${resumeMb} MB in 3s... (attempt ${attempt + 1}/${MAX_ATTEMPTS})`); + await new Promise(r => setTimeout(r, 3000)); + } } } if (lastErr) throw lastErr; @@ -287,7 +324,7 @@

Device Status

} } if (installErr) throw installErr; - setModalProgress(100, 'Installing... device will restart in about 1 minute. This page will reload when v' + latestVersion + ' is detected.'); + setModalProgress(100, 'Installing... device will restart in about 1 minute. The new firmware boots into Home, not File Transfer -- to verify v' + latestVersion + ' is running, re-enter File Transfer mode on your device after the screen returns to Home.'); pollForReboot(latestVersion); } catch (e) { setModalStatus('Update failed after retries: ' + e.message); diff --git a/src/network/html/js/optimizer.js b/src/network/html/js/optimizer.js index 226b9448..54f3b819 100644 --- a/src/network/html/js/optimizer.js +++ b/src/network/html/js/optimizer.js @@ -2290,6 +2290,13 @@ function loadCrumblePrebakeFactory() { // rejected instead of just an opaque "exit code N". const crumblePrebakeOutputBuf = []; +// CrumBLE 4.5.4: live progress heartbeat. Set by prebakeChapters() before +// Module.callMain() and cleared after. The print/printErr hooks invoke it +// on every WASM stdout line so the UI bar moves during the long CLI run. +// Defined at module scope (not inside the factory) so the same function +// instance is hooked once at module-load and read per-call. +let crumblePrebakeHeartbeat = null; + // Get a ready Module instance. The factory is invoked once and the resulting // Module is reused across runs (EXIT_RUNTIME=0 keeps MEMFS + libc alive // between callMain invocations). This means we pay the ~850 KB WASM @@ -2312,11 +2319,24 @@ async function loadCrumblePrebakeModule() { crumblePrebakeOutputBuf.push(msg); try { console.log('[prebake]', msg); } catch (e) {} try { log(`[prebake] ${msg}`, '', 'PRE'); } catch (e) {} + // CrumBLE 4.5.4: live heartbeat. callMain() blocks the JS main + // thread, so setInterval/setTimeout in JS can't tick during the + // CLI run. But Module.print fires sync on every stdout line -- + // hook it to nudge the progress bar so 80->99% actually moves + // instead of sticking at the "started prebake" milestone forever. + // Caller sets crumblePrebakeHeartbeat to (lineText) => void before + // callMain and clears it after. + if (typeof crumblePrebakeHeartbeat === 'function') { + try { crumblePrebakeHeartbeat(msg); } catch (e) {} + } }, printErr: (msg) => { crumblePrebakeOutputBuf.push(msg); try { console.error('[prebake-err]', msg); } catch (e) {} try { log(`[prebake-err] ${msg}`, 'warning', 'PRE-ERR'); } catch (e) {} + if (typeof crumblePrebakeHeartbeat === 'function') { + try { crumblePrebakeHeartbeat(msg); } catch (e) {} + } }, }); })(); @@ -2476,12 +2496,44 @@ async function prebakeChapters(epubBlob, deviceFilePath, progressCallback) { } if (pt > 0) { log(`Chapter prebake: fetching SD font ${fname} @ ${pt}pt`, '', 'PRE'); + // Hard-deadline + retry. Without this, a hung HTTP response from the + // device (most often heap pressure stalling /api/fonts/file mid-stream) + // silently wedges the whole prebake at the "fetching SD font" log line + // with no progress and no error -- user can't tell it's stuck vs slow. + const FONT_FETCH_TIMEOUT_MS = 30000; // CJK fonts can be 2-3 MB at 18pt + const FONT_FETCH_MAX_ATTEMPTS = 3; + let fontResp = null; + let fontBytes = null; + let lastFontErr = null; + for (let attempt = 1; attempt <= FONT_FETCH_MAX_ATTEMPTS; attempt++) { + const ctrl = new AbortController(); + const deadline = setTimeout(() => ctrl.abort(), FONT_FETCH_TIMEOUT_MS); + try { + fontResp = await fetch( + `/api/fonts/file?family=${encodeURIComponent(fname)}&size=${pt}`, + { signal: ctrl.signal }, + ); + if (!fontResp.ok) throw new Error(`HTTP ${fontResp.status}`); + fontBytes = new Uint8Array(await fontResp.arrayBuffer()); + clearTimeout(deadline); + lastFontErr = null; + break; + } catch (e) { + clearTimeout(deadline); + lastFontErr = e; + log(`SD font fetch attempt ${attempt}/${FONT_FETCH_MAX_ATTEMPTS} failed: ${e.name === 'AbortError' ? `timeout after ${FONT_FETCH_TIMEOUT_MS}ms` : (e.message || e)}`, + 'warning', 'PRE'); + if (attempt < FONT_FETCH_MAX_ATTEMPTS) { + const backoff = 1500 * attempt; + log(`Retrying in ${backoff}ms...`, '', 'PRE'); + await new Promise(r => setTimeout(r, backoff)); + } + } + } try { - const fontResp = await fetch(`/api/fonts/file?family=${encodeURIComponent(fname)}&size=${pt}`); - if (!fontResp.ok) { - throw new Error(`/api/fonts/file ${fontResp.status}`); + if (!fontBytes) { + throw lastFontErr || new Error('SD font fetch exhausted retries'); } - const fontBytes = new Uint8Array(await fontResp.arrayBuffer()); Module.FS.writeFile(sdFontPath, fontBytes); // CrumBLE 4.4: --emit-section-glyph-subsets gates BOTH the v39 EGS // emit AND the v40 glyph atlas emit on the CLI side. Without it the @@ -2509,7 +2561,24 @@ async function prebakeChapters(epubBlob, deviceFilePath, progressCallback) { // Clear the print/printErr capture buffer right before we run so any // earlier session output doesn't pollute this run's diagnostics. crumblePrebakeOutputBuf.length = 0; - const rc = Module.callMain(cliArgs); + // CrumBLE 4.5.4: live heartbeat. WASM CLI runs synchronously (blocks + // the JS main thread), so the only way to update the UI during it is + // from inside the print hook. Each stdout line nudges the bar 0.4% + // within the 40..90 sub-range we own here, capped so we never reach + // 100 before the actual upload-cache step runs. + let heartbeatPct = 40; + crumblePrebakeHeartbeat = (line) => { + heartbeatPct = Math.min(89, heartbeatPct + 0.4); + // Truncate long lines so the status text doesn't overflow the modal. + const summary = typeof line === 'string' && line.length > 70 ? line.slice(0, 67) + '...' : (line || ''); + reportProgress(heartbeatPct, summary || 'baking sections...'); + }; + let rc = -1; + try { + rc = Module.callMain(cliArgs); + } finally { + crumblePrebakeHeartbeat = null; + } if (rc !== 0) { // Surface whatever the CLI actually said on its way out. Without this // the thrown error is just "exit code N" with no clue what was wrong.