From 9ab0b0bfb74983cec1392d933d32f394e69ca8b4 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 24 Jun 2026 15:36:29 -0400 Subject: [PATCH] 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. --- .gitmodules | 6 +- freeink-sdk | 1 + lib/I18n/translations/english.yaml | 20 ++ open-x4-sdk | 1 - platformio.ini | 23 +- src/BleInput.cpp | 89 +++++ src/BleInput.h | 41 +++ src/CrossPointSettings.h | 16 + src/JsonSettingsIO.cpp | 28 ++ src/MappedInputManager.cpp | 102 +++++- src/MappedInputManager.h | 33 ++ .../reader/EpubReaderMenuActivity.cpp | 21 +- .../reader/EpubReaderMenuActivity.h | 3 +- .../settings/BleButtonMapActivity.cpp | 158 +++++++++ .../settings/BleButtonMapActivity.h | 50 +++ .../settings/BluetoothSettingsActivity.cpp | 313 ++++++++++++++++++ .../settings/BluetoothSettingsActivity.h | 63 ++++ src/activities/settings/SettingsActivity.cpp | 5 + src/activities/settings/SettingsActivity.h | 1 + src/main.cpp | 25 +- 20 files changed, 984 insertions(+), 15 deletions(-) create mode 160000 freeink-sdk delete mode 160000 open-x4-sdk create mode 100644 src/BleInput.cpp create mode 100644 src/BleInput.h create mode 100644 src/activities/settings/BleButtonMapActivity.cpp create mode 100644 src/activities/settings/BleButtonMapActivity.h create mode 100644 src/activities/settings/BluetoothSettingsActivity.cpp create mode 100644 src/activities/settings/BluetoothSettingsActivity.h diff --git a/.gitmodules b/.gitmodules index 80308f05..dd6c0b1d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/freeink-sdk b/freeink-sdk new file mode 160000 index 00000000..b0269658 --- /dev/null +++ b/freeink-sdk @@ -0,0 +1 @@ +Subproject commit b02696589faaf2934689fdcede401bc2d06d312d diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 96ebbdd3..3a59e618 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -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" diff --git a/open-x4-sdk b/open-x4-sdk deleted file mode 160000 index 198ad267..00000000 --- a/open-x4-sdk +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 198ad267219c25c8ab84418b806c66f1fb5216a3 diff --git a/platformio.ini b/platformio.ini index abb5e322..b35a3875 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/src/BleInput.cpp b/src/BleInput.cpp new file mode 100644 index 00000000..5204eaf1 --- /dev/null +++ b/src/BleInput.cpp @@ -0,0 +1,89 @@ +#include "BleInput.h" + +#include + +#include +#include + +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(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(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(value)); +} + +} // namespace bleinput diff --git a/src/BleInput.h b/src/BleInput.h new file mode 100644 index 00000000..d32764c5 --- /dev/null +++ b/src/BleInput.h @@ -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 + +#include + +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 diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index af274e3c..73410c83 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -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; diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 6f0a51d2..233d4bb0 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -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(); + for (const auto& e : s.bleKeyMap) { + if (e.keyKind == 0xFF || e.button == 0xFF) continue; // skip empty/unassigned slots + JsonObject o = bleMap.add(); + 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); diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index f8b5f0cc..13a49068 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -1,7 +1,9 @@ #include "MappedInputManager.h" #include +#include +#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(); } diff --git a/src/MappedInputManager.h b/src/MappedInputManager.h index 89e6b862..312b6f51 100644 --- a/src/MappedInputManager.h +++ b/src/MappedInputManager.h @@ -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; }; diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 69e1fccd..12608743 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -3,6 +3,8 @@ #include #include +#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::buildMenuItems(bool hasFootnotes, bool hasBookmarks) { std::vector 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::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(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 ""; } diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 285ab76e..5482dfd3 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -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, diff --git a/src/activities/settings/BleButtonMapActivity.cpp b/src/activities/settings/BleButtonMapActivity.cpp new file mode 100644 index 00000000..154a4480 --- /dev/null +++ b/src/activities/settings/BleButtonMapActivity.cpp @@ -0,0 +1,158 @@ +#include "BleButtonMapActivity.h" + +#include + +#include + +#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(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(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(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(); +} diff --git a/src/activities/settings/BleButtonMapActivity.h b/src/activities/settings/BleButtonMapActivity.h new file mode 100644 index 00000000..25673ce7 --- /dev/null +++ b/src/activities/settings/BleButtonMapActivity.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +#include + +#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); +}; diff --git a/src/activities/settings/BluetoothSettingsActivity.cpp b/src/activities/settings/BluetoothSettingsActivity.cpp new file mode 100644 index 00000000..06204f47 --- /dev/null +++ b/src/activities/settings/BluetoothSettingsActivity.cpp @@ -0,0 +1,313 @@ +#include "BluetoothSettingsActivity.h" + +#include +#include + +#include + +#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(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(key); + SETTINGS.bleKeyMap[slot].button = static_cast(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(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(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(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(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(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(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(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(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(); +} diff --git a/src/activities/settings/BluetoothSettingsActivity.h b/src/activities/settings/BluetoothSettingsActivity.h new file mode 100644 index 00000000..af9abb51 --- /dev/null +++ b/src/activities/settings/BluetoothSettingsActivity.h @@ -0,0 +1,63 @@ +#pragma once + +#include + +#include +#include + +#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 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 +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 8c0a7ef6..8c541042 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -7,6 +7,7 @@ #include #include +#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(renderer, mappedInput), resultHandler); break; + case SettingAction::Bluetooth: + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + break; case SettingAction::None: // Do nothing break; diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 5ec639a0..3742b4d7 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -23,6 +23,7 @@ enum class SettingAction { SdFirmwareUpdate, Language, DownloadFonts, + Bluetooth, }; struct SettingInfo { diff --git a/src/main.cpp b/src/main.cpp index 13ba129b..9016d9b2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,6 +18,7 @@ #include +#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); }