Add Bluetooth HID remote control support

Integrate BLE keyboard host functionality for page-turner remotes. Adds device pairing, button mapping, preset configurations for Free2/Free3 remotes, and persistent storage of mappings. Migrates from open-x4-sdk to freeink-sdk submodule which includes the BleKeyboardHost library. Implements CPU frequency locking during BLE operations to prevent watchdog timeouts.
This commit is contained in:
Justin Mitchell
2026-06-24 15:36:29 -04:00
parent 6e8dbd7f23
commit 9ab0b0bfb7
20 changed files with 984 additions and 15 deletions
+3 -3
View File
@@ -1,3 +1,3 @@
[submodule "open-x4-sdk"]
path = open-x4-sdk
url = https://github.com/crosspoint-reader/community-sdk.git
[submodule "freeink-sdk"]
path = freeink-sdk
url = https://github.com/Free-Ink/freeink-sdk.git
Submodule
+1
Submodule freeink-sdk added at b02696589f
+20
View File
@@ -285,6 +285,26 @@ STR_HW_BACK_LABEL: "Back (1st button)"
STR_HW_CONFIRM_LABEL: "Confirm (2nd button)"
STR_HW_LEFT_LABEL: "Left (3rd button)"
STR_HW_RIGHT_LABEL: "Right (4th button)"
STR_BLUETOOTH: "Bluetooth"
STR_TOGGLE_BLUETOOTH: "Toggle Bluetooth"
STR_BT_SCAN_PAIR: "Scan & Pair"
STR_BT_NO_DEVICES: "No devices found"
STR_BT_DISCONNECT: "Disconnect"
STR_BT_CONNECTED_TO: "Connected: %s"
STR_BT_NOT_CONNECTED: "Not connected"
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_ASSIGN_PROMPT: "Select what this button does"
STR_BT_PRESET_FREE2: "Apply Free2 Preset"
STR_BT_PRESET_FREE3: "Apply Free3 Preset"
STR_BT_CLEAR_MAP: "Clear Button Mappings"
STR_BT_PAIRING_PASSKEY: "Passkey: %u"
STR_BT_PAGE_FORWARD: "Page Forward"
STR_BT_PAGE_BACK: "Page Back"
STR_BT_FORGET_PROMPT: "Hold Confirm to forget"
STR_BT_MAP_FULL: "Mapping table full - clear mappings first"
STR_GO_TO_PERCENT: "Go to %"
STR_GO_HOME_BUTTON: "Go Home"
STR_SYNC_PROGRESS: "Sync Progress"
Submodule open-x4-sdk deleted from 198ad26721
+19 -4
View File
@@ -38,6 +38,16 @@ build_flags =
-Wno-bidi-chars
-Wl,--wrap=panic_print_backtrace,--wrap=panic_abort,--wrap=bootloader_common_check_efuse_blk_validity
-fno-exceptions
# FreeInk panel profiles: compile both X3 (792x528/UC8253) and X4 (800x480/SSD1677);
# the firmware picks the active one at runtime via HalDisplay setDisplayX3().
-DFREEINK_DEVICE_X3=1
-DFREEINK_DEVICE_X4=1
# BLE HID page-turner host (BleKeyboardHost). NimBLE role/bond config is baked into
# the prebuilt arduino-esp32 framework's sdkconfig.h, so we don't redefine it here
# (doing so only warns and has no effect). The host only compiles when
# FREEINK_CAP_BLE_HID_HOST is set on the env (below); with the capability off,
# BleKeyboardHost links stubs and pulls in zero NimBLE code.
-DFREEINK_BLE_HID_SHOW_UNNAMED_DEVICES=0
build_unflags =
-std=gnu++11
@@ -57,10 +67,12 @@ extra_scripts =
; Libraries
lib_deps =
BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor
InputManager=symlink://open-x4-sdk/libs/hardware/InputManager
EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager
BatteryMonitor=symlink://freeink-sdk/libs/hardware/BatteryMonitor
InputManager=symlink://freeink-sdk/libs/hardware/InputManager
EInkDisplay=symlink://freeink-sdk/libs/display/FreeInkDisplay
SDCardManager=symlink://freeink-sdk/libs/hardware/SDCardManager
BleKeyboardHost=symlink://freeink-sdk/libs/network/BleKeyboardHost
h2zero/NimBLE-Arduino @ ^2.3.8
bblanchon/ArduinoJson @ 7.4.2
ricmoo/QRCode @ 0.0.1
bitbank2/PNGdec @ 1.1.6
@@ -74,6 +86,7 @@ build_flags =
; CROSSPOINT_VERSION is set by scripts/git_branch.py (includes branch + short SHA)
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2 ; Set log level to debug for development builds
-DFREEINK_CAP_BLE_HID_HOST=1 ; BLE HID page-turner host (NimBLE)
[env:gh_release]
@@ -83,6 +96,7 @@ build_flags =
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=1 ; Set log level to info for release builds
-DFREEINK_CAP_BLE_HID_HOST=1 ; BLE HID page-turner host (NimBLE)
[env:gh_release_rc]
extends = base
@@ -91,6 +105,7 @@ build_flags =
-DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=1 ; Set log level to info for release candidate builds
-DFREEINK_CAP_BLE_HID_HOST=1 ; BLE HID page-turner host (NimBLE)
[env:slim]
extends = base
+89
View File
@@ -0,0 +1,89 @@
#include "BleInput.h"
#include <HalPowerManager.h>
#include <cstdio>
#include <cstring>
namespace bleinput {
// NimBLE controller init/deinit hang (interrupt WDT) if run at the 10 MHz low-power
// frequency, so force normal CPU speed around both. Centralized here so every caller
// (boot restore, settings toggle, reader toggle, sleep) is covered automatically.
bool ensureStarted() {
HalPowerManager::Lock powerLock;
return BleHid.begin(kHostName);
}
// Full teardown (NimBLE deinit), not just a link drop, so the BLE stack's RAM is
// returned to the heap — otherwise memory-hungry work like EPUB inflate can't
// allocate even after the user turns Bluetooth off.
void stop() {
HalPowerManager::Lock powerLock;
BleHid.end();
}
bool encodeKey(const freeink::KeyEvent& ev, uint8_t& kind, uint8_t& value) {
if (ev.special != freeink::SpecialKey::None) {
kind = 0;
value = static_cast<uint8_t>(ev.special);
return true;
}
if (ev.keycode != 0) {
kind = 1;
value = ev.keycode;
return true;
}
return false;
}
namespace {
const char* specialName(uint8_t value) {
switch (static_cast<freeink::SpecialKey>(value)) {
case freeink::SpecialKey::Enter:
return "Enter";
case freeink::SpecialKey::Backspace:
return "Backspace";
case freeink::SpecialKey::Tab:
return "Tab";
case freeink::SpecialKey::Escape:
return "Escape";
case freeink::SpecialKey::Delete:
return "Delete";
case freeink::SpecialKey::Left:
return "Left";
case freeink::SpecialKey::Right:
return "Right";
case freeink::SpecialKey::Up:
return "Up";
case freeink::SpecialKey::Down:
return "Down";
case freeink::SpecialKey::Home:
return "Home";
case freeink::SpecialKey::End:
return "End";
case freeink::SpecialKey::PageUp:
return "Page Up";
case freeink::SpecialKey::PageDown:
return "Page Down";
default:
return nullptr;
}
}
} // namespace
void describeKey(uint8_t kind, uint8_t value, char* out, size_t outLen) {
if (!out || outLen == 0) return;
if (kind == 0) {
const char* name = specialName(value);
if (name) {
strncpy(out, name, outLen - 1);
out[outLen - 1] = '\0';
return;
}
}
// Printable ASCII usage handled as a generic key code; show the raw value.
snprintf(out, outLen, "Key 0x%02X", static_cast<unsigned>(value));
}
} // namespace bleinput
+41
View File
@@ -0,0 +1,41 @@
#pragma once
// CrossPoint <-> FreeInk BLE HID host glue.
//
// Thin, capability-safe helpers around freeink::BleKeyboardHost (the `BleHid`
// singleton). When FREEINK_CAP_BLE_HID_HOST is compiled out the SDK links stubs,
// so every call here is still valid and simply no-ops / returns false — callers
// need no #ifdefs.
//
// The (kind, value) pair produced by encodeKey() is the stable identity stored in
// CrossPointSettings::bleKeyMap. Page-turner remotes emit "special" keys
// (PageUp/PageDown/arrows); plain keyboards emit usage codes. We deliberately
// ignore modifiers and the printable char for matching (page turners don't use
// modifiers), keeping the persisted entry a trivial two-byte comparison.
#include <BleKeyboardHost.h>
#include <cstdint>
namespace bleinput {
// Advertised central name shown to peripherals during pairing.
inline constexpr const char* kHostName = "CrossPoint";
// Start the BLE HID host (idempotent). Returns false if BLE is compiled out or
// NimBLE init failed. Safe to call repeatedly.
bool ensureStarted();
// Drop the active link (e.g. before deep sleep or when the user disables BT).
void stop();
// Encode a decoded key event into the stable (kind, value) identity used by the
// settings map. kind: 0 = SpecialKey, 1 = HID usage. Returns false when the event
// carries no usable identity (no special key and no usage code).
bool encodeKey(const freeink::KeyEvent& ev, uint8_t& kind, uint8_t& value);
// Human-readable name for a stored (kind, value) identity, for the mapping UI.
// 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);
} // namespace bleinput
+16
View File
@@ -216,6 +216,22 @@ class CrossPointSettings {
uint8_t frontButtonConfirm = FRONT_HW_CONFIRM;
uint8_t frontButtonLeft = FRONT_HW_LEFT;
uint8_t frontButtonRight = FRONT_HW_RIGHT;
// --- Bluetooth (BLE HID page-turner) ---
// Master on/off for the BLE HID host. Persisted; auto-restored on boot/wake.
// Managed by BluetoothSettingsActivity and the in-reader "Toggle Bluetooth" menu item.
uint8_t bluetoothEnabled = 0;
// Remote-button mapping table: each slot binds a decoded BLE key identity to a
// logical MappedInputManager::Button. Fixed-capacity POD (no heap), persisted
// manually in JsonSettingsIO (like the front-button remap). 0xFF = empty/unassigned.
// Headroom for several buttons plus optional presets and rolling-code remotes
// (some buttons emit more than one code). Each entry is 3 bytes.
static constexpr uint8_t BLE_MAP_CAPACITY = 10;
struct BleKeyMapEntry {
uint8_t keyKind = 0xFF; // 0 = SpecialKey, 1 = HID usage code; 0xFF = empty slot
uint8_t keyValue = 0; // (uint8_t)freeink::SpecialKey, or the raw HID usage id
uint8_t button = 0xFF; // (uint8_t)MappedInputManager::Button; 0xFF = unassigned
};
BleKeyMapEntry bleKeyMap[BLE_MAP_CAPACITY] = {};
// Reader font settings
uint8_t fontFamily = NOTOSERIF;
uint8_t fontSize = MEDIUM;
+28
View File
@@ -11,6 +11,7 @@
#include "BookmarkEntry.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "RecentBooksStore.h"
#include "SettingsList.h"
@@ -142,6 +143,16 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
doc["frontButtonConfirm"] = s.frontButtonConfirm;
doc["frontButtonLeft"] = s.frontButtonLeft;
doc["frontButtonRight"] = s.frontButtonRight;
// Bluetooth — managed by BluetoothSettingsActivity, not in SettingsList.
doc["bluetoothEnabled"] = s.bluetoothEnabled;
JsonArray bleMap = doc["bleKeyMap"].to<JsonArray>();
for (const auto& e : s.bleKeyMap) {
if (e.keyKind == 0xFF || e.button == 0xFF) continue; // skip empty/unassigned slots
JsonObject o = bleMap.add<JsonObject>();
o["k"] = e.keyKind;
o["v"] = e.keyValue;
o["b"] = e.button;
}
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
doc["fontFamily"] = s.fontFamily;
// SD card font family name — not in SettingsList, save manually
@@ -243,6 +254,23 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
clamp(doc["frontButtonRight"] | (uint8_t)S::FRONT_HW_RIGHT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_RIGHT);
CrossPointSettings::validateFrontButtonMapping(s);
// Bluetooth — managed by BluetoothSettingsActivity, not in SettingsList.
s.bluetoothEnabled = clamp(doc["bluetoothEnabled"] | (uint8_t)0, 2, 0);
for (auto& e : s.bleKeyMap) e = CrossPointSettings::BleKeyMapEntry{}; // reset to empty
JsonArrayConst bleMap = doc["bleKeyMap"];
if (!bleMap.isNull()) {
uint8_t slot = 0;
for (JsonObjectConst o : bleMap) {
if (slot >= CrossPointSettings::BLE_MAP_CAPACITY) break;
const uint8_t button = o["b"] | (uint8_t)0xFF;
if (button >= MappedInputManager::kButtonCount) continue; // drop invalid mappings
s.bleKeyMap[slot].keyKind = o["k"] | (uint8_t)0xFF;
s.bleKeyMap[slot].keyValue = o["v"] | (uint8_t)0;
s.bleKeyMap[slot].button = button;
slot++;
}
}
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
const uint8_t storedFontFamily = doc["fontFamily"] | (uint8_t)0;
s.fontFamily = clamp(storedFontFamily, CrossPointSettings::BUILTIN_FONT_COUNT, 0);
+99 -3
View File
@@ -1,7 +1,9 @@
#include "MappedInputManager.h"
#include <GfxRenderer.h>
#include <Logging.h>
#include "BleInput.h"
#include "CrossPointSettings.h"
bool MappedInputManager::isNavDirectionSwapped() const {
@@ -74,11 +76,105 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
return false;
}
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
bool MappedInputManager::bleEdge(const bool* arr, const Button button) const {
// Mirror mapButton()'s composite navigation handling so a BLE key bound to a
// physical direction also satisfies the derived NavNext / NavPrevious logical
// buttons (used by list navigation), respecting the orientation axis flip.
switch (button) {
case Button::NavNext:
return isNavDirectionSwapped() ? (arr[(int)Button::Up] || arr[(int)Button::Left])
: (arr[(int)Button::Down] || arr[(int)Button::Right]);
case Button::NavPrevious:
return isNavDirectionSwapped() ? (arr[(int)Button::Down] || arr[(int)Button::Right])
: (arr[(int)Button::Up] || arr[(int)Button::Left]);
default:
return arr[(int)button];
}
}
bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
bool MappedInputManager::wasPressed(const Button button) const {
return mapButton(button, &HalGPIO::wasPressed) || bleEdge(blePressEdge, button);
}
bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
bool MappedInputManager::wasReleased(const Button button) const {
return mapButton(button, &HalGPIO::wasReleased) || bleEdge(bleReleaseEdge, button);
}
bool MappedInputManager::isPressed(const Button button) const {
// A BLE tap is momentary: report "pressed" only on the press-edge frame.
return mapButton(button, &HalGPIO::isPressed) || bleEdge(blePressEdge, button);
}
void MappedInputManager::setBleCaptureMode(const bool on) {
bleCaptureMode = on;
bleHasCaptured = false;
if (on) {
// Clear any stale overlay so a held remote key doesn't leak into the UI.
for (uint8_t i = 0; i < kButtonCount; i++) {
blePressEdge[i] = false;
bleReleaseEdge[i] = false;
}
}
}
bool MappedInputManager::takeCapturedBleKey(uint8_t& kind, uint8_t& value) {
if (!bleHasCaptured) return false;
kind = bleCapturedKind;
value = bleCapturedValue;
bleHasCaptured = false;
return true;
}
void MappedInputManager::pollBle() {
bleActivityThisFrame = false;
// Age last frame's press edges into this frame's release edges (the FreeInk host
// surfaces presses + synthetic repeats but never releases), then clear presses.
for (uint8_t i = 0; i < kButtonCount; i++) {
bleReleaseEdge[i] = blePressEdge[i];
blePressEdge[i] = false;
}
freeink::KeyEvent ev;
while (BleHid.popKey(ev)) {
uint8_t kind = 0xFF;
uint8_t value = 0;
const bool encoded = bleinput::encodeKey(ev, kind, value);
// TEMP page-turner bring-up: log every decoded BLE key so the actual keycodes
// a remote sends are visible on serial. Remove once mapping is verified.
LOG_DBG("BLE", "key ch=%d code=0x%02X special=%u -> kind=%u val=0x%02X enc=%d", ev.ch, ev.keycode,
(unsigned)ev.special, kind, value, encoded);
if (!encoded) continue;
if (bleCaptureMode) {
bleCapturedKind = kind;
bleCapturedValue = value;
bleHasCaptured = true;
LOG_DBG("BLE", "captured (capture mode) kind=%u val=0x%02X", kind, value);
continue;
}
// Resolve the key identity against the persisted mapping table.
bool matched = false;
for (const auto& e : SETTINGS.bleKeyMap) {
if (e.button == 0xFF || e.keyKind != kind || e.keyValue != value) continue;
if (e.button < kButtonCount) {
blePressEdge[e.button] = true;
bleActivityThisFrame = true;
matched = true;
LOG_DBG("BLE", "matched -> logical button %u", e.button);
}
break;
}
if (!matched) {
LOG_DBG("BLE", "NO MATCH for kind=%u val=0x%02X; current map:", kind, value);
for (uint8_t i = 0; i < CrossPointSettings::BLE_MAP_CAPACITY; i++) {
const auto& e = SETTINGS.bleKeyMap[i];
if (e.button == 0xFF) continue;
LOG_DBG("BLE", " slot %u: kind=%u val=0x%02X -> button %u", i, e.keyKind, e.keyValue, e.button);
}
}
}
}
bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
+33
View File
@@ -7,6 +7,9 @@ class GfxRenderer;
class MappedInputManager {
public:
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward, NavNext, NavPrevious };
// Number of values in Button (Back..NavPrevious). Used to size the BLE overlay and
// to clamp persisted BLE mappings. Keep in sync with the enum above.
static constexpr uint8_t kButtonCount = 11;
struct Labels {
const char* btn1;
@@ -28,6 +31,23 @@ class MappedInputManager {
// Returns the raw front button index that was pressed this frame (or -1 if none).
int getPressedFrontButton() const;
// --- BLE page-turner overlay -------------------------------------------------
// Drain decoded key events from the FreeInk BLE HID host and translate the ones
// bound in SETTINGS.bleKeyMap into per-frame logical-button edges that OR into
// wasPressed()/isPressed()/wasReleased(). Call once per main-loop iteration,
// right after gpio.update() and BleHid.poll(). No-ops when BLE is compiled out.
void pollBle();
// True when a mapped BLE key produced an edge this frame — keeps the inactivity
// / auto-sleep timer alive while a remote is the only input device in use.
bool bleHadActivityThisFrame() const { return bleActivityThisFrame; }
// Capture mode: while on, pollBle() stops mapping events and instead stashes the
// raw decoded key identity so the button-mapping UI can read it without racing the
// live mapping over the single popKey() queue.
void setBleCaptureMode(bool on);
// Pop a captured (kind, value) key identity grabbed while in capture mode.
// Returns false when nothing has been captured since the last call.
bool takeCapturedBleKey(uint8_t& kind, uint8_t& value);
// True when the control axis is flipped relative to the physical buttons: the user opted into
// orientation-following front buttons AND the screen is *currently rendered* rotated (INVERTED /
// LANDSCAPE_CCW). Keyed on the live renderer orientation rather than the persisted reader setting,
@@ -44,4 +64,17 @@ class MappedInputManager {
const GfxRenderer& renderer;
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
// OR-in the BLE overlay for a logical button, mirroring mapButton()'s composite
// handling of NavNext/NavPrevious so a remote key bound to Up/Down/Left/Right also
// drives list navigation.
bool bleEdge(const bool* arr, Button button) const;
// Per-frame BLE overlay, indexed by (uint8_t)Button.
bool blePressEdge[kButtonCount] = {}; // press edge this frame -> wasPressed / isPressed
bool bleReleaseEdge[kButtonCount] = {}; // release edge this frame -> wasReleased
bool bleActivityThisFrame = false;
bool bleCaptureMode = false;
bool bleHasCaptured = false;
uint8_t bleCapturedKind = 0xFF;
uint8_t bleCapturedValue = 0;
};
@@ -3,6 +3,8 @@
#include <GfxRenderer.h>
#include <I18n.h>
#include "BleInput.h"
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
@@ -22,7 +24,7 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes,
bool hasBookmarks) {
std::vector<MenuItem> items;
items.reserve(12);
items.reserve(13);
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
if (hasFootnotes) {
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
@@ -36,6 +38,7 @@ std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuI
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
items.push_back({MenuAction::SCREENSHOT, StrId::STR_SCREENSHOT_BUTTON});
items.push_back({MenuAction::DISPLAY_QR, StrId::STR_DISPLAY_QR});
items.push_back({MenuAction::TOGGLE_BLUETOOTH, StrId::STR_TOGGLE_BLUETOOTH});
items.push_back({MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON});
items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS});
items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE});
@@ -76,6 +79,19 @@ void EpubReaderMenuActivity::loop() {
return;
}
if (selectedAction == MenuAction::TOGGLE_BLUETOOTH) {
// Toggle in place and stay in the menu (no reader re-render needed).
SETTINGS.bluetoothEnabled = SETTINGS.bluetoothEnabled ? 0 : 1;
if (SETTINGS.bluetoothEnabled) {
bleinput::ensureStarted();
} else {
bleinput::stop();
}
SETTINGS.saveToFile();
requestUpdate();
return;
}
setResult(MenuResult{static_cast<int>(selectedAction), pendingOrientation, selectedPageTurnOption});
finish();
return;
@@ -125,6 +141,9 @@ void EpubReaderMenuActivity::render(RenderLock&&) {
} else if (value == MenuAction::AUTO_PAGE_TURN) {
// Render current page turn value on the right edge of the content area.
return pageTurnLabels[selectedPageTurnOption];
} else if (value == MenuAction::TOGGLE_BLUETOOTH) {
// Render current Bluetooth on/off state on the right edge.
return SETTINGS.bluetoothEnabled ? tr(STR_STATE_ON) : tr(STR_STATE_OFF);
} else {
return "";
}
@@ -23,7 +23,8 @@ class EpubReaderMenuActivity final : public Activity {
DISPLAY_QR,
GO_HOME,
SYNC,
DELETE_CACHE
DELETE_CACHE,
TOGGLE_BLUETOOTH
};
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
@@ -0,0 +1,158 @@
#include "BleButtonMapActivity.h"
#include <GfxRenderer.h>
#include <cstdio>
#include "BleInput.h"
#include "CrossPointSettings.h"
#include "components/UITheme.h"
#include "fontIds.h"
// Logical functions offered for binding. Page navigation + confirm cover Free2 /
// Free3; the directions are included so a remote can also drive menu navigation.
const BleButtonMapActivity::Fn BleButtonMapActivity::kFunctions[] = {
{MappedInputManager::Button::PageForward, StrId::STR_BT_PAGE_FORWARD},
{MappedInputManager::Button::PageBack, StrId::STR_BT_PAGE_BACK},
{MappedInputManager::Button::Confirm, StrId::STR_CONFIRM},
{MappedInputManager::Button::Back, StrId::STR_BACK},
{MappedInputManager::Button::Up, StrId::STR_DIR_UP},
{MappedInputManager::Button::Down, StrId::STR_DIR_DOWN},
{MappedInputManager::Button::Left, StrId::STR_DIR_LEFT},
{MappedInputManager::Button::Right, StrId::STR_DIR_RIGHT},
};
const uint8_t BleButtonMapActivity::kFunctionCount =
static_cast<uint8_t>(sizeof(kFunctions) / sizeof(kFunctions[0]));
void BleButtonMapActivity::onEnter() {
Activity::onEnter();
step = Step::WaitForKey;
capturedKind = 0xFF;
functionIndex = 0;
mappedInput.setBleCaptureMode(true);
requestUpdate();
}
void BleButtonMapActivity::onExit() {
mappedInput.setBleCaptureMode(false);
Activity::onExit();
}
bool BleButtonMapActivity::assignCapturedKey(MappedInputManager::Button button) {
const uint8_t btn = static_cast<uint8_t>(button);
// Update an existing binding for this key, if present.
for (auto& e : SETTINGS.bleKeyMap) {
if (e.button != 0xFF && e.keyKind == capturedKind && e.keyValue == capturedValue) {
e.button = btn;
SETTINGS.saveToFile();
return true;
}
}
// Otherwise take a free slot.
for (auto& e : SETTINGS.bleKeyMap) {
if (e.button == 0xFF || e.keyKind == 0xFF) {
e.keyKind = capturedKind;
e.keyValue = capturedValue;
e.button = btn;
SETTINGS.saveToFile();
return true;
}
}
return false; // table full
}
void BleButtonMapActivity::loop() {
// Front Back button exits the mapping screen at any step.
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
finish();
return;
}
if (step == Step::WaitForKey) {
uint8_t kind = 0xFF;
uint8_t value = 0;
if (mappedInput.takeCapturedBleKey(kind, value)) {
capturedKind = kind;
capturedValue = value;
functionIndex = 0;
step = Step::SelectFunction;
requestUpdate();
}
return;
}
// Step::SelectFunction — pick a logical function for the captured key.
buttonNavigator.onNext([this] {
functionIndex = ButtonNavigator::nextIndex(functionIndex, kFunctionCount);
requestUpdate();
});
buttonNavigator.onPrevious([this] {
functionIndex = ButtonNavigator::previousIndex(functionIndex, kFunctionCount);
requestUpdate();
});
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (!assignCapturedKey(kFunctions[functionIndex].button)) {
// Table full: surface it instead of silently dropping the binding.
errorUntil = millis() + 2500;
}
// Back to capturing so the user can map the next remote button.
step = Step::WaitForKey;
capturedKind = 0xFF;
requestUpdate();
}
}
void BleButtonMapActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_BT_MAP_BUTTONS));
const int topOffset = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - topOffset - metrics.buttonHintsHeight - metrics.verticalSpacing;
if (step == Step::WaitForKey) {
GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight},
tr(STR_BT_PRESS_REMOTE));
// Show the current mappings so the user sees progress.
int row = 0;
for (const auto& e : SETTINGS.bleKeyMap) {
if (e.button == 0xFF) continue;
char keyName[24];
bleinput::describeKey(e.keyKind, e.keyValue, keyName, sizeof(keyName));
const char* fnName = "";
for (uint8_t i = 0; i < kFunctionCount; i++) {
if (static_cast<uint8_t>(kFunctions[i].button) == e.button) {
fnName = I18N.get(kFunctions[i].label);
break;
}
}
char line[64];
snprintf(line, sizeof(line), "%s -> %s", keyName, fnName);
GUI.drawHelpText(renderer, Rect{0, topOffset + row * 22, pageWidth, 20}, line);
row++;
}
} else {
char captured[24];
bleinput::describeKey(capturedKind, capturedValue, captured, sizeof(captured));
GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight},
captured);
GUI.drawList(
renderer, Rect{0, topOffset, pageWidth, contentHeight}, kFunctionCount, functionIndex,
[this](int i) { return std::string(I18N.get(kFunctions[i].label)); }, nullptr, nullptr, nullptr, false);
}
if (errorUntil > millis()) {
GUI.drawHelpText(renderer, Rect{0, pageHeight - metrics.buttonHintsHeight - 22, pageWidth, 20}, tr(STR_BT_MAP_FULL));
}
const char* confirm = step == Step::WaitForKey ? "" : tr(STR_SELECT);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirm, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
@@ -0,0 +1,50 @@
#pragma once
#include <I18n.h>
#include <cstdint>
#include "MappedInputManager.h"
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
// Capture-then-assign mapping for BLE page-turner buttons. The user presses a
// button on the remote; we capture its decoded key identity (via the
// MappedInputManager BLE capture mode) and let them bind it to a logical button.
// Repeat to map each remote button; Back exits. Mirrors ButtonRemapActivity's
// flow, but the input source is the BLE host instead of the front buttons.
class BleButtonMapActivity final : public Activity {
public:
explicit BleButtonMapActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("BleButtonMap", renderer, mappedInput) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
// Logical functions a remote button can be bound to.
struct Fn {
MappedInputManager::Button button;
StrId label;
};
static const Fn kFunctions[];
static const uint8_t kFunctionCount;
enum class Step { WaitForKey, SelectFunction };
Step step = Step::WaitForKey;
uint8_t capturedKind = 0xFF;
uint8_t capturedValue = 0;
int functionIndex = 0;
// Transient "mapping table full" banner.
unsigned long errorUntil = 0;
ButtonNavigator buttonNavigator;
// Bind the captured key to the chosen logical button in SETTINGS.bleKeyMap and
// persist. Returns false when the table is full and the key is new.
bool assignCapturedKey(MappedInputManager::Button button);
};
@@ -0,0 +1,313 @@
#include "BluetoothSettingsActivity.h"
#include <BleKeyboardHost.h>
#include <GfxRenderer.h>
#include <cstdio>
#include "BleButtonMapActivity.h"
#include "BleInput.h"
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
constexpr unsigned long kBannerMs = 2000;
constexpr uint32_t kScanMs = 8000;
constexpr unsigned long kForgetHoldMs = 1200; // hold Confirm this long in the Paired view to forget
} // namespace
void BluetoothSettingsActivity::onEnter() {
Activity::onEnter();
view = View::Menu;
menuIndex = 0;
rebuildMenuRows();
requestUpdate();
}
void BluetoothSettingsActivity::onExit() {
if (BleHid.isScanning()) BleHid.stopScan();
Activity::onExit();
}
void BluetoothSettingsActivity::setBanner(const char* text) {
banner = text ? text : "";
bannerUntil = millis() + kBannerMs;
}
void BluetoothSettingsActivity::rebuildMenuRows() {
menuRows.clear();
menuRows.reserve(8);
menuRows.push_back({Action::ToggleBt, StrId::STR_BLUETOOTH});
if (SETTINGS.bluetoothEnabled) {
menuRows.push_back({Action::Scan, StrId::STR_BT_SCAN_PAIR});
if (BleHid.isConnected()) menuRows.push_back({Action::Disconnect, StrId::STR_BT_DISCONNECT});
menuRows.push_back({Action::PairedDevices, StrId::STR_BT_PAIRED_DEVICES});
menuRows.push_back({Action::MapButtons, StrId::STR_BT_MAP_BUTTONS});
menuRows.push_back({Action::PresetFree2, StrId::STR_BT_PRESET_FREE2});
menuRows.push_back({Action::PresetFree3, StrId::STR_BT_PRESET_FREE3});
menuRows.push_back({Action::ClearMap, StrId::STR_BT_CLEAR_MAP});
}
if (menuIndex >= static_cast<int>(menuRows.size())) menuIndex = 0;
}
void BluetoothSettingsActivity::applyPreset(bool free3) {
// Starter presets. Page-turner remotes commonly emit PageUp/PageDown (and a
// center key on the 3-button Free3); the user can re-map via "Map Remote
// Buttons" if their device sends different codes.
using Btn = MappedInputManager::Button;
for (auto& e : SETTINGS.bleKeyMap) e = CrossPointSettings::BleKeyMapEntry{};
auto set = [&](int slot, freeink::SpecialKey key, Btn button) {
SETTINGS.bleKeyMap[slot].keyKind = 0; // SpecialKey
SETTINGS.bleKeyMap[slot].keyValue = static_cast<uint8_t>(key);
SETTINGS.bleKeyMap[slot].button = static_cast<uint8_t>(button);
};
set(0, freeink::SpecialKey::PageDown, Btn::PageForward);
set(1, freeink::SpecialKey::PageUp, Btn::PageBack);
if (free3) set(2, freeink::SpecialKey::Enter, Btn::Confirm);
SETTINGS.saveToFile();
}
void BluetoothSettingsActivity::startScanView() {
view = View::Scan;
scanIndex = 0;
awaitingConnect = false;
BleHid.startScan(kScanMs);
requestUpdate();
}
void BluetoothSettingsActivity::handleMenuConfirm() {
if (menuRows.empty()) return;
const Action action = menuRows[menuIndex].action;
switch (action) {
case Action::ToggleBt:
SETTINGS.bluetoothEnabled = SETTINGS.bluetoothEnabled ? 0 : 1;
if (SETTINGS.bluetoothEnabled) {
bleinput::ensureStarted();
} else {
bleinput::stop();
}
SETTINGS.saveToFile();
rebuildMenuRows();
requestUpdate();
break;
case Action::Scan:
startScanView();
break;
case Action::Disconnect:
BleHid.disconnect();
setBanner(tr(STR_BT_NOT_CONNECTED));
rebuildMenuRows();
requestUpdate();
break;
case Action::PairedDevices:
view = View::Paired;
pairedIndex = 0;
requestUpdate();
break;
case Action::MapButtons:
startActivityForResult(std::make_unique<BleButtonMapActivity>(renderer, mappedInput),
[this](const ActivityResult&) {
rebuildMenuRows();
requestUpdate();
});
break;
case Action::PresetFree2:
applyPreset(false);
setBanner(tr(STR_BT_PRESET_FREE2));
requestUpdate();
break;
case Action::PresetFree3:
applyPreset(true);
setBanner(tr(STR_BT_PRESET_FREE3));
requestUpdate();
break;
case Action::ClearMap:
for (auto& e : SETTINGS.bleKeyMap) e = CrossPointSettings::BleKeyMapEntry{};
SETTINGS.saveToFile();
setBanner(tr(STR_BT_CLEAR_MAP));
requestUpdate();
break;
}
}
void BluetoothSettingsActivity::loop() {
// Clear an expired status banner.
if (bannerUntil > 0 && millis() > bannerUntil) {
banner.clear();
bannerUntil = 0;
requestUpdate();
}
// Watch for an async connect result (from either the scan list or the paired list).
if (awaitingConnect) {
char reason[48];
if (BleHid.isConnected()) {
awaitingConnect = false;
BleHid.releaseScanResults();
view = View::Menu;
rebuildMenuRows();
char buf[64];
snprintf(buf, sizeof(buf), tr(STR_BT_CONNECTED_TO), BleHid.connectedName());
setBanner(buf);
requestUpdate();
} else if (BleHid.takeConnectFailure(reason, sizeof(reason))) {
awaitingConnect = false;
setBanner(reason);
requestUpdate();
}
}
// Back returns to the menu from a sub-view, or leaves the screen from the menu.
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
if (view == View::Menu) {
finish();
} else {
if (BleHid.isScanning()) BleHid.stopScan();
view = View::Menu;
rebuildMenuRows();
requestUpdate();
}
return;
}
// Navigation within the active list.
const int count = view == View::Menu ? static_cast<int>(menuRows.size())
: view == View::Scan ? BleHid.deviceCount()
: BleHid.pairedCount();
int* idx = view == View::Menu ? &menuIndex : view == View::Scan ? &scanIndex : &pairedIndex;
buttonNavigator.onNext([this, count, idx] {
if (count > 0) *idx = ButtonNavigator::nextIndex(*idx, count);
requestUpdate();
});
buttonNavigator.onPrevious([this, count, idx] {
if (count > 0) *idx = ButtonNavigator::previousIndex(*idx, count);
requestUpdate();
});
// Paired view: tap Confirm to connect, hold Confirm to forget. Uses release for
// connect so a hold can fire forget without also connecting on the same press.
if (view == View::Paired) {
if (mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
if (!pairedActionTaken && mappedInput.getHeldTime() >= kForgetHoldMs && pairedIndex < BleHid.pairedCount()) {
const auto& p = BleHid.paired(static_cast<uint8_t>(pairedIndex));
BleHid.forget(p.addr);
if (pairedIndex > 0) pairedIndex--;
setBanner(tr(STR_FORGET_BUTTON));
pairedActionTaken = true;
rebuildMenuRows();
requestUpdate();
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!pairedActionTaken && !awaitingConnect && pairedIndex < BleHid.pairedCount()) {
const auto& p = BleHid.paired(static_cast<uint8_t>(pairedIndex));
awaitingConnect = true;
setBanner(tr(STR_CONNECTING));
BleHid.connect(p.addr);
requestUpdate();
}
pairedActionTaken = false;
}
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (view == View::Menu) {
handleMenuConfirm();
} else if (view == View::Scan) {
if (!awaitingConnect && scanIndex < BleHid.deviceCount()) {
if (BleHid.isScanning()) BleHid.stopScan();
const auto& d = BleHid.device(static_cast<uint8_t>(scanIndex));
awaitingConnect = true;
setBanner(tr(STR_CONNECTING));
BleHid.connect(d.addr);
requestUpdate();
}
}
return;
}
// The scan list changes as devices are discovered — keep repainting while active.
if (view == View::Scan && BleHid.isScanning()) requestUpdate();
}
std::string BluetoothSettingsActivity::deviceLabel(int index) const {
if (index >= BleHid.deviceCount()) return "";
const auto& d = BleHid.device(static_cast<uint8_t>(index));
return std::string(d.name);
}
std::string BluetoothSettingsActivity::pairedLabel(int index) const {
if (index >= BleHid.pairedCount()) return "";
const auto& p = BleHid.paired(static_cast<uint8_t>(index));
return std::string(p.name);
}
void BluetoothSettingsActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const char* title = tr(STR_BLUETOOTH);
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, title);
// Sub-header: connection status.
const char* status = BleHid.isConnected() ? BleHid.connectedName() : tr(STR_BT_NOT_CONNECTED);
GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight},
status);
const int topOffset = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - topOffset - metrics.buttonHintsHeight - metrics.verticalSpacing;
const Rect listRect{0, topOffset, pageWidth, contentHeight};
if (view == View::Menu) {
GUI.drawList(
renderer, listRect, static_cast<int>(menuRows.size()), menuIndex,
[this](int i) { return std::string(I18N.get(menuRows[i].label)); }, nullptr, nullptr,
[this](int i) -> std::string {
if (menuRows[i].action == Action::ToggleBt) return SETTINGS.bluetoothEnabled ? tr(STR_STATE_ON) : tr(STR_STATE_OFF);
return "";
},
true);
} else if (view == View::Scan) {
const int count = BleHid.deviceCount();
if (count == 0) {
GUI.drawHelpText(renderer, Rect{0, topOffset + metrics.verticalSpacing, 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);
}
} else { // Paired
const int count = BleHid.pairedCount();
if (count == 0) {
GUI.drawHelpText(renderer, Rect{0, topOffset + metrics.verticalSpacing, pageWidth, 24}, tr(STR_BT_NO_PAIRED));
} else {
GUI.drawList(
renderer, listRect, count, pairedIndex, [this](int i) { return pairedLabel(i); }, nullptr, nullptr, nullptr,
false);
}
}
// Transient banner above the hints.
if (!banner.empty()) {
GUI.drawHelpText(renderer, Rect{0, pageHeight - metrics.buttonHintsHeight - 22, pageWidth, 20}, banner.c_str());
}
// In the paired list, Confirm connects and a hold forgets — surface the hold hint.
if (view == View::Paired && BleHid.pairedCount() > 0 && banner.empty()) {
GUI.drawHelpText(renderer, Rect{0, pageHeight - metrics.buttonHintsHeight - 22, pageWidth, 20},
tr(STR_BT_FORGET_PROMPT));
}
// Button hints differ by view (Menu selects; Scan and Paired both connect).
const char* confirm = view == View::Menu ? tr(STR_SELECT) : tr(STR_CONNECT);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirm, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
@@ -0,0 +1,63 @@
#pragma once
#include <I18n.h>
#include <string>
#include <vector>
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
// Bluetooth page-turner settings. One screen with three views:
// Menu — enable/disable BT, scan & pair, disconnect, map buttons, presets.
// Scan — live list of discovered BLE HID devices; Confirm connects.
// Paired — bonded devices; Confirm forgets the selected one.
// All BLE access goes through the FreeInk BleHid singleton; everything no-ops
// gracefully when BLE is compiled out (BleHid.begin() returns false).
class BluetoothSettingsActivity final : public Activity {
public:
explicit BluetoothSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("BluetoothSettings", renderer, mappedInput) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
enum class View { Menu, Scan, Paired };
// Menu row actions.
enum class Action { ToggleBt, Scan, Disconnect, MapButtons, PairedDevices, PresetFree2, PresetFree3, ClearMap };
struct MenuRow {
Action action;
StrId label;
};
View view = View::Menu;
std::vector<MenuRow> menuRows;
int menuIndex = 0;
int scanIndex = 0;
int pairedIndex = 0;
ButtonNavigator buttonNavigator;
// Transient status banner (connect result, forget confirmation, etc.).
std::string banner;
unsigned long bannerUntil = 0;
// Set when a connect() has been issued and we're waiting for the async result.
bool awaitingConnect = false;
// Guards the Paired view's hold-to-forget so it fires once per hold and suppresses
// the tap-to-connect on the same press.
bool pairedActionTaken = false;
void rebuildMenuRows();
void handleMenuConfirm();
void startScanView();
void applyPreset(bool free3);
void setBanner(const char* text);
std::string deviceLabel(int index) const; // scan list row text
std::string pairedLabel(int index) const; // paired list row text
};
@@ -7,6 +7,7 @@
#include <cstdio>
#include <cstring>
#include "BluetoothSettingsActivity.h"
#include "ButtonRemapActivity.h"
#include "ClearCacheActivity.h"
#include "CrossPointSettings.h"
@@ -59,6 +60,7 @@ void SettingsActivity::rebuildSettingsLists() {
// Append device-only ACTION items
controlsSettings.insert(controlsSettings.begin(),
SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons));
controlsSettings.push_back(SettingInfo::Action(StrId::STR_BLUETOOTH, SettingAction::Bluetooth));
systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network));
systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync));
systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser));
@@ -264,6 +266,9 @@ void SettingsActivity::toggleCurrentSetting() {
case SettingAction::Language:
startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);
break;
case SettingAction::Bluetooth:
startActivityForResult(std::make_unique<BluetoothSettingsActivity>(renderer, mappedInput), resultHandler);
break;
case SettingAction::None:
// Do nothing
break;
@@ -23,6 +23,7 @@ enum class SettingAction {
SdFirmwareUpdate,
Language,
DownloadFonts,
Bluetooth,
};
struct SettingInfo {
+23 -2
View File
@@ -18,6 +18,7 @@
#include <cstring>
#include "BleInput.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "KOReaderCredentialStore.h"
@@ -262,6 +263,10 @@ void enterDeepSleep(bool fromTimeout = false) {
WiFi.mode(WIFI_OFF);
}
// Drop any BLE HID link cleanly so the page-turner sees the disconnect promptly.
// bluetoothEnabled persists, so the setting is restored on the next wake.
bleinput::stop();
halTiltSensor.deepSleep();
display.deepSleep();
LOG_DBG("MAIN", "Entering deep sleep");
@@ -478,6 +483,15 @@ void setup() {
// Ensure we're not still holding the power button before leaving setup
waitForPowerRelease();
allowSleepAt = millis() + 2000;
// 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) {
bleinput::ensureStarted();
}
}
void loop() {
@@ -486,6 +500,8 @@ void loop() {
static unsigned long lastMemPrint = 0;
gpio.update();
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());
renderer.setFadingFix(SETTINGS.fadingFix);
@@ -516,7 +532,7 @@ void loop() {
// Check for any user activity (button press or release) or active background work
static unsigned long lastActivityTime = millis();
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || halTiltSensor.hadActivity() ||
activityManager.preventAutoSleep()) {
mappedInputManager.bleHadActivityThisFrame() || activityManager.preventAutoSleep()) {
lastActivityTime = millis(); // Reset inactivity timer
powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity
}
@@ -597,11 +613,16 @@ void loop() {
powerManager.setPowerSaving(false); // Make sure we're at full performance when skipLoopDelay is requested
yield(); // Give FreeRTOS a chance to run tasks, but return immediately
} else {
if (millis() - lastActivityTime >= HalPowerManager::IDLE_POWER_SAVING_MS) {
// 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) {
// 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
// Short delay to prevent tight loop while still being responsive
delay(10);
}