diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index c7989712..38ad2652 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -289,6 +289,8 @@ STR_BLUETOOTH: "Bluetooth" STR_TOGGLE_BLUETOOTH: "Toggle Bluetooth" STR_BT_SCAN_PAIR: "Scan & Pair" STR_BT_NO_DEVICES: "No devices found" +STR_BT_FREE_HINT1: "Set Free2/3 to Reader Mode and" +STR_BT_FREE_HINT2: "Volume Function to pair" STR_BT_DISCONNECT: "Disconnect" STR_BT_CONNECTED_TO: "Connected: %s" STR_BT_NOT_CONNECTED: "Not connected" @@ -296,7 +298,7 @@ STR_BT_PAIRED_DEVICES: "Paired Devices" STR_BT_NO_PAIRED: "No paired devices" STR_BT_MAP_BUTTONS: "Map Remote Buttons" STR_BT_PRESS_REMOTE: "Press a button on your remote" -STR_BT_CONNECTING_POPUP: "BT Connecting" +STR_BT_CONNECTING_POPUP: "BT Connecting..." STR_BT_PAGE_FORWARD: "Page Forward" STR_BT_PAGE_BACK: "Page Back" STR_BT_FORGET_PROMPT: "Hold Confirm to forget" diff --git a/src/BleInput.cpp b/src/BleInput.cpp index 5204eaf1..8fdf4bc6 100644 --- a/src/BleInput.cpp +++ b/src/BleInput.cpp @@ -1,10 +1,15 @@ #include "BleInput.h" +#include #include +#include #include #include +#include "MappedInputManager.h" +#include "components/UITheme.h" + namespace bleinput { // NimBLE controller init/deinit hang (interrupt WDT) if run at the 10 MHz low-power @@ -72,6 +77,24 @@ const char* specialName(uint8_t value) { } } // namespace +void showConnectingUntilLinked(GfxRenderer& renderer, MappedInputManager& input) { + if (!BleHid.isRunning() || BleHid.isConnected()) return; + // drawPopup refreshes the panel itself, so draw once and let e-ink hold it while we + // pump the host. Holds until the remote links, the user presses a button to bail, or + // a generous timeout (a remote that slept after a disconnect needs a button to wake). + GUI.drawPopup(renderer, tr(STR_BT_CONNECTING_POPUP)); + const unsigned long deadline = millis() + 10000; + while (!BleHid.isConnected() && millis() < deadline) { + BleHid.poll(); + input.update(); + if (input.wasAnyPressed()) break; + delay(50); + } + // Note: the caller must redraw to clear the popup. For grayscale reader pages the + // caller should also request a ghost-cleanup (HALF) refresh first — a plain fast/ + // partial refresh ghosts badly over the BW popup (see Activity::requestGhostCleanup). +} + void describeKey(uint8_t kind, uint8_t value, char* out, size_t outLen) { if (!out || outLen == 0) return; if (kind == 0) { diff --git a/src/BleInput.h b/src/BleInput.h index d32764c5..f0bba35a 100644 --- a/src/BleInput.h +++ b/src/BleInput.h @@ -17,6 +17,9 @@ #include +class GfxRenderer; +class MappedInputManager; + namespace bleinput { // Advertised central name shown to peripherals during pairing. @@ -38,4 +41,9 @@ bool encodeKey(const freeink::KeyEvent& ev, uint8_t& kind, uint8_t& value); // Writes a null-terminated string into out (e.g. "Page Down", "Key 0x4B"). void describeKey(uint8_t kind, uint8_t value, char* out, size_t outLen); +// Draw a "BT Connecting..." popup and pump the BLE host until the bonded remote +// links, the user presses a button to dismiss, or a timeout. No-op if BLE isn't +// running or is already connected. The caller must redraw afterward to clear it. +void showConnectingUntilLinked(GfxRenderer& renderer, MappedInputManager& input); + } // namespace bleinput diff --git a/src/activities/Activity.h b/src/activities/Activity.h index 070eb4fd..42e3375b 100644 --- a/src/activities/Activity.h +++ b/src/activities/Activity.h @@ -44,6 +44,14 @@ class Activity { virtual bool skipLoopDelay() { return false; } virtual bool preventAutoSleep() { return false; } virtual bool isReaderActivity() const { return false; } + // True if this activity needs the BLE stack resident (beyond the readers, which are + // covered by isReaderActivity()). The Bluetooth settings screen overrides this so + // pairing/scanning works there. Everywhere else BLE is torn down to free heap. + virtual bool keepsBluetoothAlive() const { return false; } + // Ask the activity to make its next render a full ghost-cleanup (HALF) refresh rather + // than a fast/partial one. Used after drawing a transient popup over grayscale content + // (e.g. the "BT Connecting..." popup over a reader page) so it clears without ghosting. + virtual void requestGhostCleanup() {} virtual ScreenshotInfo getScreenshotInfo() const { return {}; } // Start a new activity without destroying the current one diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 96e85460..c4c92225 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -254,6 +254,17 @@ bool ActivityManager::isReaderActivity() const { (currentActivity && currentActivity->isReaderActivity()); } +void ActivityManager::requestGhostCleanup() { + if (currentActivity) currentActivity->requestGhostCleanup(); +} + +bool ActivityManager::bluetoothShouldBeActive() const { + const auto wants = [](const auto& activity) { + return activity && (activity->isReaderActivity() || activity->keepsBluetoothAlive()); + }; + return std::any_of(stackActivities.begin(), stackActivities.end(), wants) || wants(currentActivity); +} + bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); } ScreenshotInfo ActivityManager::getScreenshotInfo() const { diff --git a/src/activities/ActivityManager.h b/src/activities/ActivityManager.h index 9066888d..ea1fa373 100644 --- a/src/activities/ActivityManager.h +++ b/src/activities/ActivityManager.h @@ -101,6 +101,9 @@ class ActivityManager { bool preventAutoSleep() const; bool isReaderActivity() const; + // True if BLE should be resident for the current context: any reader (page-turner + // input) or the Bluetooth settings screen (pairing) is on the stack. + bool bluetoothShouldBeActive() const; bool skipLoopDelay() const; ScreenshotInfo getScreenshotInfo() const; @@ -108,6 +111,9 @@ class ActivityManager { // Otherwise, it will be deferred until the end of the current loop iteration. void requestUpdate(bool immediate = false); + // Ask the current activity to make its next render a ghost-cleanup (HALF) refresh. + void requestGhostCleanup(); + // Trigger a render and block until it completes. // Must NOT be called from the render task or while holding a RenderLock. void requestUpdateAndWait(); diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 9d473699..60588554 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -6,6 +6,7 @@ #include #include +#include "BleInput.h" #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "WifiCredentialStore.h" @@ -93,6 +94,11 @@ void WifiSelectionActivity::startWifiScan() { networks.clear(); requestUpdate(); + // Free the BLE stack before bringing WiFi up: the C3 has one radio and the two + // stacks can't both fit in heap. Reachable from the reader via KOReader sync, where + // BLE is still resident; a no-op when BLE is already off (launched from Settings). + bleinput::stop(); + // Set WiFi mode to station WiFi.mode(WIFI_STA); WiFi.disconnect(); @@ -211,6 +217,7 @@ void WifiSelectionActivity::attemptConnection() { connectionError.clear(); requestUpdate(); + bleinput::stop(); // free the BLE stack before WiFi (shared C3 radio, tight heap) WiFi.persistent(false); // Credentials are managed by WifiCredentialStore; suppress SDK NVS auto-connect WiFi.mode(WIFI_STA); WiFi.disconnect(true, true); // Abort any in-progress SDK auto-connect and clear NVS-saved SSID diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 7946be9d..2de5e825 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -875,19 +875,11 @@ void EpubReaderActivity::render(RenderLock&& lock) { built = buildSection(); const bool bleOk = bleinput::ensureStarted(); LOG_INF("ERS", "BLE restart after build: begin=%d", bleOk); - if (bleOk) { - // Show a connecting popup and give the bonded remote a moment to re-link so the - // user isn't left with an unresponsive remote and a silent pause. Bounded: a - // remote that needs a button press to re-advertise won't stall reading — it - // reconnects in the background after we return. The popup is drawn once (e-ink - // holds it) and the page render overwrites it when we resume. - GUI.drawPopup(renderer, tr(STR_BT_CONNECTING_POPUP)); - const unsigned long deadline = millis() + 4000; - while (!BleHid.isConnected() && millis() < deadline) { - BleHid.poll(); - delay(50); - } - } + // Hold the "BT Connecting..." popup until the remote re-links, then force the + // page render below onto the ghost-cleanup (HALF) path so the popup clears + // without ghosting the grayscale page. + bleinput::showConnectingUntilLinked(renderer, mappedInput); + requestGhostCleanup(); } if (!built) { LOG_ERR("ERS", "Failed to persist page data to SD"); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 7e2a4ad2..20dee8be 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -86,6 +86,7 @@ class EpubReaderActivity final : public Activity { void loop() override; void render(RenderLock&& lock) override; bool isReaderActivity() const override { return true; } + void requestGhostCleanup() override { pagesUntilFullRefresh = 1; } ScreenshotInfo getScreenshotInfo() const override; CrossPointPosition getCurrentPosition() const; }; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 12608743..dac06714 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -3,7 +3,6 @@ #include #include -#include "BleInput.h" #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "components/UITheme.h" @@ -80,13 +79,10 @@ void EpubReaderMenuActivity::loop() { } if (selectedAction == MenuAction::TOGGLE_BLUETOOTH) { - // Toggle in place and stay in the menu (no reader re-render needed). + // Just flip the preference and stay in the menu. The main-loop lifecycle check + // brings the BLE stack up/down to match (and shows the "BT Connecting..." popup), + // so start/stop has a single owner. SETTINGS.bluetoothEnabled = SETTINGS.bluetoothEnabled ? 0 : 1; - if (SETTINGS.bluetoothEnabled) { - bleinput::ensureStarted(); - } else { - bleinput::stop(); - } SETTINGS.saveToFile(); requestUpdate(); return; diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index b5c8b0d9..8a36ef53 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -49,5 +49,6 @@ class TxtReaderActivity final : public Activity { void loop() override; void render(RenderLock&&) override; bool isReaderActivity() const override { return true; } + void requestGhostCleanup() override { pagesUntilFullRefresh = 1; } ScreenshotInfo getScreenshotInfo() const override; }; diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index f020b0b0..3b0bfaf3 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -41,5 +41,6 @@ class XtcReaderActivity final : public Activity { void loop() override; void render(RenderLock&&) override; bool isReaderActivity() const override { return true; } + void requestGhostCleanup() override { pagesUntilFullRefresh = 1; } ScreenshotInfo getScreenshotInfo() const override; }; diff --git a/src/activities/settings/BluetoothSettingsActivity.cpp b/src/activities/settings/BluetoothSettingsActivity.cpp index c7e8e727..576c9855 100644 --- a/src/activities/settings/BluetoothSettingsActivity.cpp +++ b/src/activities/settings/BluetoothSettingsActivity.cpp @@ -6,7 +6,6 @@ #include #include "BleButtonMapActivity.h" -#include "BleInput.h" #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "components/UITheme.h" @@ -62,12 +61,9 @@ void BluetoothSettingsActivity::handleMenuConfirm() { const Action action = menuRows[menuIndex].action; switch (action) { case Action::ToggleBt: + // Flip the preference only; the main-loop lifecycle check starts/stops the BLE + // stack to match (and shows the "BT Connecting..." popup). Single owner. SETTINGS.bluetoothEnabled = SETTINGS.bluetoothEnabled ? 0 : 1; - if (SETTINGS.bluetoothEnabled) { - bleinput::ensureStarted(); - } else { - bleinput::stop(); - } SETTINGS.saveToFile(); rebuildMenuRows(); requestUpdate(); @@ -238,14 +234,18 @@ void BluetoothSettingsActivity::render(RenderLock&&) { }, true); } else if (view == View::Scan) { + // Free2/3 remotes only advertise in the right slider mode — tell the user how. + GUI.drawHelpText(renderer, Rect{0, topOffset, pageWidth, 16}, tr(STR_BT_FREE_HINT1)); + GUI.drawHelpText(renderer, Rect{0, topOffset + 16, pageWidth, 16}, tr(STR_BT_FREE_HINT2)); + const int scanTop = topOffset + 38; const int count = BleHid.deviceCount(); if (count == 0) { - GUI.drawHelpText(renderer, Rect{0, topOffset + metrics.verticalSpacing, pageWidth, 24}, + GUI.drawHelpText(renderer, Rect{0, scanTop, pageWidth, 24}, BleHid.isScanning() ? tr(STR_SCANNING) : tr(STR_BT_NO_DEVICES)); } else { GUI.drawList( - renderer, listRect, count, scanIndex, [this](int i) { return deviceLabel(i); }, nullptr, nullptr, nullptr, - false); + renderer, Rect{0, scanTop, pageWidth, contentHeight - 38}, count, scanIndex, + [this](int i) { return deviceLabel(i); }, nullptr, nullptr, nullptr, false); } } else { // Paired const int count = BleHid.pairedCount(); diff --git a/src/activities/settings/BluetoothSettingsActivity.h b/src/activities/settings/BluetoothSettingsActivity.h index c8e142e2..648ca9c0 100644 --- a/src/activities/settings/BluetoothSettingsActivity.h +++ b/src/activities/settings/BluetoothSettingsActivity.h @@ -23,6 +23,7 @@ class BluetoothSettingsActivity final : public Activity { void onExit() override; void loop() override; void render(RenderLock&&) override; + bool keepsBluetoothAlive() const override { return true; } private: enum class View { Menu, Scan, Paired }; diff --git a/src/main.cpp b/src/main.cpp index 8b4e4314..c9e6156a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -483,14 +483,29 @@ void setup() { // Ensure we're not still holding the power button before leaving setup waitForPowerRelease(); allowSleepAt = millis() + 2000; + // Bluetooth is started lazily by the lifecycle check in loop() once a reader or the + // Bluetooth settings screen is on the stack — not here at boot — so home/browser and + // WiFi activities keep the ~50 KB the BLE stack would otherwise hold. +} - // Restore Bluetooth if it was enabled before sleep/reboot. Deep-sleep wake is a - // full chip reset, so NimBLE starts fresh here and auto-reconnects to the bonded - // page-turner. Left uninitialised (zero radio/RAM cost) when the setting is off. - // ensureStarted() forces normal CPU frequency internally (NimBLE controller init - // hangs at the 10 MHz low-power state). - if (SETTINGS.bluetoothEnabled) { +// Bring the BLE stack up or down to match the current context. BLE is only resident +// while a reader (page-turner input) or the Bluetooth settings screen (pairing) is on +// the stack AND WiFi is off — WiFi and BLE share the one C3 radio and can't both fit in +// heap. Called every loop; ensureStarted()/stop() are no-ops when already in the right +// state, so this only does work on a transition. +void updateBluetoothLifecycle() { + const bool wanted = + SETTINGS.bluetoothEnabled && activityManager.bluetoothShouldBeActive() && WiFi.getMode() == WIFI_MODE_NULL; + if (wanted && !BleHid.isRunning()) { bleinput::ensureStarted(); + // Single place BLE starts for normal use (reader entry, BT toggled on, etc.), so the + // "BT Connecting..." popup shows uniformly. Then clear it with a ghost-cleanup (HALF) + // refresh so a grayscale reader page doesn't ghost over the popup. + bleinput::showConnectingUntilLinked(renderer, mappedInputManager); + activityManager.requestGhostCleanup(); + activityManager.requestUpdate(); + } else if (!wanted && BleHid.isRunning()) { + bleinput::stop(); } } @@ -500,6 +515,7 @@ void loop() { static unsigned long lastMemPrint = 0; gpio.update(); + updateBluetoothLifecycle(); // bring BLE up/down for the current activity context BleHid.poll(); // drive BLE auto-reconnect + key auto-repeat (no-op when BT off) mappedInputManager.pollBle(); // drain BLE keys -> logical-button overlay for this frame halTiltSensor.update(SETTINGS.tiltPageTurn, SETTINGS.orientation, activityManager.isReaderActivity()); @@ -616,13 +632,13 @@ void loop() { // The BLE controller cannot run at the 10 MHz low-power frequency — NimBLE's // controller reset/maintenance hangs the radio and trips the interrupt WDT (the // same reason WiFi force-disables power saving in HalPowerManager). Keep full CPU - // speed whenever Bluetooth is enabled, regardless of input idleness. - if (!SETTINGS.bluetoothEnabled && millis() - lastActivityTime >= HalPowerManager::IDLE_POWER_SAVING_MS) { + // speed whenever the BLE stack is actually resident, regardless of input idleness. + if (!BleHid.isRunning() && millis() - lastActivityTime >= HalPowerManager::IDLE_POWER_SAVING_MS) { // If we've been inactive for a while, increase the delay to save power powerManager.setPowerSaving(true); // Lower CPU frequency after extended inactivity delay(50); } else { - if (SETTINGS.bluetoothEnabled) powerManager.setPowerSaving(false); // keep the BLE radio stable + if (BleHid.isRunning()) powerManager.setPowerSaving(false); // keep the BLE radio stable // Short delay to prevent tight loop while still being responsive delay(10); }