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:
+3
-3
@@ -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
@@ -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"
|
||||
|
||||
-1
Submodule open-x4-sdk deleted from 198ad26721
+19
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user