Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 26 additions & 15 deletions lib/hal/BluetoothHIDManager.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "BluetoothHIDManager.h"
#include <algorithm>
#include <Logging.h>
#include <NimBLEDevice.h>
#include <HalGPIO.h>
Expand Down Expand Up @@ -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);
}
}
};

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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<int>(_discoveredDevices.size()));
}

bool BluetoothHIDManager::connectToDevice(const std::string& address) {
if (!_enabled) {
LOG_ERR("BT", "Cannot connect: Bluetooth not enabled");
Expand Down
2 changes: 2 additions & 0 deletions lib/hal/BluetoothHIDManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 9 additions & 7 deletions lib/hal/DeviceProfiles.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down
4 changes: 4 additions & 0 deletions lib/hal/DeviceProfiles.h
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
4 changes: 3 additions & 1 deletion platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/CrossPointSettings.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions src/SilentRestart.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/activities/ActivityManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<KOReaderAuthActivity>(renderer, mappedInput));
}

void ActivityManager::goToSettings() { replaceActivity(std::make_unique<SettingsActivity>(renderer, mappedInput)); }

void ActivityManager::goToFileBrowser(std::string path) {
Expand Down
4 changes: 4 additions & 0 deletions src/activities/ActivityManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
19 changes: 19 additions & 0 deletions src/activities/browser/OpdsBookBrowserActivity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions src/activities/home/HomeActivity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
7 changes: 7 additions & 0 deletions src/activities/network/CrossPointWebServerActivity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
77 changes: 73 additions & 4 deletions src/activities/reader/EpubReaderActivity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<double>(curLineComp),
static_cast<double>(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<ChoicePromptActivity>(
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading