Add keepsBluetoothAlive hook to Activity base class

Introduces a virtual method allowing activities to indicate whether they need the BLE stack to remain active. This enables selective teardown of Bluetooth to free heap memory, with the Bluetooth settings screen being able to override this for pairing and scanning functionality.
This commit is contained in:
Justin Mitchell
2026-06-24 17:00:52 -04:00
parent d30fde2f4d
commit d6f5be6b7a
15 changed files with 112 additions and 39 deletions
+3 -1
View File
@@ -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"
+23
View File
@@ -1,10 +1,15 @@
#include "BleInput.h"
#include <GfxRenderer.h>
#include <HalPowerManager.h>
#include <I18n.h>
#include <cstdio>
#include <cstring>
#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) {
+8
View File
@@ -17,6 +17,9 @@
#include <cstdint>
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
+8
View File
@@ -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
+11
View File
@@ -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 {
+6
View File
@@ -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();
@@ -6,6 +6,7 @@
#include <Logging.h>
#include <WiFi.h>
#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
+5 -13
View File
@@ -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");
@@ -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;
};
@@ -3,7 +3,6 @@
#include <GfxRenderer.h>
#include <I18n.h>
#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;
@@ -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;
};
@@ -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;
};
@@ -6,7 +6,6 @@
#include <cstdio>
#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();
@@ -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 };
+25 -9
View File
@@ -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);
}