Compare commits
25
Commits
develop
...
cecdbefa0e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cecdbefa0e | ||
|
|
4c5fd653c0 | ||
|
|
05c1e9aa46 | ||
|
|
f3da5e4f06 | ||
|
|
6f0707e83c | ||
|
|
7b6df60a54 | ||
|
|
1c13913713 | ||
|
|
613371b716 | ||
|
|
0d7a84e3c5 | ||
|
|
4c93950771 | ||
|
|
1d01eebf55 | ||
|
|
9bf6e5f43c | ||
|
|
ad3138983b | ||
|
|
793685e76b | ||
|
|
551d29744a | ||
|
|
74a0969cc6 | ||
|
|
06aa1ac2f7 | ||
|
|
c463da994e | ||
|
|
988652513a | ||
|
|
28d0d6f5d2 | ||
|
|
568831f232 | ||
|
|
d6f5be6b7a | ||
|
|
d30fde2f4d | ||
|
|
6305777b22 | ||
|
|
9ab0b0bfb7 |
@@ -23,3 +23,9 @@ lib/EpdFont/scripts/output/
|
|||||||
# (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out.
|
# (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out.
|
||||||
.claude/*
|
.claude/*
|
||||||
!.claude/skills/
|
!.claude/skills/
|
||||||
|
/managed_components
|
||||||
|
/.dummy
|
||||||
|
dependencies.lock
|
||||||
|
sdkconfig.default
|
||||||
|
sdkconfig.defaults
|
||||||
|
CMakeLists.txt
|
||||||
|
|||||||
+1
-1
Submodule freeink-sdk updated: f611d71d8e...2442e1d672
+44
-17
@@ -68,6 +68,22 @@ bool collectUniqueCodepoints(const char* text, uint32_t* codepoints, uint32_t& c
|
|||||||
const char* asCStr(const std::string& s) { return s.c_str(); }
|
const char* asCStr(const std::string& s) { return s.c_str(); }
|
||||||
const char* asCStr(const char* s) { return s; }
|
const char* asCStr(const char* s) { return s; }
|
||||||
|
|
||||||
|
// Keep-if-fits buffer reuse: only reallocate when the needed size exceeds the
|
||||||
|
// current capacity. Freeing + reallocating slightly different sizes every page
|
||||||
|
// turn punches non-coalescing holes in the heap (the freed block rarely fits the
|
||||||
|
// next page's need), eroding the largest contiguous block all session. With
|
||||||
|
// reuse, capacities converge on the book's max page after a few turns and page
|
||||||
|
// turns stop touching the allocator. Only three small instantiations exist
|
||||||
|
// (interval/glyph/byte arrays), so template bloat is negligible.
|
||||||
|
template <typename T, typename CapT>
|
||||||
|
bool ensureArrayCapacity(T*& buf, CapT& capacity, const uint32_t needed) {
|
||||||
|
if (buf && capacity >= needed) return true;
|
||||||
|
delete[] buf;
|
||||||
|
buf = new (std::nothrow) T[needed > 0 ? needed : 1];
|
||||||
|
capacity = buf ? static_cast<CapT>(needed) : 0;
|
||||||
|
return buf != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
SdCardFont::~SdCardFont() { freeAll(); }
|
SdCardFont::~SdCardFont() { freeAll(); }
|
||||||
@@ -83,6 +99,9 @@ void SdCardFont::freeStyleMiniData(PerStyle& s) {
|
|||||||
s.miniBitmap = nullptr;
|
s.miniBitmap = nullptr;
|
||||||
s.miniIntervalCount = 0;
|
s.miniIntervalCount = 0;
|
||||||
s.miniGlyphCount = 0;
|
s.miniGlyphCount = 0;
|
||||||
|
s.miniIntervalCapacity = 0;
|
||||||
|
s.miniGlyphCapacity = 0;
|
||||||
|
s.miniBitmapCapacity = 0;
|
||||||
freeStyleMiniKern(s);
|
freeStyleMiniKern(s);
|
||||||
memset(&s.miniData, 0, sizeof(s.miniData));
|
memset(&s.miniData, 0, sizeof(s.miniData));
|
||||||
s.epdFont.data = &s.stubData;
|
s.epdFont.data = &s.stubData;
|
||||||
@@ -109,6 +128,9 @@ void SdCardFont::freeStyleMiniKern(PerStyle& s) {
|
|||||||
s.miniKernRightEntryCount = 0;
|
s.miniKernRightEntryCount = 0;
|
||||||
s.miniKernLeftClassCount = 0;
|
s.miniKernLeftClassCount = 0;
|
||||||
s.miniKernRightClassCount = 0;
|
s.miniKernRightClassCount = 0;
|
||||||
|
s.miniKernLeftCapacity = 0;
|
||||||
|
s.miniKernRightCapacity = 0;
|
||||||
|
s.miniKernMatrixCapacity = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void SdCardFont::freeStyleAll(PerStyle& s) {
|
void SdCardFont::freeStyleAll(PerStyle& s) {
|
||||||
@@ -311,13 +333,13 @@ bool SdCardFont::buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, ui
|
|||||||
if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++;
|
if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: allocate the three mini buffers. The matrix is <1KB in practice
|
// Step 4: size the three mini buffers (reused across pages when they fit; the
|
||||||
// (<30 × <30 × 1 byte) so fragmentation is a non-issue.
|
// per-page sizes vary by a few entries, which as free+realloc churn was punching
|
||||||
|
// non-coalescing holes in the heap every page turn).
|
||||||
const uint32_t matrixBytes = static_cast<uint32_t>(numLeft) * numRight;
|
const uint32_t matrixBytes = static_cast<uint32_t>(numLeft) * numRight;
|
||||||
s.miniKernLeftClasses = new (std::nothrow) EpdKernClassEntry[miniLeftCount];
|
if (!ensureArrayCapacity(s.miniKernLeftClasses, s.miniKernLeftCapacity, miniLeftCount) ||
|
||||||
s.miniKernRightClasses = new (std::nothrow) EpdKernClassEntry[miniRightCount];
|
!ensureArrayCapacity(s.miniKernRightClasses, s.miniKernRightCapacity, miniRightCount) ||
|
||||||
s.miniKernMatrix = new (std::nothrow) int8_t[matrixBytes];
|
!ensureArrayCapacity(s.miniKernMatrix, s.miniKernMatrixCapacity, matrixBytes)) {
|
||||||
if (!s.miniKernLeftClasses || !s.miniKernRightClasses || !s.miniKernMatrix) {
|
|
||||||
LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u,
|
LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u,
|
||||||
matrixBytes);
|
matrixBytes);
|
||||||
freeStyleMiniKern(s);
|
freeStyleMiniKern(s);
|
||||||
@@ -793,12 +815,19 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
|||||||
return missed;
|
return missed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build mini intervals from sorted codepoints
|
// Build mini intervals from sorted codepoints. Reset counts and fall back to the
|
||||||
freeStyleMiniData(s);
|
// stub until the rebuild completes, but KEEP the existing buffers (keep-if-fits
|
||||||
|
// reuse) — the free-and-realloc-per-page pattern here was a primary fragmenter.
|
||||||
|
s.miniIntervalCount = 0;
|
||||||
|
s.miniGlyphCount = 0;
|
||||||
|
s.miniKernLeftEntryCount = 0;
|
||||||
|
s.miniKernRightEntryCount = 0;
|
||||||
|
s.miniKernLeftClassCount = 0;
|
||||||
|
s.miniKernRightClassCount = 0;
|
||||||
|
memset(&s.miniData, 0, sizeof(s.miniData));
|
||||||
|
s.epdFont.data = &s.stubData;
|
||||||
|
|
||||||
uint32_t intervalCapacity = validCount;
|
if (!ensureArrayCapacity(s.miniIntervals, s.miniIntervalCapacity, validCount)) {
|
||||||
s.miniIntervals = new (std::nothrow) EpdUnicodeInterval[intervalCapacity];
|
|
||||||
if (!s.miniIntervals) {
|
|
||||||
LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx);
|
LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx);
|
||||||
delete[] mappings;
|
delete[] mappings;
|
||||||
return static_cast<int>(cpCount);
|
return static_cast<int>(cpCount);
|
||||||
@@ -816,15 +845,14 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allocate mini glyph array
|
// Mini glyph array (reused across pages when it fits)
|
||||||
s.miniGlyphCount = validCount;
|
if (!ensureArrayCapacity(s.miniGlyphs, s.miniGlyphCapacity, validCount)) {
|
||||||
s.miniGlyphs = new (std::nothrow) EpdGlyph[s.miniGlyphCount];
|
|
||||||
if (!s.miniGlyphs) {
|
|
||||||
LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx);
|
LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx);
|
||||||
delete[] mappings;
|
delete[] mappings;
|
||||||
freeStyleMiniData(s);
|
freeStyleMiniData(s);
|
||||||
return static_cast<int>(cpCount);
|
return static_cast<int>(cpCount);
|
||||||
}
|
}
|
||||||
|
s.miniGlyphCount = validCount;
|
||||||
|
|
||||||
// Build sorted read order for sequential I/O
|
// Build sorted read order for sequential I/O
|
||||||
uint32_t* readOrder = new (std::nothrow) uint32_t[validCount];
|
uint32_t* readOrder = new (std::nothrow) uint32_t[validCount];
|
||||||
@@ -891,8 +919,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
|||||||
totalBitmapSize += s.miniGlyphs[i].dataLength;
|
totalBitmapSize += s.miniGlyphs[i].dataLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
s.miniBitmap = new (std::nothrow) uint8_t[totalBitmapSize > 0 ? totalBitmapSize : 1];
|
if (!ensureArrayCapacity(s.miniBitmap, s.miniBitmapCapacity, totalBitmapSize)) {
|
||||||
if (!s.miniBitmap) {
|
|
||||||
LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx);
|
LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx);
|
||||||
delete[] readOrder;
|
delete[] readOrder;
|
||||||
delete[] mappings;
|
delete[] mappings;
|
||||||
|
|||||||
@@ -163,13 +163,22 @@ class SdCardFont {
|
|||||||
// Stub EpdFontData returned when not prewarmed
|
// Stub EpdFontData returned when not prewarmed
|
||||||
EpdFontData stubData{};
|
EpdFontData stubData{};
|
||||||
|
|
||||||
// Mini EpdFontData built during prewarm
|
// Mini EpdFontData built during prewarm. Buffers are kept-if-fits across pages
|
||||||
|
// (capacities below track allocated sizes): freeing and reallocating slightly
|
||||||
|
// different sizes on every page turn was a primary heap fragmenter — each page's
|
||||||
|
// freed hole rarely fit the next page's need, so maxAlloc eroded all session.
|
||||||
|
// After a few pages the capacities converge on the book's max and page turns
|
||||||
|
// stop allocating entirely. freeStyleMiniData() still releases everything (and
|
||||||
|
// zeroes capacities) for style eviction / font unload.
|
||||||
EpdFontData miniData{};
|
EpdFontData miniData{};
|
||||||
EpdUnicodeInterval* miniIntervals = nullptr;
|
EpdUnicodeInterval* miniIntervals = nullptr;
|
||||||
EpdGlyph* miniGlyphs = nullptr;
|
EpdGlyph* miniGlyphs = nullptr;
|
||||||
uint8_t* miniBitmap = nullptr;
|
uint8_t* miniBitmap = nullptr;
|
||||||
uint32_t miniIntervalCount = 0;
|
uint32_t miniIntervalCount = 0;
|
||||||
uint32_t miniGlyphCount = 0;
|
uint32_t miniGlyphCount = 0;
|
||||||
|
uint32_t miniIntervalCapacity = 0;
|
||||||
|
uint32_t miniGlyphCapacity = 0;
|
||||||
|
uint32_t miniBitmapCapacity = 0;
|
||||||
|
|
||||||
// Per-page mini kern matrix (built by buildMiniKernMatrix on each full
|
// Per-page mini kern matrix (built by buildMiniKernMatrix on each full
|
||||||
// prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints
|
// prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints
|
||||||
@@ -184,6 +193,10 @@ class SdCardFont {
|
|||||||
uint8_t miniKernLeftClassCount = 0;
|
uint8_t miniKernLeftClassCount = 0;
|
||||||
uint8_t miniKernRightClassCount = 0;
|
uint8_t miniKernRightClassCount = 0;
|
||||||
int8_t* miniKernMatrix = nullptr;
|
int8_t* miniKernMatrix = nullptr;
|
||||||
|
// Kept-if-fits capacities, same rationale as the mini glyph buffers above.
|
||||||
|
uint16_t miniKernLeftCapacity = 0;
|
||||||
|
uint16_t miniKernRightCapacity = 0;
|
||||||
|
uint32_t miniKernMatrixCapacity = 0;
|
||||||
|
|
||||||
// The EpdFont whose data pointer we manage
|
// The EpdFont whose data pointer we manage
|
||||||
EpdFont epdFont{&stubData};
|
EpdFont epdFont{&stubData};
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ std::unique_ptr<Page> Page::deserialize(HalFile& file) {
|
|||||||
|
|
||||||
uint16_t count;
|
uint16_t count;
|
||||||
serialization::readPod(file, count);
|
serialization::readPod(file, count);
|
||||||
|
// Reserve up front: growth-by-doubling needs old + new capacity live at once and
|
||||||
|
// reallocates repeatedly — a field crash (bad_alloc -> abort under -fno-exceptions)
|
||||||
|
// hit exactly this append path on a heavily fragmented heap.
|
||||||
|
page->elements.reserve(count);
|
||||||
|
|
||||||
for (uint16_t i = 0; i < count; i++) {
|
for (uint16_t i = 0; i < count; i++) {
|
||||||
uint8_t tag;
|
uint8_t tag;
|
||||||
|
|||||||
@@ -62,7 +62,14 @@ void FontCacheManager::resetStats() {
|
|||||||
bool FontCacheManager::isScanning() const { return scanMode_ == ScanMode::Scanning; }
|
bool FontCacheManager::isScanning() const { return scanMode_ == ScanMode::Scanning; }
|
||||||
|
|
||||||
void FontCacheManager::recordText(const char* text, int fontId, EpdFontFamily::Style style) {
|
void FontCacheManager::recordText(const char* text, int fontId, EpdFontFamily::Style style) {
|
||||||
scanText_ += text;
|
if (!text) return;
|
||||||
|
const size_t remaining = (scanTextLen_ < SCAN_TEXT_CAPACITY - 1) ? (SCAN_TEXT_CAPACITY - 1 - scanTextLen_) : 0;
|
||||||
|
if (remaining > 0) {
|
||||||
|
const size_t textLen = strnlen(text, remaining);
|
||||||
|
memcpy(scanText_ + scanTextLen_, text, textLen);
|
||||||
|
scanTextLen_ += textLen;
|
||||||
|
scanText_[scanTextLen_] = '\0';
|
||||||
|
}
|
||||||
if (scanFontId_ < 0) scanFontId_ = fontId;
|
if (scanFontId_ < 0) scanFontId_ = fontId;
|
||||||
const uint8_t baseStyle = static_cast<uint8_t>(style) & 0x03;
|
const uint8_t baseStyle = static_cast<uint8_t>(style) & 0x03;
|
||||||
const unsigned char* p = reinterpret_cast<const unsigned char*>(text);
|
const unsigned char* p = reinterpret_cast<const unsigned char*>(text);
|
||||||
@@ -80,15 +87,15 @@ FontCacheManager::PrewarmScope::PrewarmScope(FontCacheManager& manager) : manage
|
|||||||
manager_->scanMode_ = ScanMode::Scanning;
|
manager_->scanMode_ = ScanMode::Scanning;
|
||||||
manager_->clearCache();
|
manager_->clearCache();
|
||||||
manager_->resetStats();
|
manager_->resetStats();
|
||||||
manager_->scanText_.clear();
|
manager_->scanTextLen_ = 0;
|
||||||
manager_->scanText_.reserve(2048); // Pre-allocate to avoid heap fragmentation from repeated concat
|
manager_->scanText_[0] = '\0';
|
||||||
memset(manager_->scanStyleCounts_, 0, sizeof(manager_->scanStyleCounts_));
|
memset(manager_->scanStyleCounts_, 0, sizeof(manager_->scanStyleCounts_));
|
||||||
manager_->scanFontId_ = -1;
|
manager_->scanFontId_ = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
void FontCacheManager::PrewarmScope::endScanAndPrewarm() {
|
void FontCacheManager::PrewarmScope::endScanAndPrewarm() {
|
||||||
manager_->scanMode_ = ScanMode::None;
|
manager_->scanMode_ = ScanMode::None;
|
||||||
if (manager_->scanText_.empty()) return;
|
if (manager_->scanTextLen_ == 0) return;
|
||||||
|
|
||||||
// Build style bitmask from all styles that appeared during the scan
|
// Build style bitmask from all styles that appeared during the scan
|
||||||
uint8_t styleMask = 0;
|
uint8_t styleMask = 0;
|
||||||
@@ -97,11 +104,10 @@ void FontCacheManager::PrewarmScope::endScanAndPrewarm() {
|
|||||||
}
|
}
|
||||||
if (styleMask == 0) styleMask = 1; // default to regular
|
if (styleMask == 0) styleMask = 1; // default to regular
|
||||||
|
|
||||||
manager_->prewarmCache(manager_->scanFontId_, manager_->scanText_.c_str(), styleMask);
|
manager_->prewarmCache(manager_->scanFontId_, manager_->scanText_, styleMask);
|
||||||
|
|
||||||
// Free scan string memory
|
manager_->scanTextLen_ = 0;
|
||||||
manager_->scanText_.clear();
|
manager_->scanText_[0] = '\0';
|
||||||
manager_->scanText_.shrink_to_fit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
FontCacheManager::PrewarmScope::~PrewarmScope() {
|
FontCacheManager::PrewarmScope::~PrewarmScope() {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
#include <EpdFontFamily.h>
|
#include <EpdFontFamily.h>
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <string>
|
|
||||||
|
|
||||||
class FontDecompressor;
|
class FontDecompressor;
|
||||||
class SdCardFont;
|
class SdCardFont;
|
||||||
@@ -51,7 +51,9 @@ class FontCacheManager {
|
|||||||
|
|
||||||
enum class ScanMode : uint8_t { None, Scanning };
|
enum class ScanMode : uint8_t { None, Scanning };
|
||||||
ScanMode scanMode_ = ScanMode::None;
|
ScanMode scanMode_ = ScanMode::None;
|
||||||
std::string scanText_;
|
static constexpr size_t SCAN_TEXT_CAPACITY = 2048;
|
||||||
|
char scanText_[SCAN_TEXT_CAPACITY] = {};
|
||||||
|
size_t scanTextLen_ = 0;
|
||||||
uint32_t scanStyleCounts_[4] = {};
|
uint32_t scanStyleCounts_[4] = {};
|
||||||
int scanFontId_ = -1;
|
int scanFontId_ = -1;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -91,6 +91,20 @@ void GfxRenderer::begin() {
|
|||||||
bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr);
|
bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GfxRenderer::releaseFrameBufferForBuild() {
|
||||||
|
display.releaseFrameBuffers();
|
||||||
|
frameBuffer = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GfxRenderer::restoreFrameBufferAfterBuild() {
|
||||||
|
if (!display.reallocFrameBuffers()) {
|
||||||
|
LOG_ERR("GFX", "Framebuffer realloc failed after build");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
frameBuffer = display.getFrameBuffer();
|
||||||
|
return frameBuffer != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
bool GfxRenderer::isFontCacheScanning() const { return fontCacheManager_ && fontCacheManager_->isScanning(); }
|
bool GfxRenderer::isFontCacheScanning() const { return fontCacheManager_ && fontCacheManager_->isScanning(); }
|
||||||
|
|
||||||
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
|
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
|
||||||
|
|||||||
@@ -250,6 +250,13 @@ class GfxRenderer {
|
|||||||
// Font helpers
|
// Font helpers
|
||||||
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
|
const uint8_t* getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const;
|
||||||
|
|
||||||
|
// Lend the framebuffer to memory-hungry phases such as section pagination.
|
||||||
|
// Nothing may draw/display while it is released. restore returns the buffer
|
||||||
|
// white, so callers must redraw the full screen afterward.
|
||||||
|
void releaseFrameBufferForBuild();
|
||||||
|
bool restoreFrameBufferAfterBuild();
|
||||||
|
bool hasFrameBuffer() const { return frameBuffer != nullptr; }
|
||||||
|
|
||||||
// Low level functions
|
// Low level functions
|
||||||
uint8_t* getFrameBuffer() const;
|
uint8_t* getFrameBuffer() const;
|
||||||
size_t getBufferSize() const;
|
size_t getBufferSize() const;
|
||||||
|
|||||||
@@ -289,6 +289,25 @@ STR_HW_BACK_LABEL: "Back (1st button)"
|
|||||||
STR_HW_CONFIRM_LABEL: "Confirm (2nd button)"
|
STR_HW_CONFIRM_LABEL: "Confirm (2nd button)"
|
||||||
STR_HW_LEFT_LABEL: "Left (3rd button)"
|
STR_HW_LEFT_LABEL: "Left (3rd button)"
|
||||||
STR_HW_RIGHT_LABEL: "Right (4th 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_FREE_HINT1: "Set Free2/3 to Reader Mode and"
|
||||||
|
STR_BT_FREE_HINT2: "Volume Function to pair"
|
||||||
|
STR_BT_DISCONNECT: "Disconnect"
|
||||||
|
STR_BT_CONNECTED_TO: "Connected: %s"
|
||||||
|
STR_BT_NOT_CONNECTED: "Not connected"
|
||||||
|
STR_BT_PAIRED_DEVICES: "Paired Devices"
|
||||||
|
STR_BT_NO_PAIRED: "No paired devices"
|
||||||
|
STR_BT_MAP_BUTTONS: "Map Remote Buttons"
|
||||||
|
STR_BT_PRESS_REMOTE: "Press a button on your remote"
|
||||||
|
STR_BT_CONNECTING_POPUP: "BT Connecting..."
|
||||||
|
STR_BT_PAUSED_LOW_MEM_POPUP: "BT paused (low memory)"
|
||||||
|
STR_STATE_PAUSED: "PAUSED"
|
||||||
|
STR_BT_PAGE_FORWARD: "Page Forward"
|
||||||
|
STR_BT_PAGE_BACK: "Page Back"
|
||||||
|
STR_BT_FORGET_PROMPT: "Hold Confirm to forget"
|
||||||
STR_GO_TO_PERCENT: "Go to %"
|
STR_GO_TO_PERCENT: "Go to %"
|
||||||
STR_GO_HOME_BUTTON: "Go Home"
|
STR_GO_HOME_BUTTON: "Go Home"
|
||||||
STR_SYNC_PROGRESS: "Sync Progress"
|
STR_SYNC_PROGRESS: "Sync Progress"
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ void HalDisplay::deepSleep() { einkDisplay.deepSleep(); }
|
|||||||
|
|
||||||
uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
||||||
|
|
||||||
|
void HalDisplay::releaseFrameBuffers() { einkDisplay.releaseBuffers(); }
|
||||||
|
|
||||||
|
bool HalDisplay::reallocFrameBuffers() { return einkDisplay.reallocBuffers(); }
|
||||||
|
|
||||||
void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer) {
|
void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer) {
|
||||||
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
|
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ class HalDisplay {
|
|||||||
// Access to frame buffer
|
// Access to frame buffer
|
||||||
uint8_t* getFrameBuffer() const;
|
uint8_t* getFrameBuffer() const;
|
||||||
|
|
||||||
|
// Lend the framebuffer's RAM to a memory-hungry phase. No display calls may
|
||||||
|
// run between release and a successful realloc; buffers come back white, so
|
||||||
|
// callers must redraw the full screen.
|
||||||
|
void releaseFrameBuffers();
|
||||||
|
bool reallocFrameBuffers();
|
||||||
|
|
||||||
// X3 grayscale preconditioning (OEM "AA-pre-BW(mid)" settle pass), windowed
|
// X3 grayscale preconditioning (OEM "AA-pre-BW(mid)" settle pass), windowed
|
||||||
// to the gray region in physical panel coordinates (no-arg = full frame).
|
// to the gray region in physical panel coordinates (no-arg = full frame).
|
||||||
// Call after the BW base frame is displayed and before the grayscale planes
|
// Call after the BW base frame is displayed and before the grayscale planes
|
||||||
|
|||||||
+66
-1
@@ -13,7 +13,10 @@ framework = arduino
|
|||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
upload_speed = 921600
|
upload_speed = 921600
|
||||||
check_tool = cppcheck
|
check_tool = cppcheck
|
||||||
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
|
; missingInclude (project headers) is suppressed alongside missingIncludeSystem: on a
|
||||||
|
; fresh CI checkout cppcheck has no resolved include paths, so it reports every
|
||||||
|
; project header as missing (~470 information-level lines) and fails the job.
|
||||||
|
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=missingInclude --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
|
||||||
check_skip_packages = yes
|
check_skip_packages = yes
|
||||||
|
|
||||||
board_upload.flash_size = 16MB
|
board_upload.flash_size = 16MB
|
||||||
@@ -40,6 +43,16 @@ build_flags =
|
|||||||
-Wno-bidi-chars
|
-Wno-bidi-chars
|
||||||
-Wl,--wrap=panic_print_backtrace,--wrap=panic_abort,--wrap=bootloader_common_check_efuse_blk_validity
|
-Wl,--wrap=panic_print_backtrace,--wrap=panic_abort,--wrap=bootloader_common_check_efuse_blk_validity
|
||||||
-fno-exceptions
|
-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 =
|
build_unflags =
|
||||||
-std=gnu++11
|
-std=gnu++11
|
||||||
@@ -50,6 +63,48 @@ board_build.flash_mode = dio
|
|||||||
board_build.flash_size = 16MB
|
board_build.flash_size = 16MB
|
||||||
board_build.partitions = partitions.csv
|
board_build.partitions = partitions.csv
|
||||||
|
|
||||||
|
; Shrink the NimBLE footprint for a 1-connection HID host moving 3-6 byte reports.
|
||||||
|
; Field-measured: begin() costs ~52 KB with these trims vs ~68 KB with the prebuilt
|
||||||
|
; framework defaults — and that 15 KB is the difference between the stack landing
|
||||||
|
; above the reader's render shed floor (stable coexistence) and below it (a
|
||||||
|
; guaranteed shed/restart flap). Rebuilds the Arduino core libs on first build
|
||||||
|
; (slower once, cached after; needs the CMake pin in platformio.local.ini on macOS).
|
||||||
|
custom_sdkconfig =
|
||||||
|
CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=n
|
||||||
|
CONFIG_BT_NIMBLE_ROLE_BROADCASTER=n
|
||||||
|
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
|
||||||
|
CONFIG_BT_NIMBLE_MAX_CCCDS=2
|
||||||
|
CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=23
|
||||||
|
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=6
|
||||||
|
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=6 ; was 24 x 320 B
|
||||||
|
CONFIG_BT_NIMBLE_ACL_BUF_COUNT=6 ; was 24 x 255 B
|
||||||
|
CONFIG_BT_NIMBLE_HCI_EVT_HI_BUF_COUNT=12 ; was 30 x 70 B; only scan bursts need many
|
||||||
|
; IDF 5.5 sizes the HCI transport pools under TRANSPORT_* names; pin both spellings.
|
||||||
|
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=6
|
||||||
|
CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=12
|
||||||
|
CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES=4 ; was 64; a HID host never does prepared writes
|
||||||
|
CONFIG_BT_CTRL_BLE_MAX_ACT=3 ; was 6; need conn + scan + initiate only
|
||||||
|
CONFIG_BT_CTRL_BLE_ADV_REPORT_FLOW_CTRL_NUM=50 ; was 100; pairing-time scan only
|
||||||
|
CONFIG_BT_CTRL_ADV_DUP_FILT_MAX=10 ; was 30
|
||||||
|
; Keep the Arduino wrappers for the removed cloud components (below) out of
|
||||||
|
; the core source list; all other bundled libraries default to enabled.
|
||||||
|
CONFIG_ARDUINO_SELECTIVE_COMPILATION=y
|
||||||
|
CONFIG_ARDUINO_SELECTIVE_RainMaker=n
|
||||||
|
CONFIG_ARDUINO_SELECTIVE_Insights=n
|
||||||
|
|
||||||
|
; Drop unused cloud components from the core rebuild. esp_insights/rainmaker
|
||||||
|
; require embedded server certs the lib builder can't generate
|
||||||
|
; ("https_server.crt.S not found"); this firmware uses none of them.
|
||||||
|
custom_component_remove =
|
||||||
|
espressif/esp_insights
|
||||||
|
espressif/esp_rainmaker
|
||||||
|
espressif/esp_diagnostics
|
||||||
|
espressif/esp_diag_data_store
|
||||||
|
espressif/esp_schedule
|
||||||
|
espressif/esp_rcp_update
|
||||||
|
espressif/esp_secure_cert_mgr
|
||||||
|
espressif/cbor
|
||||||
|
|
||||||
extra_scripts =
|
extra_scripts =
|
||||||
pre:scripts/build_html.py
|
pre:scripts/build_html.py
|
||||||
pre:scripts/gen_i18n.py
|
pre:scripts/gen_i18n.py
|
||||||
@@ -63,10 +118,15 @@ lib_deps =
|
|||||||
InputManager=symlink://freeink-sdk/libs/hardware/InputManager
|
InputManager=symlink://freeink-sdk/libs/hardware/InputManager
|
||||||
EInkDisplay=symlink://freeink-sdk/libs/display/FreeInkDisplay
|
EInkDisplay=symlink://freeink-sdk/libs/display/FreeInkDisplay
|
||||||
SDCardManager=symlink://freeink-sdk/libs/hardware/SDCardManager
|
SDCardManager=symlink://freeink-sdk/libs/hardware/SDCardManager
|
||||||
|
; FreeInk HAL support libs the above depend on (BoardConfig pin maps, etc.).
|
||||||
BoardConfig=symlink://freeink-sdk/libs/hardware/BoardConfig
|
BoardConfig=symlink://freeink-sdk/libs/hardware/BoardConfig
|
||||||
PowerManager=symlink://freeink-sdk/libs/hardware/PowerManager
|
PowerManager=symlink://freeink-sdk/libs/hardware/PowerManager
|
||||||
|
Rtc=symlink://freeink-sdk/libs/hardware/Rtc
|
||||||
|
Imu=symlink://freeink-sdk/libs/hardware/Imu
|
||||||
FreeInkUI=symlink://freeink-sdk/libs/ui/FreeInkUI
|
FreeInkUI=symlink://freeink-sdk/libs/ui/FreeInkUI
|
||||||
Icons=symlink://freeink-sdk/libs/assets/Icons
|
Icons=symlink://freeink-sdk/libs/assets/Icons
|
||||||
|
BleKeyboardHost=symlink://freeink-sdk/libs/network/BleKeyboardHost
|
||||||
|
h2zero/NimBLE-Arduino @ ^2.3.8
|
||||||
bblanchon/ArduinoJson @ 7.4.2
|
bblanchon/ArduinoJson @ 7.4.2
|
||||||
ricmoo/QRCode @ 0.0.1
|
ricmoo/QRCode @ 0.0.1
|
||||||
bitbank2/PNGdec @ 1.1.6
|
bitbank2/PNGdec @ 1.1.6
|
||||||
@@ -80,6 +140,9 @@ build_flags =
|
|||||||
; CROSSPOINT_VERSION is set by scripts/git_branch.py (includes branch + short SHA)
|
; CROSSPOINT_VERSION is set by scripts/git_branch.py (includes branch + short SHA)
|
||||||
-DENABLE_SERIAL_LOG
|
-DENABLE_SERIAL_LOG
|
||||||
-DLOG_LEVEL=2 ; Set log level to debug for development builds
|
-DLOG_LEVEL=2 ; Set log level to debug for development builds
|
||||||
|
-DFREEINK_CAP_BLE_HID_HOST=1 ; BLE HID page-turner host (NimBLE)
|
||||||
|
-DFREEINK_BLE_HID_SCAN_DEBUG=1 ; verbose BLE scan lifecycle/advertisement logs for bring-up
|
||||||
|
-DFREEINK_BLE_HID_REPORT_DEBUG=1 ; raw HID report hex dumps + report-map hints (bring-up)
|
||||||
|
|
||||||
|
|
||||||
[env:gh_release]
|
[env:gh_release]
|
||||||
@@ -89,6 +152,7 @@ build_flags =
|
|||||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"
|
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"
|
||||||
-DENABLE_SERIAL_LOG
|
-DENABLE_SERIAL_LOG
|
||||||
-DLOG_LEVEL=1 ; Set log level to info for release builds
|
-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]
|
[env:gh_release_rc]
|
||||||
extends = base
|
extends = base
|
||||||
@@ -97,6 +161,7 @@ build_flags =
|
|||||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\"
|
-DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\"
|
||||||
-DENABLE_SERIAL_LOG
|
-DENABLE_SERIAL_LOG
|
||||||
-DLOG_LEVEL=1 ; Set log level to info for release candidate builds
|
-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]
|
[env:slim]
|
||||||
extends = base
|
extends = base
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#include "BleInput.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <HalPowerManager.h>
|
||||||
|
#include <I18n.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
|
||||||
|
namespace bleinput {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
volatile bool g_startInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
g_startInProgress = true;
|
||||||
|
HalPowerManager::Lock powerLock;
|
||||||
|
const bool ok = BleHid.begin(kHostName);
|
||||||
|
g_startInProgress = false;
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool startInProgress() { return g_startInProgress; }
|
||||||
|
|
||||||
|
// 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 showConnectingUntilLinked(const GfxRenderer& renderer, const MappedInputManager& input) {
|
||||||
|
if (!BleHid.isRunning() || BleHid.isConnected()) return;
|
||||||
|
// drawPopup refreshes the panel itself, so draw once and let e-ink hold it while we
|
||||||
|
// pump the host. Holds until the remote links, the user presses a button to bail, or
|
||||||
|
// a generous timeout (a remote that slept after a disconnect needs a button to wake).
|
||||||
|
GUI.drawPopup(renderer, tr(STR_BT_CONNECTING_POPUP));
|
||||||
|
const unsigned long deadline = millis() + 10000;
|
||||||
|
while (!BleHid.isConnected() && millis() < deadline) {
|
||||||
|
BleHid.poll();
|
||||||
|
input.update();
|
||||||
|
if (input.wasAnyPressed()) break;
|
||||||
|
delay(50);
|
||||||
|
}
|
||||||
|
// Note: the caller must redraw to clear the popup. For grayscale reader pages the
|
||||||
|
// caller should also request a ghost-cleanup (HALF) refresh first — a plain fast/
|
||||||
|
// partial refresh ghosts badly over the BW popup (see Activity::requestGhostCleanup).
|
||||||
|
}
|
||||||
|
|
||||||
|
void describeKey(uint8_t kind, uint8_t value, char* out, size_t outLen) {
|
||||||
|
if (!out || outLen == 0) return;
|
||||||
|
if (kind == 0) {
|
||||||
|
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,62 @@
|
|||||||
|
#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>
|
||||||
|
|
||||||
|
class GfxRenderer;
|
||||||
|
class MappedInputManager;
|
||||||
|
|
||||||
|
namespace bleinput {
|
||||||
|
|
||||||
|
// Advertised central name shown to peripherals during pairing.
|
||||||
|
inline constexpr const char* kHostName = "CrossPoint";
|
||||||
|
|
||||||
|
// Heap floor for starting the NimBLE stack (measured begin() cost: ~52-57 KB).
|
||||||
|
// The reader now lends the framebuffer to section builds, so BLE startup no
|
||||||
|
// longer needs to reserve the old full build headroom. Keep a modest margin and
|
||||||
|
// let the render/build shed paths handle genuinely tight moments.
|
||||||
|
inline constexpr size_t kStartMinFreeHeap = 56 * 1024;
|
||||||
|
|
||||||
|
// Lower floor for the Bluetooth settings screen, where the user has explicitly asked
|
||||||
|
// for BLE right now (scanning/pairing is dead without the stack). No page renders or
|
||||||
|
// section builds run there, so the reader-sized reserve above doesn't apply — only
|
||||||
|
// NimBLE's own ~57 KB plus working margin.
|
||||||
|
inline constexpr size_t kStartMinFreeHeapExplicit = 70 * 1024;
|
||||||
|
|
||||||
|
// Start the BLE HID host (idempotent). Returns false if BLE is compiled out or
|
||||||
|
// NimBLE init failed. Safe to call repeatedly.
|
||||||
|
bool ensureStarted();
|
||||||
|
bool startInProgress();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Draw a "BT Connecting..." popup and pump the BLE host until the bonded remote
|
||||||
|
// links, the user presses a button to dismiss, or a timeout. No-op if BLE isn't
|
||||||
|
// running or is already connected. The caller must redraw afterward to clear it.
|
||||||
|
void showConnectingUntilLinked(const GfxRenderer& renderer, const MappedInputManager& input);
|
||||||
|
|
||||||
|
} // namespace bleinput
|
||||||
@@ -225,6 +225,22 @@ class CrossPointSettings {
|
|||||||
uint8_t frontButtonConfirm = FRONT_HW_CONFIRM;
|
uint8_t frontButtonConfirm = FRONT_HW_CONFIRM;
|
||||||
uint8_t frontButtonLeft = FRONT_HW_LEFT;
|
uint8_t frontButtonLeft = FRONT_HW_LEFT;
|
||||||
uint8_t frontButtonRight = FRONT_HW_RIGHT;
|
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
|
// Reader font settings
|
||||||
uint8_t fontFamily = NOTOSERIF;
|
uint8_t fontFamily = NOTOSERIF;
|
||||||
uint8_t fontSize = MEDIUM;
|
uint8_t fontSize = MEDIUM;
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <iterator>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "BookmarkEntry.h"
|
#include "BookmarkEntry.h"
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
|
#include "MappedInputManager.h"
|
||||||
#include "OpdsServerStore.h"
|
#include "OpdsServerStore.h"
|
||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "SettingsList.h"
|
#include "SettingsList.h"
|
||||||
@@ -143,6 +145,16 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
|
|||||||
doc["frontButtonConfirm"] = s.frontButtonConfirm;
|
doc["frontButtonConfirm"] = s.frontButtonConfirm;
|
||||||
doc["frontButtonLeft"] = s.frontButtonLeft;
|
doc["frontButtonLeft"] = s.frontButtonLeft;
|
||||||
doc["frontButtonRight"] = s.frontButtonRight;
|
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.
|
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
||||||
doc["fontFamily"] = s.fontFamily;
|
doc["fontFamily"] = s.fontFamily;
|
||||||
// SD card font family name — not in SettingsList, save manually
|
// SD card font family name — not in SettingsList, save manually
|
||||||
@@ -240,6 +252,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);
|
clamp(doc["frontButtonRight"] | (uint8_t)S::FRONT_HW_RIGHT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_RIGHT);
|
||||||
CrossPointSettings::validateFrontButtonMapping(s);
|
CrossPointSettings::validateFrontButtonMapping(s);
|
||||||
|
|
||||||
|
// Bluetooth — managed by BluetoothSettingsActivity, not in SettingsList.
|
||||||
|
s.bluetoothEnabled = clamp(doc["bluetoothEnabled"] | (uint8_t)0, 2, 0);
|
||||||
|
std::fill(std::begin(s.bleKeyMap), std::end(s.bleKeyMap), 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.
|
// Font family — uses dynamic getter/setter in SettingsList so the generic loop skips it.
|
||||||
const uint8_t storedFontFamily = doc["fontFamily"] | (uint8_t)0;
|
const uint8_t storedFontFamily = doc["fontFamily"] | (uint8_t)0;
|
||||||
s.fontFamily = clamp(storedFontFamily, CrossPointSettings::BUILTIN_FONT_COUNT, 0);
|
s.fontFamily = clamp(storedFontFamily, CrossPointSettings::BUILTIN_FONT_COUNT, 0);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
|
#include "BleInput.h"
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
|
|
||||||
bool MappedInputManager::isNavDirectionSwapped() const {
|
bool MappedInputManager::isNavDirectionSwapped() const {
|
||||||
@@ -74,17 +75,105 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
|
|||||||
return false;
|
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. A
|
||||||
|
// pending release also counts as BLE activity this frame so getHeldTime() reports
|
||||||
|
// zero on the release frame too (page-turn handlers often fire on release).
|
||||||
|
for (uint8_t i = 0; i < kButtonCount; i++) {
|
||||||
|
bleReleaseEdge[i] = blePressEdge[i];
|
||||||
|
blePressEdge[i] = false;
|
||||||
|
if (bleReleaseEdge[i]) bleActivityThisFrame = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
freeink::KeyEvent ev;
|
||||||
|
while (BleHid.popKey(ev)) {
|
||||||
|
uint8_t kind = 0xFF;
|
||||||
|
uint8_t value = 0;
|
||||||
|
if (!bleinput::encodeKey(ev, kind, value)) continue;
|
||||||
|
|
||||||
|
if (bleCaptureMode) {
|
||||||
|
bleCapturedKind = kind;
|
||||||
|
bleCapturedValue = value;
|
||||||
|
bleHasCaptured = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the key identity against the persisted mapping table.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
|
bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
|
||||||
|
|
||||||
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
|
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
|
||||||
|
|
||||||
unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); }
|
unsigned long MappedInputManager::getHeldTime() const {
|
||||||
|
// A BLE-mapped key is a momentary tap with no physical hold (we don't model BLE
|
||||||
|
// press-and-hold). gpio.getHeldTime() returns the *last physical* button's hold
|
||||||
|
// duration, which is stale — if a BLE edge drove input this frame, reporting that
|
||||||
|
// stale value makes a tap look like a long-press (e.g. page tap -> chapter skip).
|
||||||
|
// Report zero in that case so BLE taps are always treated as short presses.
|
||||||
|
if (bleActivityThisFrame) return 0;
|
||||||
|
return gpio.getHeldTime();
|
||||||
|
}
|
||||||
|
|
||||||
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
||||||
const char* next) const {
|
const char* next) const {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ class GfxRenderer;
|
|||||||
class MappedInputManager {
|
class MappedInputManager {
|
||||||
public:
|
public:
|
||||||
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward, NavNext, NavPrevious };
|
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 {
|
struct Labels {
|
||||||
const char* btn1;
|
const char* btn1;
|
||||||
@@ -28,6 +31,23 @@ class MappedInputManager {
|
|||||||
// Returns the raw front button index that was pressed this frame (or -1 if none).
|
// Returns the raw front button index that was pressed this frame (or -1 if none).
|
||||||
int getPressedFrontButton() const;
|
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
|
// 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 /
|
// 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,
|
// LANDSCAPE_CCW). Keyed on the live renderer orientation rather than the persisted reader setting,
|
||||||
@@ -44,4 +64,17 @@ class MappedInputManager {
|
|||||||
const GfxRenderer& renderer;
|
const GfxRenderer& renderer;
|
||||||
|
|
||||||
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,3 +6,7 @@
|
|||||||
|
|
||||||
void silentRestart(); // home screen
|
void silentRestart(); // home screen
|
||||||
void silentRestartToReader(); // currently-open EPUB (APP_STATE.openEpubPath)
|
void silentRestartToReader(); // currently-open EPUB (APP_STATE.openEpubPath)
|
||||||
|
// True when this boot itself came from a silent restart. Callers that restart
|
||||||
|
// as a last-resort defrag must check this so a failure that survives the
|
||||||
|
// restart degrades to an error instead of a reboot loop.
|
||||||
|
bool bootWasSilentRestart();
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ class Activity {
|
|||||||
virtual bool skipLoopDelay() { return false; }
|
virtual bool skipLoopDelay() { return false; }
|
||||||
virtual bool preventAutoSleep() { return false; }
|
virtual bool preventAutoSleep() { return false; }
|
||||||
virtual bool isReaderActivity() const { return false; }
|
virtual bool isReaderActivity() const { return false; }
|
||||||
|
// True if this activity needs the BLE stack resident (beyond the readers, which are
|
||||||
|
// covered by isReaderActivity()). The Bluetooth settings screen overrides this so
|
||||||
|
// pairing/scanning works there. Everywhere else BLE is torn down to free heap.
|
||||||
|
virtual bool keepsBluetoothAlive() const { return false; }
|
||||||
|
// True while the current activity is doing heap-heavy work that must finish
|
||||||
|
// before the BLE stack (~52 KB) may start.
|
||||||
|
virtual bool deferBluetoothStart() const { return false; }
|
||||||
|
// Ask the activity to make its next render a full ghost-cleanup (HALF) refresh rather
|
||||||
|
// than a fast/partial one. Used after drawing a transient popup over grayscale content
|
||||||
|
// (e.g. the "BT Connecting..." popup over a reader page) so it clears without ghosting.
|
||||||
|
virtual void requestGhostCleanup() {}
|
||||||
virtual ScreenshotInfo getScreenshotInfo() const { return {}; }
|
virtual ScreenshotInfo getScreenshotInfo() const { return {}; }
|
||||||
|
|
||||||
// Start a new activity without destroying the current one
|
// Start a new activity without destroying the current one
|
||||||
|
|||||||
@@ -256,6 +256,25 @@ bool ActivityManager::isReaderActivity() const {
|
|||||||
(currentActivity && currentActivity->isReaderActivity());
|
(currentActivity && currentActivity->isReaderActivity());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ActivityManager::currentKeepsBluetoothAlive() const {
|
||||||
|
return currentActivity && currentActivity->keepsBluetoothAlive();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ActivityManager::requestGhostCleanup() {
|
||||||
|
if (currentActivity) currentActivity->requestGhostCleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ActivityManager::bluetoothShouldBeActive() const {
|
||||||
|
const auto wants = [](const auto& activity) {
|
||||||
|
return activity && (activity->isReaderActivity() || activity->keepsBluetoothAlive());
|
||||||
|
};
|
||||||
|
return std::any_of(stackActivities.begin(), stackActivities.end(), wants) || wants(currentActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ActivityManager::bluetoothStartDeferred() const {
|
||||||
|
return currentActivity && currentActivity->deferBluetoothStart();
|
||||||
|
}
|
||||||
|
|
||||||
bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); }
|
bool ActivityManager::skipLoopDelay() const { return currentActivity && currentActivity->skipLoopDelay(); }
|
||||||
|
|
||||||
ScreenshotInfo ActivityManager::getScreenshotInfo() const {
|
ScreenshotInfo ActivityManager::getScreenshotInfo() const {
|
||||||
|
|||||||
@@ -102,6 +102,16 @@ class ActivityManager {
|
|||||||
|
|
||||||
bool preventAutoSleep() const;
|
bool preventAutoSleep() const;
|
||||||
bool isReaderActivity() const;
|
bool isReaderActivity() const;
|
||||||
|
bool currentKeepsBluetoothAlive() const;
|
||||||
|
// True if BLE should be resident for the current context: any reader (page-turner
|
||||||
|
// input) or the Bluetooth settings screen (pairing) is on the stack.
|
||||||
|
bool bluetoothShouldBeActive() const;
|
||||||
|
// True while the CURRENT activity is mid heap-heavy work that must complete before
|
||||||
|
// NimBLE may start (see Activity::deferBluetoothStart). Current only, not the
|
||||||
|
// stack: a reader stacked under a menu has its loop() paused, so its build never
|
||||||
|
// advances — a stack-wide check would hold BLE off for as long as the menu stays
|
||||||
|
// open.
|
||||||
|
bool bluetoothStartDeferred() const;
|
||||||
bool skipLoopDelay() const;
|
bool skipLoopDelay() const;
|
||||||
ScreenshotInfo getScreenshotInfo() const;
|
ScreenshotInfo getScreenshotInfo() const;
|
||||||
|
|
||||||
@@ -109,6 +119,9 @@ class ActivityManager {
|
|||||||
// Otherwise, it will be deferred until the end of the current loop iteration.
|
// Otherwise, it will be deferred until the end of the current loop iteration.
|
||||||
void requestUpdate(bool immediate = false);
|
void requestUpdate(bool immediate = false);
|
||||||
|
|
||||||
|
// Ask the current activity to make its next render a ghost-cleanup (HALF) refresh.
|
||||||
|
void requestGhostCleanup();
|
||||||
|
|
||||||
// Trigger a render and block until it completes.
|
// Trigger a render and block until it completes.
|
||||||
// Must NOT be called from the render task or while holding a RenderLock.
|
// Must NOT be called from the render task or while holding a RenderLock.
|
||||||
void requestUpdateAndWait();
|
void requestUpdateAndWait();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
|
|
||||||
|
#include "BleInput.h"
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "WifiCredentialStore.h"
|
#include "WifiCredentialStore.h"
|
||||||
@@ -93,6 +94,11 @@ void WifiSelectionActivity::startWifiScan() {
|
|||||||
networks.clear();
|
networks.clear();
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
|
|
||||||
|
// Free the BLE stack before bringing WiFi up: the C3 has one radio and the two
|
||||||
|
// stacks can't both fit in heap. Reachable from the reader via KOReader sync, where
|
||||||
|
// BLE is still resident; a no-op when BLE is already off (launched from Settings).
|
||||||
|
bleinput::stop();
|
||||||
|
|
||||||
// Set WiFi mode to station
|
// Set WiFi mode to station
|
||||||
WiFi.mode(WIFI_STA);
|
WiFi.mode(WIFI_STA);
|
||||||
WiFi.disconnect();
|
WiFi.disconnect();
|
||||||
@@ -211,6 +217,7 @@ void WifiSelectionActivity::attemptConnection() {
|
|||||||
connectionError.clear();
|
connectionError.clear();
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
|
|
||||||
|
bleinput::stop(); // free the BLE stack before WiFi (shared C3 radio, tight heap)
|
||||||
WiFi.persistent(false); // Credentials are managed by WifiCredentialStore; suppress SDK NVS auto-connect
|
WiFi.persistent(false); // Credentials are managed by WifiCredentialStore; suppress SDK NVS auto-connect
|
||||||
WiFi.mode(WIFI_STA);
|
WiFi.mode(WIFI_STA);
|
||||||
WiFi.disconnect(true, true); // Abort any in-progress SDK auto-connect and clear NVS-saved SSID
|
WiFi.disconnect(true, true); // Abort any in-progress SDK auto-connect and clear NVS-saved SSID
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#include <iterator>
|
#include <iterator>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
|
||||||
|
#include "BleInput.h"
|
||||||
#include "BookmarkEntry.h"
|
#include "BookmarkEntry.h"
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
#include "QrDisplayActivity.h"
|
#include "QrDisplayActivity.h"
|
||||||
#include "ReaderUtils.h"
|
#include "ReaderUtils.h"
|
||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
|
#include "SilentRestart.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
#include "util/BookmarkUtil.h"
|
#include "util/BookmarkUtil.h"
|
||||||
@@ -65,6 +67,55 @@ bool isInReadFolder(const std::string& path) {
|
|||||||
return path.size() > n && path.compare(0, n, READ_FOLDER) == 0 && path[n] == '/';
|
return path.size() > n && path.compare(0, n, READ_FOLDER) == 0 && path[n] == '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FrameBufferBuildLoan {
|
||||||
|
public:
|
||||||
|
explicit FrameBufferBuildLoan(GfxRenderer& renderer) : renderer_(renderer) {}
|
||||||
|
~FrameBufferBuildLoan() {
|
||||||
|
if (active_ && !restore()) {
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void release() {
|
||||||
|
if (active_ || !renderer_.hasFrameBuffer()) return;
|
||||||
|
if (bleinput::startInProgress()) {
|
||||||
|
LOG_INF("ERS", "Framebuffer loan waiting for BLE start to settle");
|
||||||
|
const uint32_t deadline = millis() + 1000;
|
||||||
|
while (bleinput::startInProgress() && millis() < deadline) {
|
||||||
|
delay(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderer_.releaseFrameBufferForBuild();
|
||||||
|
active_ = true;
|
||||||
|
LOG_DBG("ERS", "Framebuffer lent for section build (ble=%u heap=%u maxAlloc=%u)", BleHid.isRunning() ? 1 : 0,
|
||||||
|
(unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool restore() {
|
||||||
|
if (!active_) return true;
|
||||||
|
active_ = false;
|
||||||
|
if (renderer_.restoreFrameBufferAfterBuild()) {
|
||||||
|
LOG_DBG("ERS", "Framebuffer restored after section build");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (BleHid.isRunning()) {
|
||||||
|
LOG_INF("ERS", "Framebuffer restore needs heap; freeing BLE and retrying (heap=%u maxAlloc=%u)",
|
||||||
|
(unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap());
|
||||||
|
bleinput::stop();
|
||||||
|
if (renderer_.restoreFrameBufferAfterBuild()) {
|
||||||
|
LOG_DBG("ERS", "Framebuffer restored after freeing BLE");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LOG_ERR("ERS", "Framebuffer restore failed after section build");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
GfxRenderer& renderer_;
|
||||||
|
bool active_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
struct ProgressRange {
|
struct ProgressRange {
|
||||||
float start;
|
float start;
|
||||||
float end;
|
float end;
|
||||||
@@ -255,6 +306,32 @@ void EpubReaderActivity::openReaderMenu() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool EpubReaderActivity::buildTickHeapGate() {
|
||||||
|
const size_t freeHeap = ESP.getFreeHeap();
|
||||||
|
const size_t maxBlock = ESP.getMaxAllocHeap();
|
||||||
|
if (freeHeap >= BACKGROUND_BUILD_MIN_FREE_HEAP && maxBlock >= BACKGROUND_BUILD_MIN_MAX_ALLOC) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const size_t lendableFrameBuffer = renderer.hasFrameBuffer() ? renderer.getBufferSize() : 0;
|
||||||
|
if (lendableFrameBuffer > 0 && freeHeap + lendableFrameBuffer >= BACKGROUND_BUILD_MIN_FREE_HEAP &&
|
||||||
|
maxBlock + lendableFrameBuffer >= BACKGROUND_BUILD_MIN_MAX_ALLOC) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Below the floors. If the BLE stack is what's squeezing the heap, shed it — the
|
||||||
|
// established policy on this branch is that builds and resident BLE don't coexist,
|
||||||
|
// and this was the one build path without that protection (field crash: a tick's
|
||||||
|
// parse allocation aborted at maxAlloc ~11 KB with BLE resident). The lifecycle's
|
||||||
|
// build-pending deferral keeps BLE down until the window is caught up, then
|
||||||
|
// restarts it behind the start floor. Without BLE resident, just wait: page-turn
|
||||||
|
// transients free up between turns and the tick retries every loop pass.
|
||||||
|
if (BleHid.isRunning()) {
|
||||||
|
LOG_INF("ERS", "Background build needs heap (free=%u maxAlloc=%u); freeing BLE RAM", (unsigned)freeHeap,
|
||||||
|
(unsigned)maxBlock);
|
||||||
|
bleinput::stop();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::loop() {
|
void EpubReaderActivity::loop() {
|
||||||
if (!epub) {
|
if (!epub) {
|
||||||
// Should never happen
|
// Should never happen
|
||||||
@@ -269,7 +346,7 @@ void EpubReaderActivity::loop() {
|
|||||||
// Skip while the render mutex is busy so we never delay a pending render; re-check
|
// Skip while the render mutex is busy so we never delay a pending render; re-check
|
||||||
// isBuilding() under the lock since render() may have just finished it.
|
// isBuilding() under the lock since render() may have just finished it.
|
||||||
if (section && section->isBuilding() && !RenderLock::peek() &&
|
if (section && section->isBuilding() && !RenderLock::peek() &&
|
||||||
static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) {
|
static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD && buildTickHeapGate()) {
|
||||||
RenderLock lock;
|
RenderLock lock;
|
||||||
// Re-check under the lock: render() (which also holds the RenderLock) may have finalized the
|
// Re-check under the lock: render() (which also holds the RenderLock) may have finalized the
|
||||||
// build between the outer isBuilding() check and acquiring the lock here, in which case
|
// build between the outer isBuilding() check and acquiring the lock here, in which case
|
||||||
@@ -277,6 +354,8 @@ void EpubReaderActivity::loop() {
|
|||||||
// mutation, so it flags this as always true.
|
// mutation, so it flags this as always true.
|
||||||
// cppcheck-suppress knownConditionTrueFalse
|
// cppcheck-suppress knownConditionTrueFalse
|
||||||
if (section->isBuilding()) {
|
if (section->isBuilding()) {
|
||||||
|
FrameBufferBuildLoan buildLoan(renderer);
|
||||||
|
buildLoan.release();
|
||||||
if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) {
|
if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) {
|
||||||
LOG_ERR("ERS", "Background section build failed");
|
LOG_ERR("ERS", "Background section build failed");
|
||||||
section.reset();
|
section.reset();
|
||||||
@@ -286,6 +365,9 @@ void EpubReaderActivity::loop() {
|
|||||||
// real page count, so re-render at the remapped page. No-op for an unchanged resume.
|
// real page count, so re-render at the remapped page. No-op for an unchanged resume.
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
}
|
}
|
||||||
|
if (!buildLoan.restore()) {
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -887,6 +969,17 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FrameBufferBuildLoan buildLoan(renderer);
|
||||||
|
|
||||||
|
// If BLE leaves the reader below the render floor, lend the framebuffer before
|
||||||
|
// deserializing/loading the page instead of tearing BLE down immediately. The
|
||||||
|
// restore path still frees BLE if the framebuffer cannot be reallocated.
|
||||||
|
if (BleHid.isRunning() && ESP.getFreeHeap() < RENDER_MIN_FREE_HEAP) {
|
||||||
|
LOG_INF("ERS", "Render heap %u below floor %u; lending framebuffer", (unsigned)ESP.getFreeHeap(),
|
||||||
|
(unsigned)RENDER_MIN_FREE_HEAP);
|
||||||
|
buildLoan.release();
|
||||||
|
}
|
||||||
|
|
||||||
const auto showPendingSyncSaveError = [this]() {
|
const auto showPendingSyncSaveError = [this]() {
|
||||||
if (!pendingSyncSaveError) return;
|
if (!pendingSyncSaveError) return;
|
||||||
pendingSyncSaveError = false;
|
pendingSyncSaveError = false;
|
||||||
@@ -896,7 +989,11 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
// A section build failure (e.g. an invalid/corrupt EPUB that fails XML parsing) leaves the
|
// A section build failure (e.g. an invalid/corrupt EPUB that fails XML parsing) leaves the
|
||||||
// "Indexing" popup on screen with no way forward. Surface an explicit error instead of hanging.
|
// "Indexing" popup on screen with no way forward. Surface an explicit error instead of hanging.
|
||||||
// clearScreen first so the error popup doesn't overlay the stale "Indexing" popup.
|
// clearScreen first so the error popup doesn't overlay the stale "Indexing" popup.
|
||||||
const auto showBuildError = [this]() {
|
const auto showBuildError = [this, &buildLoan]() {
|
||||||
|
if (!buildLoan.restore()) {
|
||||||
|
ESP.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
GUI.drawPopup(renderer, tr(STR_INDEX_FAILED));
|
GUI.drawPopup(renderer, tr(STR_INDEX_FAILED));
|
||||||
automaticPageTurnActive = false;
|
automaticPageTurnActive = false;
|
||||||
@@ -975,6 +1072,44 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
LOG_DBG("ERS", "Cache not found, building...");
|
LOG_DBG("ERS", "Cache not found, building...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The layout code (line-break DP arrays, CSS lookups, glyph buffers) allocates freely
|
||||||
|
// and abort()s on OOM under -fno-exceptions, so a starved heap must be handled BEFORE
|
||||||
|
// the build: pre-flight the floor and take the recovery path up front instead of
|
||||||
|
// crashing mid-parse. Field data: builds succeed at ~46 KB free with BLE resident;
|
||||||
|
// abort() observed at ~11 KB free.
|
||||||
|
const size_t lendableFrameBuffer = renderer.hasFrameBuffer() ? renderer.getBufferSize() : 0;
|
||||||
|
const bool heapTooLow = ESP.getFreeHeap() + lendableFrameBuffer < BUILD_MIN_FREE_HEAP;
|
||||||
|
if (heapTooLow) {
|
||||||
|
LOG_ERR("ERS", "Pre-build heap %u (+fb %u) below floor %u; entering build recovery",
|
||||||
|
(unsigned)ESP.getFreeHeap(), (unsigned)lendableFrameBuffer, (unsigned)BUILD_MIN_FREE_HEAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Building a section needs a large contiguous inflate (deflate) window that the
|
||||||
|
// resident NimBLE stack fragments out of existence (~16 KB max block with BT on).
|
||||||
|
// On build failure (or a pre-flight floor miss) with BT enabled: free the BLE stack
|
||||||
|
// and retry. The chapter is cached afterwards, so this recovery runs at most once
|
||||||
|
// per uncached chapter.
|
||||||
|
// Deliberately do NOT restart BLE inline: this render still has its own allocations
|
||||||
|
// to make. The main-loop lifecycle restarts BLE later, behind its activity,
|
||||||
|
// render-lock, framebuffer, and heap gates.
|
||||||
|
const auto retryWithBleFreed = [&](auto&& buildFn) {
|
||||||
|
LOG_INF("ERS", "Section build needs heap; freeing BLE RAM and retrying");
|
||||||
|
bleinput::stop();
|
||||||
|
return buildFn();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Even with BLE freed, the build can fail when this session's parse churn has
|
||||||
|
// fragmented the heap beyond in-place recovery. A silent restart is the only
|
||||||
|
// real defrag on this heap (no compaction); it resumes into this book and
|
||||||
|
// rebuilds the section on a fresh heap. Guarded by bootWasSilentRestart() so a
|
||||||
|
// build that fails again after the restart degrades to the error popup below
|
||||||
|
// instead of reboot-looping.
|
||||||
|
const auto silentRestartDefrag = [&]() {
|
||||||
|
if (bootWasSilentRestart()) return;
|
||||||
|
LOG_ERR("ERS", "Section build failed after BLE recovery; silent restart to defrag heap");
|
||||||
|
silentRestartToReader();
|
||||||
|
};
|
||||||
|
|
||||||
// Jumps that need the final pagination or the anchor map -- explicit page jumps,
|
// Jumps that need the final pagination or the anchor map -- explicit page jumps,
|
||||||
// fragment anchors, percent jumps, and cross-setting progress repositioning -- can't
|
// fragment anchors, percent jumps, and cross-setting progress repositioning -- can't
|
||||||
// resolve their landing page until the whole chapter is laid out, so they take the full
|
// resolve their landing page until the whole chapter is laid out, so they take the full
|
||||||
@@ -993,11 +1128,24 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
// The popup's own refresh is a plain FAST, so force the page that replaces it onto the HALF
|
// The popup's own refresh is a plain FAST, so force the page that replaces it onto the HALF
|
||||||
// ghost-cleanup path -- otherwise the "INDEXING" text ghosts under the rendered page.
|
// ghost-cleanup path -- otherwise the "INDEXING" text ghosts under the rendered page.
|
||||||
pagesUntilFullRefresh = 1;
|
pagesUntilFullRefresh = 1;
|
||||||
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
|
const auto popupFn = [this]() {
|
||||||
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
if (renderer.hasFrameBuffer()) {
|
||||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
}
|
||||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn)) {
|
};
|
||||||
|
buildLoan.release();
|
||||||
|
const auto buildSection = [&]() {
|
||||||
|
return section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||||
|
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||||
|
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||||
|
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn);
|
||||||
|
};
|
||||||
|
bool built = !heapTooLow && buildSection();
|
||||||
|
if (!built && SETTINGS.bluetoothEnabled) {
|
||||||
|
built = retryWithBleFreed(buildSection);
|
||||||
|
}
|
||||||
|
if (!built) {
|
||||||
|
silentRestartDefrag();
|
||||||
LOG_ERR("ERS", "Failed to persist page data to SD");
|
LOG_ERR("ERS", "Failed to persist page data to SD");
|
||||||
section.reset();
|
section.reset();
|
||||||
showBuildError();
|
showBuildError();
|
||||||
@@ -1036,10 +1184,22 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
|
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
|
||||||
pagesUntilFullRefresh = 1;
|
pagesUntilFullRefresh = 1;
|
||||||
}
|
}
|
||||||
if (!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
buildLoan.release();
|
||||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
// startBuild does the zip inflate (the big contiguous allocation), so it gets
|
||||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
// the BLE free-and-retry fallback too; it cleans up fully on failure, making a
|
||||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
// retry safe.
|
||||||
|
const auto beginBuild = [&]() {
|
||||||
|
return section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||||
|
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||||
|
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||||
|
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled);
|
||||||
|
};
|
||||||
|
bool started = !heapTooLow && beginBuild();
|
||||||
|
if (!started && SETTINGS.bluetoothEnabled) {
|
||||||
|
started = retryWithBleFreed(beginBuild);
|
||||||
|
}
|
||||||
|
if (!started) {
|
||||||
|
silentRestartDefrag();
|
||||||
LOG_ERR("ERS", "Failed to start section build");
|
LOG_ERR("ERS", "Failed to start section build");
|
||||||
section.reset();
|
section.reset();
|
||||||
showBuildError();
|
showBuildError();
|
||||||
@@ -1100,6 +1260,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
// ahead of the background builder; pages already built do no work here.
|
// ahead of the background builder; pages already built do no work here.
|
||||||
while (section->isPartial() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
while (section->isPartial() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||||
// Start a build to extend a partial toward the requested page.
|
// Start a build to extend a partial toward the requested page.
|
||||||
|
buildLoan.release();
|
||||||
if (!section->isBuilding() &&
|
if (!section->isBuilding() &&
|
||||||
!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight,
|
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight,
|
||||||
@@ -1122,6 +1283,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
}
|
}
|
||||||
// For an in-progress incremental build, make sure the page we're about to show has been laid out.
|
// For an in-progress incremental build, make sure the page we're about to show has been laid out.
|
||||||
if (section->isBuilding()) {
|
if (section->isBuilding()) {
|
||||||
|
buildLoan.release();
|
||||||
while (!section->isBuildComplete() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
while (!section->isBuildComplete() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
||||||
LOG_ERR("ERS", "Failed during incremental section build");
|
LOG_ERR("ERS", "Failed during incremental section build");
|
||||||
@@ -1132,6 +1294,14 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auto restoreFramebufferForDraw = [&buildLoan]() {
|
||||||
|
if (!buildLoan.restore()) {
|
||||||
|
ESP.restart();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// The requested page is now as built as it will get. If it still lands past the end,
|
// The requested page is now as built as it will get. If it still lands past the end,
|
||||||
// clamp to the last real page: the UINT16_MAX "last page" sentinel from backward chapter
|
// clamp to the last real page: the UINT16_MAX "last page" sentinel from backward chapter
|
||||||
// navigation, an explicit jump beyond a finished chapter, or a stale saved position.
|
// navigation, an explicit jump beyond a finished chapter, or a stale saved position.
|
||||||
@@ -1146,10 +1316,10 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
// a plain resume / unchanged pagination). If still building, this defers to loop() on completion.
|
// a plain resume / unchanged pagination). If still building, this defers to loop() on completion.
|
||||||
applyDeferredReposition();
|
applyDeferredReposition();
|
||||||
|
|
||||||
renderer.clearScreen();
|
|
||||||
|
|
||||||
if (section->pageCount == 0) {
|
if (section->pageCount == 0) {
|
||||||
LOG_DBG("ERS", "No pages to render");
|
LOG_DBG("ERS", "No pages to render");
|
||||||
|
if (!restoreFramebufferForDraw()) return;
|
||||||
|
renderer.clearScreen();
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_CHAPTER), true, EpdFontFamily::BOLD);
|
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_EMPTY_CHAPTER), true, EpdFontFamily::BOLD);
|
||||||
renderStatusBar();
|
renderStatusBar();
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
@@ -1160,6 +1330,8 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
|
|
||||||
if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
|
if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
|
||||||
LOG_DBG("ERS", "Page out of bounds: %d (max %d)", section->currentPage, section->pageCount);
|
LOG_DBG("ERS", "Page out of bounds: %d (max %d)", section->currentPage, section->pageCount);
|
||||||
|
if (!restoreFramebufferForDraw()) return;
|
||||||
|
renderer.clearScreen();
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_OUT_OF_BOUNDS), true, EpdFontFamily::BOLD);
|
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_OUT_OF_BOUNDS), true, EpdFontFamily::BOLD);
|
||||||
renderStatusBar();
|
renderStatusBar();
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
@@ -1188,6 +1360,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
if (giveUp) {
|
if (giveUp) {
|
||||||
LOG_ERR("ERS", "Page load retry limit reached, aborting");
|
LOG_ERR("ERS", "Page load retry limit reached, aborting");
|
||||||
pageLoadRetryCount = 0; // Reset so a later user-initiated navigation can try afresh
|
pageLoadRetryCount = 0; // Reset so a later user-initiated navigation can try afresh
|
||||||
|
if (!restoreFramebufferForDraw()) return;
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_PAGE_LOAD_ERROR), true, EpdFontFamily::BOLD);
|
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_PAGE_LOAD_ERROR), true, EpdFontFamily::BOLD);
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
@@ -1203,9 +1376,16 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
|||||||
// Collect footnotes from the loaded page
|
// Collect footnotes from the loaded page
|
||||||
currentPageFootnotes = std::move(p->footnotes);
|
currentPageFootnotes = std::move(p->footnotes);
|
||||||
|
|
||||||
|
if (!restoreFramebufferForDraw()) return;
|
||||||
|
renderer.clearScreen();
|
||||||
|
|
||||||
const auto start = millis();
|
const auto start = millis();
|
||||||
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
|
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
|
||||||
|
// Fragmentation tracker: free vs largest block after every page. A falling
|
||||||
|
// maxAlloc/free ratio across pages points at whichever allocation pattern the
|
||||||
|
// preceding lines show (mini rebuilds, kern reloads, BLE churn).
|
||||||
|
LOG_DBG("MEM", "post-render: free=%u maxAlloc=%u", (unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap());
|
||||||
}
|
}
|
||||||
saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages());
|
saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages());
|
||||||
|
|
||||||
@@ -1449,8 +1629,13 @@ void EpubReaderActivity::renderStatusBar() const {
|
|||||||
title = epub->getTitle();
|
title = epub->getTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (SETTINGS.bluetoothEnabled && !BleHid.isConnected()) {
|
||||||
|
const std::string btStatus = tr(STR_BT_CONNECTING_POPUP);
|
||||||
|
title = title.empty() ? btStatus : btStatus + " " + title;
|
||||||
|
}
|
||||||
|
|
||||||
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked,
|
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked,
|
||||||
section->isBuilding());
|
section->isBuilding(), BleHid.isConnected());
|
||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
|
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
|
||||||
|
|||||||
@@ -63,6 +63,21 @@ class EpubReaderActivity final : public Activity {
|
|||||||
SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH] = {};
|
SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH] = {};
|
||||||
int footnoteDepth = 0;
|
int footnoteDepth = 0;
|
||||||
|
|
||||||
|
// Heap floor for entering a section build. The layout code allocates freely (line-break DP
|
||||||
|
// arrays sized by word count, CSS rule lookups, glyph buffers) and under -fno-exceptions an
|
||||||
|
// OOM there abort()s the firmware instead of failing cleanly -- so a starved heap must be
|
||||||
|
// handled *before* the build, not after. Field data: builds succeed at ~46 KB free with BLE
|
||||||
|
// resident; abort() observed at ~11 KB free. CSS styling already degrades below 48 KB
|
||||||
|
// (MIN_FREE_HEAP_FOR_CSS), so 40 KB trades a few early BLE teardowns for not crashing.
|
||||||
|
static constexpr size_t BUILD_MIN_FREE_HEAP = 40 * 1024;
|
||||||
|
|
||||||
|
// Heap floor for rendering a page at all. Page deserialization (TextBlock word
|
||||||
|
// vectors/strings) and glyph caching allocate through throwing paths that abort()
|
||||||
|
// on OOM; below this floor render() sheds the BLE stack (~52 KB back, and it
|
||||||
|
// restores a large contiguous block) before touching the page. Field data: a
|
||||||
|
// session with no shed ground to <2.2 KB free and aborted on a page load.
|
||||||
|
static constexpr size_t RENDER_MIN_FREE_HEAP = 24 * 1024;
|
||||||
|
|
||||||
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
|
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
|
||||||
int orientedMarginBottom, int orientedMarginLeft);
|
int orientedMarginBottom, int orientedMarginLeft);
|
||||||
void renderStatusBar() const;
|
void renderStatusBar() const;
|
||||||
@@ -71,6 +86,28 @@ class EpubReaderActivity final : public Activity {
|
|||||||
// background build chunk never noticeably delays input or a pending render.
|
// background build chunk never noticeably delays input or a pending render.
|
||||||
static constexpr int BUILD_PAGES_PER_CHUNK = 8;
|
static constexpr int BUILD_PAGES_PER_CHUNK = 8;
|
||||||
static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2;
|
static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2;
|
||||||
|
|
||||||
|
// Skip background build ticks below this free-heap floor. The parse path grows
|
||||||
|
// word vectors of heap strings — throwing allocations that abort() on OOM under
|
||||||
|
// -fno-exceptions (field crash: bad_alloc in ParsedText::addWord during a
|
||||||
|
// background tick with the BLE stack resident). The tick is deferrable work:
|
||||||
|
// page-turn transients free up between turns and the build resumes; the render
|
||||||
|
// path still builds the page it actually needs regardless of this floor.
|
||||||
|
// Calibrated BETWEEN the measured states: steady reading with BLE resident runs at
|
||||||
|
// ~29.4 KB free / ~16.4 KB largest block (ticks are safe there — a 2-page parse
|
||||||
|
// transient is a few KB), while the field crash happened at 34.7 KB free with an
|
||||||
|
// ~11 KB largest block. A first cut at 32 KB/16 KB sat just ABOVE the healthy
|
||||||
|
// steady state, guaranteeing a pointless BLE shed the moment any build work was
|
||||||
|
// pending (the maxAlloc floor fired on a 12-byte shortfall).
|
||||||
|
static constexpr size_t BACKGROUND_BUILD_MIN_FREE_HEAP = 26 * 1024;
|
||||||
|
// Fragmentation floor for the same gate: free heap says how much memory exists;
|
||||||
|
// maxAlloc says whether any single allocation can actually have it.
|
||||||
|
static constexpr size_t BACKGROUND_BUILD_MIN_MAX_ALLOC = 13 * 1024;
|
||||||
|
// Gate for a background build tick: true when the heap can take parse allocations.
|
||||||
|
// When BLE is what's squeezing the heap, sheds it (build-pending deferral in the
|
||||||
|
// lifecycle then holds restarts off until the window is caught up) instead of
|
||||||
|
// stalling the build forever below the floors.
|
||||||
|
bool buildTickHeapGate();
|
||||||
// How many pages to keep laid out ahead of the reader for a still-building section. A page
|
// How many pages to keep laid out ahead of the reader for a still-building section. A page
|
||||||
// turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder
|
// turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder
|
||||||
// -- a tiny buffer is enough. The background build stops once the watermark is this far
|
// -- a tiny buffer is enough. The background build stops once the watermark is this far
|
||||||
@@ -119,6 +156,7 @@ class EpubReaderActivity final : public Activity {
|
|||||||
void loop() override;
|
void loop() override;
|
||||||
void render(RenderLock&& lock) override;
|
void render(RenderLock&& lock) override;
|
||||||
bool isReaderActivity() const override { return true; }
|
bool isReaderActivity() const override { return true; }
|
||||||
|
void requestGhostCleanup() override { pagesUntilFullRefresh = 1; }
|
||||||
ScreenshotInfo getScreenshotInfo() const override;
|
ScreenshotInfo getScreenshotInfo() const override;
|
||||||
CrossPointPosition getCurrentPosition() const;
|
CrossPointPosition getCurrentPosition() const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <I18n.h>
|
#include <I18n.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
|
||||||
|
#include "BleInput.h"
|
||||||
|
#include "CrossPointSettings.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "SilentRestart.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
@@ -22,7 +26,7 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu
|
|||||||
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes,
|
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes,
|
||||||
bool hasBookmarks) {
|
bool hasBookmarks) {
|
||||||
std::vector<MenuItem> items;
|
std::vector<MenuItem> items;
|
||||||
items.reserve(12);
|
items.reserve(13);
|
||||||
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
|
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
|
||||||
if (hasFootnotes) {
|
if (hasFootnotes) {
|
||||||
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
|
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
|
||||||
@@ -36,6 +40,7 @@ std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuI
|
|||||||
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
|
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::SCREENSHOT, StrId::STR_SCREENSHOT_BUTTON});
|
||||||
items.push_back({MenuAction::DISPLAY_QR, StrId::STR_DISPLAY_QR});
|
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::GO_HOME, StrId::STR_GO_HOME_BUTTON});
|
||||||
items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS});
|
items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS});
|
||||||
items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE});
|
items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE});
|
||||||
@@ -85,6 +90,24 @@ void EpubReaderMenuActivity::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedAction == MenuAction::TOGGLE_BLUETOOTH) {
|
||||||
|
// Just flip the preference and stay in the menu. The main-loop lifecycle check
|
||||||
|
// brings the BLE stack up/down to match, so start/stop has a single owner.
|
||||||
|
SETTINGS.bluetoothEnabled = SETTINGS.bluetoothEnabled ? 0 : 1;
|
||||||
|
SETTINGS.saveToFile();
|
||||||
|
// Turning BT on below the lifecycle's heap floor would otherwise wait
|
||||||
|
// until the heap happens to recover -- which a long session's fragmentation never
|
||||||
|
// gives back. The user asked for BT *now*: silent-restart into this book to
|
||||||
|
// defrag (fresh boot is ~118 KB free, comfortably above the floor), and BT
|
||||||
|
// auto-starts on the way back in.
|
||||||
|
if (SETTINGS.bluetoothEnabled && !BleHid.isRunning() && ESP.getFreeHeap() < bleinput::kStartMinFreeHeap) {
|
||||||
|
LOG_INF("ERM", "BT enabled below heap floor (%u); silent restart to defrag", ESP.getFreeHeap());
|
||||||
|
silentRestartToReader();
|
||||||
|
}
|
||||||
|
requestUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setResult(MenuResult{static_cast<int>(selectedAction), pendingOrientation, selectedPageTurnOption});
|
setResult(MenuResult{static_cast<int>(selectedAction), pendingOrientation, selectedPageTurnOption});
|
||||||
finish();
|
finish();
|
||||||
return;
|
return;
|
||||||
@@ -136,6 +159,12 @@ void EpubReaderMenuActivity::render(RenderLock&&) {
|
|||||||
} else if (value == MenuAction::AUTO_PAGE_TURN) {
|
} else if (value == MenuAction::AUTO_PAGE_TURN) {
|
||||||
// Render current page turn value on the right edge of the content area.
|
// Render current page turn value on the right edge of the content area.
|
||||||
return pageTurnLabels[selectedPageTurnOption];
|
return pageTurnLabels[selectedPageTurnOption];
|
||||||
|
} else if (value == MenuAction::TOGGLE_BLUETOOTH) {
|
||||||
|
if (SETTINGS.bluetoothEnabled) {
|
||||||
|
if (!BleHid.isRunning()) return tr(STR_CONNECTING);
|
||||||
|
return BleHid.isConnected() ? tr(STR_STATE_ON) : tr(STR_CONNECTING);
|
||||||
|
}
|
||||||
|
return tr(STR_STATE_OFF);
|
||||||
} else {
|
} else {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ class EpubReaderMenuActivity final : public Activity {
|
|||||||
DISPLAY_QR,
|
DISPLAY_QR,
|
||||||
GO_HOME,
|
GO_HOME,
|
||||||
SYNC,
|
SYNC,
|
||||||
DELETE_CACHE
|
DELETE_CACHE,
|
||||||
|
TOGGLE_BLUETOOTH
|
||||||
};
|
};
|
||||||
|
|
||||||
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
||||||
|
|||||||
@@ -31,5 +31,6 @@ class ReaderActivity final : public Activity {
|
|||||||
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath)
|
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath)
|
||||||
: Activity("Reader", renderer, mappedInput), initialBookPath(std::move(initialBookPath)) {}
|
: Activity("Reader", renderer, mappedInput), initialBookPath(std::move(initialBookPath)) {}
|
||||||
void onEnter() override;
|
void onEnter() override;
|
||||||
bool isReaderActivity() const override { return true; }
|
bool isReaderActivity() const override { return false; }
|
||||||
|
bool deferBluetoothStart() const override { return true; }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,5 +49,6 @@ class TxtReaderActivity final : public Activity {
|
|||||||
void loop() override;
|
void loop() override;
|
||||||
void render(RenderLock&&) override;
|
void render(RenderLock&&) override;
|
||||||
bool isReaderActivity() const override { return true; }
|
bool isReaderActivity() const override { return true; }
|
||||||
|
void requestGhostCleanup() override { pagesUntilFullRefresh = 1; }
|
||||||
ScreenshotInfo getScreenshotInfo() const override;
|
ScreenshotInfo getScreenshotInfo() const override;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,5 +46,6 @@ class XtcReaderActivity final : public Activity {
|
|||||||
void loop() override;
|
void loop() override;
|
||||||
void render(RenderLock&&) override;
|
void render(RenderLock&&) override;
|
||||||
bool isReaderActivity() const override { return true; }
|
bool isReaderActivity() const override { return true; }
|
||||||
|
void requestGhostCleanup() override { pagesUntilFullRefresh = 1; }
|
||||||
ScreenshotInfo getScreenshotInfo() const override;
|
ScreenshotInfo getScreenshotInfo() const override;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
#include "BleButtonMapActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <iterator>
|
||||||
|
|
||||||
|
#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;
|
||||||
|
// Start every mapping session from a clean slate: the user re-maps each remote
|
||||||
|
// button once, so a button can't be left bound to a stale action and there's no
|
||||||
|
// separate "clear mappings" step to remember.
|
||||||
|
std::fill(std::begin(SETTINGS.bleKeyMap), std::end(SETTINGS.bleKeyMap), CrossPointSettings::BleKeyMapEntry{});
|
||||||
|
SETTINGS.saveToFile();
|
||||||
|
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);
|
||||||
|
// Mutated via std::replace_if below and through `slot`; cppcheck's CI parse
|
||||||
|
// (no include paths) can't see the writes and suggests const.
|
||||||
|
// cppcheck-suppress constVariableReference
|
||||||
|
auto& map = SETTINGS.bleKeyMap;
|
||||||
|
using Entry = CrossPointSettings::BleKeyMapEntry;
|
||||||
|
const uint8_t kind = capturedKind;
|
||||||
|
const uint8_t value = capturedValue;
|
||||||
|
|
||||||
|
// One key per action: drop any other key currently bound to this action so the same
|
||||||
|
// action can't be triggered by two different remote buttons.
|
||||||
|
std::replace_if(
|
||||||
|
std::begin(map), std::end(map),
|
||||||
|
[&](const Entry& e) { return e.button == btn && !(e.keyKind == kind && e.keyValue == value); }, Entry{});
|
||||||
|
|
||||||
|
// Reuse the slot already bound to this key, else the first free slot.
|
||||||
|
auto* slot = std::find_if(std::begin(map), std::end(map), [&](const Entry& e) {
|
||||||
|
return e.button != 0xFF && e.keyKind == kind && e.keyValue == value;
|
||||||
|
});
|
||||||
|
if (slot == std::end(map)) {
|
||||||
|
slot = std::find_if(std::begin(map), std::end(map),
|
||||||
|
[](const Entry& e) { return e.button == 0xFF || e.keyKind == 0xFF; });
|
||||||
|
}
|
||||||
|
if (slot == std::end(map)) return false; // table full
|
||||||
|
|
||||||
|
slot->keyKind = kind;
|
||||||
|
slot->keyValue = value;
|
||||||
|
slot->button = btn;
|
||||||
|
SETTINGS.saveToFile();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
assignCapturedKey(kFunctions[functionIndex].button);
|
||||||
|
// Back to capturing so the user can map (or re-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,47 @@
|
|||||||
|
#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;
|
||||||
|
|
||||||
|
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,314 @@
|
|||||||
|
#include "BluetoothSettingsActivity.h"
|
||||||
|
|
||||||
|
#include <BleKeyboardHost.h>
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <Logging.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});
|
||||||
|
}
|
||||||
|
if (menuIndex >= static_cast<int>(menuRows.size())) menuIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BluetoothSettingsActivity::startScanView() {
|
||||||
|
LOG_INF("BLEUI", "scan view: begin running=%d scanning=%d devices=%u paired=%u", BleHid.isRunning(),
|
||||||
|
BleHid.isScanning(), BleHid.deviceCount(), BleHid.pairedCount());
|
||||||
|
view = View::Scan;
|
||||||
|
scanIndex = 0;
|
||||||
|
awaitingConnect = false;
|
||||||
|
lastLoggedScanState = false;
|
||||||
|
lastLoggedDeviceCount = 0xFF;
|
||||||
|
// The main-loop lifecycle owns steady-state start/stop, but a scan needs the stack
|
||||||
|
// this instant — entering this screen can precede the lifecycle's next tick, or its
|
||||||
|
// heap gate may have deferred the start. ensureStarted() is idempotent, and with
|
||||||
|
// this screen on top the lifecycle keeps the stack up (keepsBluetoothAlive).
|
||||||
|
if (!BleHid.isRunning() && !bleinput::ensureStarted()) {
|
||||||
|
LOG_ERR("BLEUI", "scan: BLE start failed (heap=%u)", ESP.getFreeHeap());
|
||||||
|
}
|
||||||
|
BleHid.startScan(kScanMs);
|
||||||
|
LOG_INF("BLEUI", "scan view: startScan requested scanning=%d devices=%u", BleHid.isScanning(), BleHid.deviceCount());
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BluetoothSettingsActivity::handleMenuConfirm() {
|
||||||
|
if (menuRows.empty()) return;
|
||||||
|
const Action action = menuRows[menuIndex].action;
|
||||||
|
switch (action) {
|
||||||
|
case Action::ToggleBt:
|
||||||
|
// Flip the preference only; the main-loop lifecycle check starts/stops the BLE
|
||||||
|
// stack to match (and shows the "BT Connecting..." popup). Single owner.
|
||||||
|
SETTINGS.bluetoothEnabled = SETTINGS.bluetoothEnabled ? 0 : 1;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const int scanCount = BleHid.deviceCount();
|
||||||
|
if (!awaitingConnect && scanCount == 0 && !BleHid.isScanning()) {
|
||||||
|
LOG_INF("BLEUI", "scan view: restart scan requested");
|
||||||
|
if (!BleHid.isRunning() && !bleinput::ensureStarted()) {
|
||||||
|
LOG_ERR("BLEUI", "scan restart: BLE start failed (heap=%u)", ESP.getFreeHeap());
|
||||||
|
}
|
||||||
|
BleHid.startScan(kScanMs);
|
||||||
|
LOG_INF("BLEUI", "scan view: restart scan state scanning=%d devices=%u", BleHid.isScanning(),
|
||||||
|
BleHid.deviceCount());
|
||||||
|
requestUpdate();
|
||||||
|
} else if (!awaitingConnect && scanIndex < scanCount) {
|
||||||
|
if (BleHid.isScanning()) BleHid.stopScan();
|
||||||
|
const auto& d = BleHid.device(static_cast<uint8_t>(scanIndex));
|
||||||
|
LOG_INF("BLEUI", "scan view: connect addr=%s name='%s' rssi=%d type=%u hid=%d conn=%d", d.addr, d.name, d.rssi,
|
||||||
|
d.addrType, d.hid, d.connectable);
|
||||||
|
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) {
|
||||||
|
const bool scanning = BleHid.isScanning();
|
||||||
|
const uint8_t deviceCount = BleHid.deviceCount();
|
||||||
|
if (scanning != lastLoggedScanState || deviceCount != lastLoggedDeviceCount) {
|
||||||
|
LOG_INF("BLEUI", "scan view: state scanning=%d devices=%u", scanning, deviceCount);
|
||||||
|
lastLoggedScanState = scanning;
|
||||||
|
lastLoggedDeviceCount = deviceCount;
|
||||||
|
}
|
||||||
|
if (scanning) 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) {
|
||||||
|
// Free2/3 remotes only advertise in the right slider mode — tell the user how.
|
||||||
|
GUI.drawHelpText(renderer, Rect{0, topOffset, pageWidth, 16}, tr(STR_BT_FREE_HINT1));
|
||||||
|
GUI.drawHelpText(renderer, Rect{0, topOffset + 16, pageWidth, 16}, tr(STR_BT_FREE_HINT2));
|
||||||
|
const int scanTop = topOffset + 38;
|
||||||
|
const int count = BleHid.deviceCount();
|
||||||
|
if (count == 0) {
|
||||||
|
GUI.drawHelpText(renderer, Rect{0, scanTop, pageWidth, 24},
|
||||||
|
BleHid.isScanning() ? tr(STR_SCANNING) : tr(STR_BT_NO_DEVICES));
|
||||||
|
} else {
|
||||||
|
GUI.drawList(
|
||||||
|
renderer, Rect{0, scanTop, pageWidth, contentHeight - 38}, count, scanIndex,
|
||||||
|
[this](int i) { return deviceLabel(i); }, nullptr, nullptr, nullptr, false);
|
||||||
|
}
|
||||||
|
} else { // Paired
|
||||||
|
const int count = BleHid.pairedCount();
|
||||||
|
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 bool scanCanRestart = view == View::Scan && BleHid.deviceCount() == 0 && !BleHid.isScanning();
|
||||||
|
const char* confirm = view == View::Menu ? tr(STR_SELECT) : scanCanRestart ? "Scan" : 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,65 @@
|
|||||||
|
#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;
|
||||||
|
bool keepsBluetoothAlive() const override { return true; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class View { Menu, Scan, Paired };
|
||||||
|
|
||||||
|
// Menu row actions.
|
||||||
|
enum class Action { ToggleBt, Scan, Disconnect, MapButtons, PairedDevices };
|
||||||
|
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;
|
||||||
|
bool lastLoggedScanState = false;
|
||||||
|
uint8_t lastLoggedDeviceCount = 0xFF;
|
||||||
|
|
||||||
|
void rebuildMenuRows();
|
||||||
|
void handleMenuConfirm();
|
||||||
|
void startScanView();
|
||||||
|
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 <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "BluetoothSettingsActivity.h"
|
||||||
#include "ButtonRemapActivity.h"
|
#include "ButtonRemapActivity.h"
|
||||||
#include "ClearCacheActivity.h"
|
#include "ClearCacheActivity.h"
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
@@ -59,6 +60,7 @@ void SettingsActivity::rebuildSettingsLists() {
|
|||||||
// Append device-only ACTION items
|
// Append device-only ACTION items
|
||||||
controlsSettings.insert(controlsSettings.begin(),
|
controlsSettings.insert(controlsSettings.begin(),
|
||||||
SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons));
|
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_WIFI_NETWORKS, SettingAction::Network));
|
||||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync));
|
systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync));
|
||||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser));
|
systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser));
|
||||||
@@ -295,6 +297,9 @@ void SettingsActivity::toggleCurrentSetting() {
|
|||||||
case SettingAction::Language:
|
case SettingAction::Language:
|
||||||
startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);
|
startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);
|
||||||
break;
|
break;
|
||||||
|
case SettingAction::Bluetooth:
|
||||||
|
startActivityForResult(std::make_unique<BluetoothSettingsActivity>(renderer, mappedInput), resultHandler);
|
||||||
|
break;
|
||||||
case SettingAction::None:
|
case SettingAction::None:
|
||||||
// Do nothing
|
// Do nothing
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ enum class SettingAction {
|
|||||||
SdFirmwareUpdate,
|
SdFirmwareUpdate,
|
||||||
Language,
|
Language,
|
||||||
DownloadFonts,
|
DownloadFonts,
|
||||||
|
Bluetooth,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct SettingInfo {
|
struct SettingInfo {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
// size: 16x16, generated from Lucide bluetooth.svg with FreeInk's icon generator.
|
||||||
|
static const uint8_t BluetoothStatusIcon[] = {0xFF, 0xFF, 0xFE, 0x7F, 0xFE, 0x3F, 0xFE, 0x1F, 0xF6, 0x4F, 0xFA,
|
||||||
|
0x5F, 0xFC, 0x3F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFC, 0x3F, 0xFA, 0x5F,
|
||||||
|
0xF6, 0x4F, 0xFE, 0x1F, 0xFE, 0x3F, 0xFE, 0x7F, 0xFF, 0xFF};
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
#include "I18n.h"
|
#include "I18n.h"
|
||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
|
#include "components/icons/bluetooth.h"
|
||||||
#include "components/icons/bookmark.h"
|
#include "components/icons/bookmark.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
@@ -23,8 +24,10 @@ constexpr int homeMarginTop = 30;
|
|||||||
constexpr int subtitleY = 738;
|
constexpr int subtitleY = 738;
|
||||||
constexpr int bookmarkStatusIconWidth = 16;
|
constexpr int bookmarkStatusIconWidth = 16;
|
||||||
constexpr int bookmarkStatusIconHeight = 14;
|
constexpr int bookmarkStatusIconHeight = 14;
|
||||||
constexpr int bookmarkStatusIconGap = 4;
|
|
||||||
constexpr int bookmarkStatusIconTopCrop = 2;
|
constexpr int bookmarkStatusIconTopCrop = 2;
|
||||||
|
constexpr int bluetoothStatusIconWidth = 16;
|
||||||
|
constexpr int bluetoothStatusIconHeight = 16;
|
||||||
|
constexpr int statusIconGap = 4;
|
||||||
|
|
||||||
bool statusBarTextLaneVisible() {
|
bool statusBarTextLaneVisible() {
|
||||||
return SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage ||
|
return SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage ||
|
||||||
@@ -43,6 +46,17 @@ void drawBookmarkStatusIcon(const GfxRenderer& renderer, const int x, const int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void drawBluetoothStatusIcon(const GfxRenderer& renderer, const int x, const int y) {
|
||||||
|
constexpr int bytesPerRow = bluetoothStatusIconWidth / 8;
|
||||||
|
for (int row = 0; row < bluetoothStatusIconHeight; ++row) {
|
||||||
|
for (int col = 0; col < bluetoothStatusIconWidth; ++col) {
|
||||||
|
const uint8_t byte = BluetoothStatusIcon[row * bytesPerRow + col / 8];
|
||||||
|
const uint8_t mask = 1U << (7 - (col % 8));
|
||||||
|
renderer.drawPixel(x + col, y + row, (byte & mask) == 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight) {
|
void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight) {
|
||||||
@@ -749,7 +763,8 @@ void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layou
|
|||||||
|
|
||||||
void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage,
|
void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage,
|
||||||
const int pageCount, std::string title, const int paddingBottom, const int textYOffset,
|
const int pageCount, std::string title, const int paddingBottom, const int textYOffset,
|
||||||
const bool fillMargin, const bool isPageBookmarked, const bool pageCountEstimated) const {
|
const bool fillMargin, const bool isPageBookmarked, const bool pageCountEstimated,
|
||||||
|
const bool bluetoothConnected) const {
|
||||||
auto metrics = UITheme::getInstance().getMetrics();
|
auto metrics = UITheme::getInstance().getMetrics();
|
||||||
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||||
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
||||||
@@ -845,9 +860,17 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw Bookmark
|
// Draw status icons
|
||||||
|
if (showStatusBarTextLane && bluetoothConnected) {
|
||||||
|
const int bluetoothGap = leftClusterWidth > 0 ? statusIconGap : 0;
|
||||||
|
const int bluetoothX = leftClusterX + leftClusterWidth + bluetoothGap;
|
||||||
|
const int bluetoothY = textY + 3;
|
||||||
|
drawBluetoothStatusIcon(renderer, bluetoothX, bluetoothY);
|
||||||
|
leftClusterWidth += bluetoothStatusIconWidth + bluetoothGap;
|
||||||
|
}
|
||||||
|
|
||||||
if (showStatusBarTextLane && isPageBookmarked) {
|
if (showStatusBarTextLane && isPageBookmarked) {
|
||||||
const int bookmarkGap = leftClusterWidth > 0 ? bookmarkStatusIconGap : 0;
|
const int bookmarkGap = leftClusterWidth > 0 ? statusIconGap : 0;
|
||||||
const int bookmarkX = leftClusterX + leftClusterWidth + bookmarkGap;
|
const int bookmarkX = leftClusterX + leftClusterWidth + bookmarkGap;
|
||||||
const int bookmarkY = textY + 5;
|
const int bookmarkY = textY + 5;
|
||||||
drawBookmarkStatusIcon(renderer, bookmarkX, bookmarkY);
|
drawBookmarkStatusIcon(renderer, bookmarkX, bookmarkY);
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ class BaseTheme {
|
|||||||
void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount,
|
void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount,
|
||||||
std::string title, const int paddingBottom = 0, const int textYOffset = 0,
|
std::string title, const int paddingBottom = 0, const int textYOffset = 0,
|
||||||
const bool fillMargin = true, const bool isPageBookmarked = false,
|
const bool fillMargin = true, const bool isPageBookmarked = false,
|
||||||
const bool pageCountEstimated = false) const;
|
const bool pageCountEstimated = false, const bool bluetoothConnected = false) const;
|
||||||
void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const;
|
void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const;
|
||||||
virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false,
|
virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false,
|
||||||
int contentStartX = 0, int contentWidth = 0) const;
|
int contentStartX = 0, int contentWidth = 0) const;
|
||||||
|
|||||||
+109
-2
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "BleInput.h"
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "KOReaderCredentialStore.h"
|
#include "KOReaderCredentialStore.h"
|
||||||
@@ -127,6 +128,12 @@ enum class BootResume : uint8_t {
|
|||||||
QuickResume, // wake from a quick-resume deep sleep (SD flag; survives power loss)
|
QuickResume, // wake from a quick-resume deep sleep (SD flag; survives power loss)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Latched in setup() from the read-and-clear of the RTC flag, so the reboot-loop
|
||||||
|
// guard in bootWasSilentRestart() has the answer for the whole session.
|
||||||
|
static bool bootWasSilentRestartFlag = false;
|
||||||
|
|
||||||
|
bool bootWasSilentRestart() { return bootWasSilentRestartFlag; }
|
||||||
|
|
||||||
// Latched true once enterDeepSleep() commits to sleeping, before it tears down
|
// Latched true once enterDeepSleep() commits to sleeping, before it tears down
|
||||||
// the current activity. WiFi activities call silentRestart() in onExit() to
|
// the current activity. WiFi activities call silentRestart() in onExit() to
|
||||||
// clear heap fragmentation on the way out, but deep sleep is a full chip reset
|
// clear heap fragmentation on the way out, but deep sleep is a full chip reset
|
||||||
@@ -262,6 +269,10 @@ void enterDeepSleep(bool fromTimeout = false) {
|
|||||||
WiFi.mode(WIFI_OFF);
|
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();
|
halTiltSensor.deepSleep();
|
||||||
display.deepSleep();
|
display.deepSleep();
|
||||||
LOG_DBG("MAIN", "Entering deep sleep");
|
LOG_DBG("MAIN", "Entering deep sleep");
|
||||||
@@ -325,6 +336,7 @@ void setup() {
|
|||||||
(isSilentReboot && silentRebootTarget <= SILENT_REBOOT_TARGET_READER) ? silentRebootTarget : 0;
|
(isSilentReboot && silentRebootTarget <= SILENT_REBOOT_TARGET_READER) ? silentRebootTarget : 0;
|
||||||
silentRebootMagic = 0;
|
silentRebootMagic = 0;
|
||||||
silentRebootTarget = 0;
|
silentRebootTarget = 0;
|
||||||
|
bootWasSilentRestartFlag = isSilentReboot;
|
||||||
|
|
||||||
gpio.begin();
|
gpio.begin();
|
||||||
powerManager.begin();
|
powerManager.begin();
|
||||||
@@ -478,6 +490,78 @@ void setup() {
|
|||||||
// Ensure we're not still holding the power button before leaving setup
|
// Ensure we're not still holding the power button before leaving setup
|
||||||
waitForPowerRelease();
|
waitForPowerRelease();
|
||||||
allowSleepAt = millis() + 2000;
|
allowSleepAt = millis() + 2000;
|
||||||
|
// Bluetooth is started lazily by the lifecycle check in loop() once a reader or the
|
||||||
|
// Bluetooth settings screen is on the stack — not here at boot — so home/browser and
|
||||||
|
// WiFi activities keep the ~50 KB the BLE stack would otherwise hold.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bring the BLE stack up or down to match the current context. BLE is only resident
|
||||||
|
// while a reader (page-turner input) or the Bluetooth settings screen (pairing) is on
|
||||||
|
// the stack AND WiFi is off. Heap-heavy reader phases may stop BLE directly; this
|
||||||
|
// lifecycle then restarts it only after the normal activity/render/heap gates pass.
|
||||||
|
void updateBluetoothLifecycle() {
|
||||||
|
const bool wanted =
|
||||||
|
SETTINGS.bluetoothEnabled && activityManager.bluetoothShouldBeActive() && WiFi.getMode() == WIFI_MODE_NULL;
|
||||||
|
if (wanted && !BleHid.isRunning() && activityManager.bluetoothStartDeferred()) {
|
||||||
|
static uint32_t lastActivityDeferLogMs = 0;
|
||||||
|
if (millis() - lastActivityDeferLogMs > 10000) {
|
||||||
|
lastActivityDeferLogMs = millis();
|
||||||
|
LOG_INF("BLELC", "start deferred: activity busy heap=%u maxAlloc=%u", ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Heap gate: NimBLE needs ~57 KB. If the reader cannot spare that yet, retry
|
||||||
|
// on the next loop without entering any separate BLE hold state.
|
||||||
|
// The BT settings screen is explicit user intent to run BLE right now (scan/pair is
|
||||||
|
// dead without the stack). Its floor only needs to cover NimBLE itself — the 100 KB
|
||||||
|
// reader floor reserves build/render headroom that never gets used there.
|
||||||
|
const bool explicitBtContext = activityManager.currentKeepsBluetoothAlive();
|
||||||
|
const size_t startFloor = explicitBtContext ? bleinput::kStartMinFreeHeapExplicit : bleinput::kStartMinFreeHeap;
|
||||||
|
if (wanted && !BleHid.isRunning() && activityManager.isReaderActivity() && !renderer.hasFrameBuffer()) {
|
||||||
|
static uint32_t lastFramebufferLoanDeferLogMs = 0;
|
||||||
|
if (millis() - lastFramebufferLoanDeferLogMs > 10000) {
|
||||||
|
lastFramebufferLoanDeferLogMs = millis();
|
||||||
|
LOG_INF("BLELC", "start deferred: framebuffer lent heap=%u maxAlloc=%u", ESP.getFreeHeap(),
|
||||||
|
ESP.getMaxAllocHeap());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wanted && !BleHid.isRunning() && activityManager.isReaderActivity() && RenderLock::peek()) {
|
||||||
|
static uint32_t lastReaderRenderDeferLogMs = 0;
|
||||||
|
if (millis() - lastReaderRenderDeferLogMs > 10000) {
|
||||||
|
lastReaderRenderDeferLogMs = millis();
|
||||||
|
LOG_INF("BLELC", "start deferred: reader render in progress heap=%u maxAlloc=%u", ESP.getFreeHeap(),
|
||||||
|
ESP.getMaxAllocHeap());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wanted && !BleHid.isRunning() && ESP.getFreeHeap() < startFloor) {
|
||||||
|
static uint32_t lastGateLogMs = 0;
|
||||||
|
if (millis() - lastGateLogMs > 10000) {
|
||||||
|
lastGateLogMs = millis();
|
||||||
|
LOG_INF("BLELC", "start deferred: heap %u floor %u", ESP.getFreeHeap(), (unsigned)startFloor);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wanted && !BleHid.isRunning()) {
|
||||||
|
LOG_INF("BLELC", "start requested enabled=%u reader=%d settings=%d wifi=%d paired=%u heap=%u maxAlloc=%u",
|
||||||
|
SETTINGS.bluetoothEnabled, activityManager.isReaderActivity(), activityManager.currentKeepsBluetoothAlive(),
|
||||||
|
WiFi.getMode(), BleHid.pairedCount(), ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||||
|
// Start immediately once the lifecycle gates pass. Do not draw a reconnect
|
||||||
|
// popup here: that schedules a reader redraw while BLE has just consumed ~53 KB,
|
||||||
|
// which can immediately trip the render heap shed path and create a start/stop loop.
|
||||||
|
if (!bleinput::ensureStarted()) {
|
||||||
|
LOG_ERR("BLELC", "start failed heap=%u maxAlloc=%u", ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LOG_INF("BLELC", "started paired=%u heap=%u maxAlloc=%u", BleHid.pairedCount(), ESP.getFreeHeap(),
|
||||||
|
ESP.getMaxAllocHeap());
|
||||||
|
} else if (!wanted && BleHid.isRunning()) {
|
||||||
|
LOG_INF("BLELC", "stop requested enabled=%u active=%d wifi=%d heap=%u maxAlloc=%u", SETTINGS.bluetoothEnabled,
|
||||||
|
activityManager.bluetoothShouldBeActive(), WiFi.getMode(), ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||||
|
bleinput::stop();
|
||||||
|
LOG_INF("BLELC", "stopped heap=%u maxAlloc=%u", ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
@@ -486,6 +570,24 @@ void loop() {
|
|||||||
static unsigned long lastMemPrint = 0;
|
static unsigned long lastMemPrint = 0;
|
||||||
|
|
||||||
gpio.update();
|
gpio.update();
|
||||||
|
updateBluetoothLifecycle(); // bring BLE up/down for the current activity context
|
||||||
|
static bool lastBleConnected = false;
|
||||||
|
BleHid.poll(); // drive BLE auto-reconnect + key auto-repeat (no-op when BT off)
|
||||||
|
const bool bleConnected = BleHid.isConnected();
|
||||||
|
if (bleConnected && !lastBleConnected) {
|
||||||
|
LOG_INF("BLELC", "connected name=%s heap=%u maxAlloc=%u", BleHid.connectedName(), ESP.getFreeHeap(),
|
||||||
|
ESP.getMaxAllocHeap());
|
||||||
|
if (activityManager.isReaderActivity() && !activityManager.currentKeepsBluetoothAlive()) {
|
||||||
|
activityManager.requestUpdate();
|
||||||
|
}
|
||||||
|
} else if (!bleConnected && lastBleConnected) {
|
||||||
|
LOG_INF("BLELC", "disconnected heap=%u maxAlloc=%u", ESP.getFreeHeap(), ESP.getMaxAllocHeap());
|
||||||
|
if (activityManager.isReaderActivity() && !activityManager.currentKeepsBluetoothAlive()) {
|
||||||
|
activityManager.requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastBleConnected = bleConnected;
|
||||||
|
mappedInputManager.pollBle(); // drain BLE keys -> logical-button overlay for this frame
|
||||||
halTiltSensor.update(SETTINGS.tiltPageTurn, SETTINGS.orientation, activityManager.isReaderActivity());
|
halTiltSensor.update(SETTINGS.tiltPageTurn, SETTINGS.orientation, activityManager.isReaderActivity());
|
||||||
|
|
||||||
renderer.setFadingFix(SETTINGS.fadingFix);
|
renderer.setFadingFix(SETTINGS.fadingFix);
|
||||||
@@ -516,7 +618,7 @@ void loop() {
|
|||||||
// Check for any user activity (button press or release) or active background work
|
// Check for any user activity (button press or release) or active background work
|
||||||
static unsigned long lastActivityTime = millis();
|
static unsigned long lastActivityTime = millis();
|
||||||
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || halTiltSensor.hadActivity() ||
|
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || halTiltSensor.hadActivity() ||
|
||||||
activityManager.preventAutoSleep()) {
|
mappedInputManager.bleHadActivityThisFrame() || activityManager.preventAutoSleep()) {
|
||||||
lastActivityTime = millis(); // Reset inactivity timer
|
lastActivityTime = millis(); // Reset inactivity timer
|
||||||
powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity
|
powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity
|
||||||
}
|
}
|
||||||
@@ -597,11 +699,16 @@ void loop() {
|
|||||||
powerManager.setPowerSaving(false); // Make sure we're at full performance when skipLoopDelay is requested
|
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
|
yield(); // Give FreeRTOS a chance to run tasks, but return immediately
|
||||||
} else {
|
} 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 the BLE stack is actually resident, regardless of input idleness.
|
||||||
|
if (!BleHid.isRunning() && millis() - lastActivityTime >= HalPowerManager::IDLE_POWER_SAVING_MS) {
|
||||||
// If we've been inactive for a while, increase the delay to save power
|
// If we've been inactive for a while, increase the delay to save power
|
||||||
powerManager.setPowerSaving(true); // Lower CPU frequency after extended inactivity
|
powerManager.setPowerSaving(true); // Lower CPU frequency after extended inactivity
|
||||||
delay(50);
|
delay(50);
|
||||||
} else {
|
} else {
|
||||||
|
if (BleHid.isRunning()) powerManager.setPowerSaving(false); // keep the BLE radio stable
|
||||||
// Short delay to prevent tight loop while still being responsive
|
// Short delay to prevent tight loop while still being responsive
|
||||||
delay(10);
|
delay(10);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user