Compare commits

...
Author SHA1 Message Date
Justin Mitchell 6174cbc613 Add RAM caching for EPUB image pixel data
Implement chunked RAM cache for .pxc pixel data to avoid repeated SD card reads during multi-pass grayscale rendering. Uses 16KB chunks up to 96KB total, heap-gated with fallback to streaming. Cache is released after each page render to avoid memory retention across page turns.
2026-07-20 17:32:10 -04:00
Justin Mitchell 9d147cade9 Deduplicate identical CSS files in EPUB parsing & probe images for dimensions instead of reading the full file
Some EPUB converters emit byte-identical stylesheets per chapter (100+ entries). Now scan the ZIP central directory once to identify duplicates by CRC32 and compressed size, then skip parsing identical files. This avoids redundant ZIP lookups and SD extraction round-trips while preserving all styles since rules merge into a global set. Also add extractItemToFile helper and allowEarlyStop parameter to readItemContentsToStream.
2026-07-20 17:16:54 -04:00
Justin Mitchell 792aab6d97 Disable ESP_SR to fix isolated core build
ESP_SR (speech recognition) only exists in the S3 core and its wrapper includes ESP_I2S.h, which the isolated core rebuild cannot resolve. Since ESP_SR is unused, disable it via CONFIG_ARDUINO_SELECTIVE_ESP_SR=n to allow the sticky environment to build successfully. Also ignore sdkconfig.sticky file.
2026-07-20 17:02:01 -04:00
Justin Mitchell b58b51bfbe Merge remote-tracking branch 'origin/develop' into feat-deferred-refresh 2026-07-20 16:36:07 -04:00
Justin Mitchell 98118d6e24 Improve heap management in EPUB rendering
Fix CPU pinning by tracking buildHeapPaused state when background build is blocked. Re-check heap gate after acquiring lock to handle race conditions where rendering expands glyph buffers. Refine plane buffer allocation to check both free heap and max contiguous block, preventing fragmentation issues that could cause OOM during page renders. Skip async refresh for image pages since they use blocking double-FAST path.
2026-07-17 12:16:32 -04:00
Justin Mitchell 3cc4069faa fix: suppress cppcheck missingInclude on fresh checkouts
On a fresh CI checkout cppcheck has no resolved include paths, so it
reports every project header as missing (~400 information-level lines)
and --fail-on-defect low fails the job. Suppress it alongside
missingIncludeSystem, matching the fix already proven on the BLE branch.
2026-07-14 21:37:11 -04:00
Justin Mitchell a3b8897977 fix: address review findings on prewarm gating and gitignore
Revalidate the section under RenderLock before the idle prewarm scan,
add a largest-block floor to its heap gate, rebuild the differential
baseline when the grayscale scratch allocation fails after an async
refresh, and anchor generated-file gitignore patterns to the repo root.
2026-07-14 16:29:33 -04:00
Justin Mitchell f7f1a6f8bf Merge remote-tracking branch 'origin/develop' into feat-deferred-refresh 2026-07-14 15:49:04 -04:00
Justin Mitchell 7cad3cfb0e Fix heap fragmentation issues
- Keep-if-fits font buffer reuse (SdCardFont), the page-turn fragmentation fix
- Background-build heap floors + buildTickHeapGate() (the ParsedText::addWord abort fix), with the BLE-shed branch removed
- KOSync TLS gate split (free ≥ 50K, largest block ≥ 20K)
- wolfSSL SP ECC flags + FP_MAX_BITS 8192 in the patch script
- custom_sdkconfig: timer-stack trims (~7KB) and, per your answer, the WiFi IRAM opts (~25-30KB); plus the cloud-component removal the hybrid build requires. All verified present in the generated sdkconfig after the build.
- Web server watchdog registration fix (this branch's handlers already call esp_task_wdt_reset without it)
2026-07-14 15:45:44 -04:00
Justin Mitchell e66de575a1 Add async display refresh support to HalDisplay
Introduces non-blocking refresh capability with displayBufferAsync() that starts panel waveform and returns immediately, plus waitRefreshComplete() to block until refresh finishes. Panels without async support fall back to blocking refresh. Requires framebuffer to remain untouched during refresh and differential baseline rebuild before next update.
2026-07-13 16:20:20 -04:00
Justin Mitchell bae29fc6ba Update freeink-sdk
1. X3 wb_gc passive byte (the one-byte 0x54 to 0x00, matching stock's passive WB table in the gc nudge bank) Every X3 grayscale render, reader AA, covers, and the sleep-cover nudge, gets it. 2. X4 blotchy grays on sleep covers: the displayGrayscaleBase(HALF_REFRESH) revert (the gray nudge LUT being calibrated against the HALF pixel state, not FULL's)
2026-07-13 02:23:59 -04:00
Justin Mitchell 379f49d165 Use HALF_REFRESH for sleep screens instead of FULL
Changes all sleep screen display refreshes from FULL_REFRESH to HALF_REFRESH to match OEM firmware behavior. The stock firmware uses a single-pass 0xD7 waveform for sleep screens, not the multi-flash GC waveform (0xF7) that FULL_REFRESH triggers. For grayscale mode, HALF_REFRESH is required because the gray nudge LUT is calibrated against the pixel charge state left by the single-pass waveform; using FULL_REFRESH causes blotchy noise in gray areas.
2026-07-13 01:08:05 -04:00
21 changed files with 852 additions and 101 deletions
+7
View File
@@ -25,3 +25,10 @@ lib/EpdFont/scripts/output/
# (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out.
.claude/*
!.claude/skills/
/managed_components
/.dummy
/CMakeLists.txt
/dependencies.lock
/sdkconfig.default
/sdkconfig.defaults
sdkconfig.sticky
+44 -17
View File
@@ -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 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
SdCardFont::~SdCardFont() { freeAll(); }
@@ -83,6 +99,9 @@ void SdCardFont::freeStyleMiniData(PerStyle& s) {
s.miniBitmap = nullptr;
s.miniIntervalCount = 0;
s.miniGlyphCount = 0;
s.miniIntervalCapacity = 0;
s.miniGlyphCapacity = 0;
s.miniBitmapCapacity = 0;
freeStyleMiniKern(s);
memset(&s.miniData, 0, sizeof(s.miniData));
s.epdFont.data = &s.stubData;
@@ -109,6 +128,9 @@ void SdCardFont::freeStyleMiniKern(PerStyle& s) {
s.miniKernRightEntryCount = 0;
s.miniKernLeftClassCount = 0;
s.miniKernRightClassCount = 0;
s.miniKernLeftCapacity = 0;
s.miniKernRightCapacity = 0;
s.miniKernMatrixCapacity = 0;
}
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++;
}
// Step 4: allocate the three mini buffers. The matrix is <1KB in practice
// (<30 × <30 × 1 byte) so fragmentation is a non-issue.
// Step 4: size the three mini buffers (reused across pages when they fit; the
// 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;
s.miniKernLeftClasses = new (std::nothrow) EpdKernClassEntry[miniLeftCount];
s.miniKernRightClasses = new (std::nothrow) EpdKernClassEntry[miniRightCount];
s.miniKernMatrix = new (std::nothrow) int8_t[matrixBytes];
if (!s.miniKernLeftClasses || !s.miniKernRightClasses || !s.miniKernMatrix) {
if (!ensureArrayCapacity(s.miniKernLeftClasses, s.miniKernLeftCapacity, miniLeftCount) ||
!ensureArrayCapacity(s.miniKernRightClasses, s.miniKernRightCapacity, miniRightCount) ||
!ensureArrayCapacity(s.miniKernMatrix, s.miniKernMatrixCapacity, matrixBytes)) {
LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u,
matrixBytes);
freeStyleMiniKern(s);
@@ -793,12 +815,19 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
return missed;
}
// Build mini intervals from sorted codepoints
freeStyleMiniData(s);
// Build mini intervals from sorted codepoints. Reset counts and fall back to the
// 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;
s.miniIntervals = new (std::nothrow) EpdUnicodeInterval[intervalCapacity];
if (!s.miniIntervals) {
if (!ensureArrayCapacity(s.miniIntervals, s.miniIntervalCapacity, validCount)) {
LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx);
delete[] mappings;
return static_cast<int>(cpCount);
@@ -816,15 +845,14 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
}
}
// Allocate mini glyph array
s.miniGlyphCount = validCount;
s.miniGlyphs = new (std::nothrow) EpdGlyph[s.miniGlyphCount];
if (!s.miniGlyphs) {
// Mini glyph array (reused across pages when it fits)
if (!ensureArrayCapacity(s.miniGlyphs, s.miniGlyphCapacity, validCount)) {
LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx);
delete[] mappings;
freeStyleMiniData(s);
return static_cast<int>(cpCount);
}
s.miniGlyphCount = validCount;
// Build sorted read order for sequential I/O
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;
}
s.miniBitmap = new (std::nothrow) uint8_t[totalBitmapSize > 0 ? totalBitmapSize : 1];
if (!s.miniBitmap) {
if (!ensureArrayCapacity(s.miniBitmap, s.miniBitmapCapacity, totalBitmapSize)) {
LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx);
delete[] readOrder;
delete[] mappings;
+14 -1
View File
@@ -168,13 +168,22 @@ class SdCardFont {
// Stub EpdFontData returned when not prewarmed
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{};
EpdUnicodeInterval* miniIntervals = nullptr;
EpdGlyph* miniGlyphs = nullptr;
uint8_t* miniBitmap = nullptr;
uint32_t miniIntervalCount = 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
// prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints
@@ -189,6 +198,10 @@ class SdCardFont {
uint8_t miniKernLeftClassCount = 0;
uint8_t miniKernRightClassCount = 0;
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
EpdFont epdFont{&stubData};
+56 -4
View File
@@ -256,8 +256,44 @@ void Epub::parseCssFiles() const {
return;
}
// Some converters emit one byte-identical stylesheet per chapter (100+ .css
// entries), and each parse costs a zip locate plus an SD extract round-trip.
// Map every CSS path to its central-directory (CRC32, compressed size) in a
// single scan and parse only the first of each identical pair. Rules merge
// into one global set, so dropping exact duplicates cannot lose styles. A
// path that never matches a directory entry keeps key 0 and always parses.
std::vector<uint64_t> dedupKeys(cssFiles.size(), 0);
if (cssFiles.size() > 1) {
std::unordered_map<std::string, size_t> pathToIndex;
pathToIndex.reserve(cssFiles.size());
for (size_t i = 0; i < cssFiles.size(); i++) {
pathToIndex.emplace(FsHelpers::normalisePath(cssFiles[i]), i);
}
ZipFile(filepath).enumerateFileEntries([&](std::string_view entryPath, uint32_t crc32, uint32_t compressedSize) {
if (!FsHelpers::hasCssExtension(entryPath)) {
return;
}
const auto it = pathToIndex.find(std::string{entryPath});
if (it != pathToIndex.end()) {
dedupKeys[it->second] = (static_cast<uint64_t>(crc32) << 32) | compressedSize;
}
});
}
std::vector<uint64_t> seenKeys;
seenKeys.reserve(cssFiles.size());
size_t skippedDuplicates = 0;
// No cache yet - parse CSS files
for (const auto& cssPath : cssFiles) {
for (size_t cssIndex = 0; cssIndex < cssFiles.size(); cssIndex++) {
const auto& cssPath = cssFiles[cssIndex];
const uint64_t dedupKey = dedupKeys[cssIndex];
if (dedupKey != 0) {
if (std::find(seenKeys.begin(), seenKeys.end(), dedupKey) != seenKeys.end()) {
skippedDuplicates++;
continue;
}
seenKeys.push_back(dedupKey);
}
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
// Check heap before parsing - CSS parsing allocates heavily
@@ -312,7 +348,8 @@ void Epub::parseCssFiles() const {
LOG_ERR("EBP", "Failed to save CSS rules to cache");
}
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files (%zu identical duplicates skipped)", cssParser->ruleCount(),
cssFiles.size(), skippedDuplicates);
cssParser->clear();
}
@@ -728,14 +765,29 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
return content;
}
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const {
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize,
const bool allowEarlyStop) const {
if (itemHref.empty()) {
LOG_DBG("EBP", "Failed to read item, empty href");
return false;
}
const std::string path = FsHelpers::normalisePath(itemHref);
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize);
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize, allowEarlyStop);
}
bool Epub::extractItemToFile(const std::string& itemHref, const std::string& destPath) const {
HalFile out;
if (!Storage.openFileForWrite("EBP", destPath, out)) {
return false;
}
const bool ok = readItemContentsToStream(itemHref, out, 4096);
out.flush();
out.close();
if (!ok) {
Storage.remove(destPath.c_str());
}
return ok;
}
bool Epub::getItemSize(const std::string& itemHref, size_t* size) const {
+4 -1
View File
@@ -59,7 +59,10 @@ class Epub {
bool generateThumbBmp(int height) const;
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
bool trailingNullByte = false) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize,
bool allowEarlyStop = false) const;
// Extract an item to a file on SD. On failure the partial file is removed.
bool extractItemToFile(const std::string& itemHref, const std::string& destPath) const;
bool getItemSize(const std::string& itemHref, size_t* size) const;
BookMetadataCache::SpineEntry getSpineItem(int spineIndex) const;
BookMetadataCache::TocEntry getTocItem(int tocIndex) const;
+4 -1
View File
@@ -17,7 +17,10 @@ namespace {
// v30: Arabic shaping changed both drawing and measurement (getTextAdvanceX now
// measures the shaped visual text); cached word positions from v29 no longer
// match what drawText renders.
constexpr uint8_t SECTION_FILE_VERSION = 31;
// v32: ImageBlock serializes the book-internal source href after the cache path
// (lazy extraction: images are header-probed at build time and extracted on
// first render).
constexpr uint8_t SECTION_FILE_VERSION = 32;
// Written into the version field while a build is in progress; patched to
// SECTION_FILE_VERSION only when the build is finalized. An abandoned /
// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects
+150 -8
View File
@@ -3,9 +3,12 @@
#include <FontCacheManager.h>
#include <GfxRenderer.h>
#include <Logging.h>
#include <Memory.h>
#include <Serialization.h>
#include <cstdlib>
#include <cstring>
#include <new>
#include "Epub/converters/DirectPixelWriter.h"
#include "Epub/converters/ImageDecoderFactory.h"
@@ -15,8 +18,16 @@
// - uint16_t height
// - uint8_t pixels[...] - 2 bits per pixel, packed (4 pixels per byte), row-major order
ImageBlock::ImageBlock(const std::string& imagePath, int16_t width, int16_t height)
: imagePath(imagePath), width(width), height(height) {}
ImageBlock::ImageBlock(const std::string& imagePath, const std::string& srcPath, int16_t width, int16_t height)
: imagePath(imagePath), srcPath(srcPath), width(width), height(height) {}
void* ImageBlock::extractCtx = nullptr;
ImageBlock::ExtractFn ImageBlock::extractFn = nullptr;
void ImageBlock::setExtractor(void* ctx, ExtractFn fn) {
extractCtx = ctx;
extractFn = fn;
}
bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str()); }
@@ -79,8 +90,113 @@ void rememberImageFailure(const std::string& path) {
failedImageHashes[failedImageCount++] = imagePathHash(path);
}
// --- Per-page-render RAM slot for the pixel cache ----------------------------
// The tiled grayscale flow re-renders an image page once for the BW
// double-refresh and again for every band of both gray planes, and each pass
// re-read the whole .pxc off SD (~100 ms for a full-page image, ~13 passes).
// Column clipping cannot reduce the SD traffic: the row stride (~100 B) is
// smaller than an SD sector, so every sector is touched regardless of the band
// window. Instead the first pass loads the payload into RAM and later passes
// render from it. Chunked allocation because a single full-image block (up to
// 96 KB) rarely fits the fragmented mid-render heap; each chunk is heap-gated
// and any failure falls back to the streaming path unchanged. The reader
// releases the slot when the page render completes, so nothing stays resident
// across page turns.
constexpr size_t PXC_CHUNK_SHIFT = 14; // 16 KB chunks
constexpr size_t PXC_CHUNK_SIZE = 1u << PXC_CHUNK_SHIFT;
constexpr size_t PXC_MAX_CHUNKS = 6; // 96 KB: a full-screen 2bpp image
constexpr size_t PXC_HEAP_RESERVE = 24 * 1024;
constexpr size_t PXC_MAX_ALLOC_RESERVE = 8 * 1024;
// Rows can straddle a chunk boundary; they are reassembled into a stack
// buffer. (screenWidth + 3) / 4 caps at 200 B for an 800px panel.
constexpr int PXC_MAX_BYTES_PER_ROW = 208;
std::unique_ptr<uint8_t[]> pxcChunks[PXC_MAX_CHUNKS];
uint64_t pxcSlotHash = 0;
uint16_t pxcSlotWidth = 0;
uint16_t pxcSlotHeight = 0;
void releasePxcSlot() {
for (auto& chunk : pxcChunks) chunk.reset();
pxcSlotHash = 0;
pxcSlotWidth = 0;
pxcSlotHeight = 0;
}
const uint8_t* pxcRowPtr(size_t rowStart, int bytesPerRow, uint8_t* tempRow) {
const size_t chunk = rowStart >> PXC_CHUNK_SHIFT;
const size_t offset = rowStart & (PXC_CHUNK_SIZE - 1);
if (offset + bytesPerRow <= PXC_CHUNK_SIZE) {
return pxcChunks[chunk].get() + offset;
}
const size_t firstPart = PXC_CHUNK_SIZE - offset;
memcpy(tempRow, pxcChunks[chunk].get() + offset, firstPart);
memcpy(tempRow + firstPart, pxcChunks[chunk + 1].get(), bytesPerRow - firstPart);
return tempRow;
}
// cacheFile is positioned just past the header. True when the slot holds the
// full pixel payload for this cache path afterward.
bool loadPxcSlot(uint64_t cacheHash, HalFile& cacheFile, uint16_t cachedWidth, uint16_t cachedHeight, int bytesPerRow) {
releasePxcSlot();
if (bytesPerRow > PXC_MAX_BYTES_PER_ROW) {
return false;
}
size_t remaining = (size_t)bytesPerRow * cachedHeight;
const size_t chunkCount = (remaining + PXC_CHUNK_SIZE - 1) >> PXC_CHUNK_SHIFT;
if (chunkCount == 0 || chunkCount > PXC_MAX_CHUNKS) {
return false;
}
for (size_t i = 0; i < chunkCount; i++) {
const size_t want = remaining < PXC_CHUNK_SIZE ? remaining : PXC_CHUNK_SIZE;
if (ESP.getFreeHeap() < remaining + PXC_HEAP_RESERVE || ESP.getMaxAllocHeap() < want + PXC_MAX_ALLOC_RESERVE) {
releasePxcSlot();
return false;
}
pxcChunks[i] = makeUniqueNoThrow<uint8_t[]>(want);
if (!pxcChunks[i] || cacheFile.read(pxcChunks[i].get(), want) != static_cast<int>(want)) {
releasePxcSlot();
return false;
}
remaining -= want;
}
pxcSlotHash = cacheHash;
pxcSlotWidth = cachedWidth;
pxcSlotHeight = cachedHeight;
return true;
}
void renderRowsFromPxcSlot(GfxRenderer& renderer, int x, int y) {
const int bytesPerRow = (pxcSlotWidth + 3) / 4;
uint8_t tempRow[PXC_MAX_BYTES_PER_ROW];
DirectPixelWriter pw;
pw.init(renderer);
for (int row = 0; row < pxcSlotHeight; row++) {
const uint8_t* rowBuffer = pxcRowPtr((size_t)row * bytesPerRow, bytesPerRow, tempRow);
pw.beginRow(y + row);
int colStart, colEnd;
pw.bandColRange(x, pxcSlotWidth, colStart, colEnd);
for (int col = colStart; col < colEnd; col++) {
const int byteIdx = col >> 2; // col / 4
const int bitShift = 6 - (col & 3) * 2; // MSB first within byte
const uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03;
pw.writePixel(x + col, pixelValue);
}
}
}
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
int expectedHeight) {
// A later pass of the same page render: the payload is already in RAM, skip
// the file entirely.
const uint64_t cacheHash = imagePathHash(cachePath);
if (pxcSlotHash == cacheHash && pxcSlotWidth != 0) {
renderRowsFromPxcSlot(renderer, x, y);
return true;
}
HalFile cacheFile;
if (!Storage.openFileForRead("IMG", cachePath, cacheFile)) {
return false;
@@ -98,12 +214,24 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
LOG_DBG("IMG", "Loading from cache: %s (%dx%d)", cachePath.c_str(), cachedWidth, cachedHeight);
// Read several rows per SD access. A full-page image is re-rendered on every
// grayscale strip pass (~14x per page), and a one-row-per-read loop here means
// cachedHeight (~728) tiny reads through the storage mutex + SdFat each time —
// the dominant cost of displaying an image page. Batching rows into a ~4KB
// buffer cuts that to ~20 reads per pass without holding the whole image.
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
// First pass of a page render: try to pull the payload into the RAM slot so
// the remaining ~12 passes skip SD entirely.
if (loadPxcSlot(cacheHash, cacheFile, cachedWidth, cachedHeight, bytesPerRow)) {
renderRowsFromPxcSlot(renderer, x, y);
LOG_DBG("IMG", "Cache render complete (payload now in RAM)");
return true;
}
// Streaming fallback (slot didn't fit). A failed slot load may have consumed
// part of the payload; rewind to just past the header.
cacheFile.seek(4);
// Read several rows per SD access. A one-row-per-read loop here means
// cachedHeight (~728) tiny reads through the storage mutex + SdFat; batching
// rows into a ~4KB buffer cuts that to ~20 reads per pass without holding the
// whole image.
int rowsPerRead = 4096 / bytesPerRow;
if (rowsPerRead < 1) rowsPerRead = 1;
if (rowsPerRead > cachedHeight) rowsPerRead = cachedHeight;
@@ -176,6 +304,8 @@ bool ImageBlock::needsDecode() const { return !imageFailedThisSession(imagePath)
void ImageBlock::clearSessionRenderFailures() { failedImageCount = 0; }
void ImageBlock::releaseRenderCache() { releasePxcSlot(); }
void ImageBlock::renderPlaceholder(GfxRenderer& renderer, const int x, const int y) const {
renderer.fillRect(x, y, width, height, true);
if (width > 2 && height > 2) {
@@ -225,6 +355,15 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
return; // Successfully rendered from cache
}
// The build only header-probed the image for dimensions; pull the actual
// file out of the book now, on first visit to the page.
if (!srcPath.empty() && extractFn && !Storage.exists(imagePath.c_str())) {
LOG_DBG("IMG", "Lazy-extracting %s -> %s", srcPath.c_str(), imagePath.c_str());
if (!extractFn(extractCtx, srcPath.c_str(), imagePath.c_str())) {
LOG_ERR("IMG", "Lazy extraction failed: %s", srcPath.c_str());
}
}
// No cache - need to decode the image
// Check if image file exists
HalFile file;
@@ -280,6 +419,7 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
bool ImageBlock::serialize(HalFile& file) {
serialization::writeString(file, imagePath);
serialization::writeString(file, srcPath);
serialization::writePod(file, width);
serialization::writePod(file, height);
return true;
@@ -287,9 +427,11 @@ bool ImageBlock::serialize(HalFile& file) {
std::unique_ptr<ImageBlock> ImageBlock::deserialize(HalFile& file) {
std::string path;
std::string src;
serialization::readString(file, path);
serialization::readString(file, src);
int16_t w, h;
serialization::readPod(file, w);
serialization::readPod(file, h);
return std::unique_ptr<ImageBlock>(new ImageBlock(path, w, h));
return std::unique_ptr<ImageBlock>(new (std::nothrow) ImageBlock(path, src, w, h));
}
+20 -1
View File
@@ -8,7 +8,7 @@
class ImageBlock final : public Block {
public:
ImageBlock(const std::string& imagePath, int16_t width, int16_t height);
ImageBlock(const std::string& imagePath, const std::string& srcPath, int16_t width, int16_t height);
~ImageBlock() override = default;
const std::string& getImagePath() const { return imagePath; }
@@ -21,6 +21,21 @@ class ImageBlock final : public Block {
void renderPlaceholder(GfxRenderer& renderer, int x, int y) const;
static void clearSessionRenderFailures();
// A page render draws its image up to ~13 times (BW double-refresh plus every
// grayscale band pass), and each draw streams the whole .pxc off SD. The
// first draw caches the pixel payload in RAM (chunked, heap-gated, falls back
// to streaming when it doesn't fit); the reader calls this when the page
// render completes so nothing stays resident between pages.
static void releaseRenderCache();
// Lazy extraction hook: the section build only header-probes images for their
// dimensions; the file at imagePath is extracted out of the book on first
// render, via this callback (function pointer + context, not std::function —
// this is render-loop code). Registered by the reader activity that owns the
// Epub, cleared on its exit.
using ExtractFn = bool (*)(void* ctx, const char* srcPath, const char* destPath);
static void setExtractor(void* ctx, ExtractFn fn);
BlockType getType() override { return IMAGE_BLOCK; }
bool isEmpty() override { return false; }
@@ -30,6 +45,10 @@ class ImageBlock final : public Block {
private:
std::string imagePath;
std::string srcPath; // book-internal source href; empty once known-extracted
int16_t width;
int16_t height;
static void* extractCtx;
static ExtractFn extractFn;
};
+144
View File
@@ -0,0 +1,144 @@
#include "ImageDimsProbe.h"
#include <cstdint>
namespace {
// SOFn markers carry the frame dimensions. C4 (DHT), C8 (JPG extension) and
// CC (DAC) share the 0xCn range but are not frame headers.
bool isJpegSof(const uint8_t marker) {
return marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC;
}
constexpr uint8_t PNG_SIG[8] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
} // namespace
bool ImageDimsProbe::feed(const uint8_t b) {
switch (state) {
case State::Sniff:
if (b == 0xFF) {
state = State::JpegSoi;
} else if (b == PNG_SIG[0]) {
state = State::PngHeader;
} else {
state = State::Failed;
return false;
}
pos = 1;
return true;
case State::PngHeader:
// Bytes 1..7: signature; 8..11: IHDR length; 12..15: "IHDR"; 16..23: dims.
if (pos < 8) {
if (b != PNG_SIG[pos]) {
state = State::Failed;
return false;
}
} else if (pos >= 12 && pos < 16) {
if (b != "IHDR"[pos - 12]) {
state = State::Failed;
return false;
}
} else if (pos >= 16 && pos < 20) {
width = static_cast<uint16_t>((static_cast<uint32_t>(width) << 8) | b);
} else if (pos >= 20 && pos < 24) {
height = static_cast<uint16_t>((static_cast<uint32_t>(height) << 8) | b);
if (pos == 23) {
state = State::Done;
pos++;
return false;
}
}
pos++;
return true;
case State::JpegSoi:
if (b != 0xD8) {
state = State::Failed;
return false;
}
state = State::JpegFf;
return true;
case State::JpegFf:
if (b != 0xFF) {
state = State::Failed;
return false;
}
state = State::JpegMarker;
return true;
case State::JpegMarker:
if (b == 0xFF) return true; // fill bytes before a marker are legal
if (b == 0x01 || (b >= 0xD0 && b <= 0xD8)) {
// TEM / RSTn / SOI: standalone, no length field.
state = State::JpegFf;
return true;
}
if (b == 0xD9 || b == 0xDA) {
// EOI or SOS before any SOF: no dimensions to be found.
state = State::Failed;
return false;
}
sofPending = isJpegSof(b);
state = State::JpegLenHi;
return true;
case State::JpegLenHi:
segLen = static_cast<uint16_t>(b << 8);
state = State::JpegLenLo;
return true;
case State::JpegLenLo:
segLen = static_cast<uint16_t>(segLen | b);
if (segLen < 2 || (sofPending && segLen < 7)) {
state = State::Failed;
return false;
}
if (sofPending) {
sofFill = 0;
state = State::JpegSof;
} else if (segLen == 2) {
state = State::JpegFf;
} else {
skipLeft = static_cast<uint32_t>(segLen) - 2;
state = State::JpegSkip;
}
return true;
case State::JpegSkip:
if (--skipLeft == 0) state = State::JpegFf;
return true;
case State::JpegSof:
sofBuf[sofFill++] = b;
if (sofFill == 5) {
height = static_cast<uint16_t>((sofBuf[1] << 8) | sofBuf[2]);
width = static_cast<uint16_t>((sofBuf[3] << 8) | sofBuf[4]);
state = State::Done;
return false;
}
return true;
case State::Done:
case State::Failed:
return false;
}
return false;
}
size_t ImageDimsProbe::write(const uint8_t b) { return feed(b) ? 1 : 0; }
size_t ImageDimsProbe::write(const uint8_t* data, const size_t len) {
for (size_t i = 0; i < len; i++) {
if (!feed(data[i])) return i; // short write: polite early stop
}
return len;
}
bool ImageDimsProbe::getDimensions(ImageDimensions& out) const {
if (state != State::Done || width == 0 || height == 0 || width > INT16_MAX || height > INT16_MAX) {
return false;
}
out.width = static_cast<int16_t>(width);
out.height = static_cast<int16_t>(height);
return true;
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <Print.h>
#include "ImageToFramebufferDecoder.h"
// Streaming JPEG/PNG header parser: finds image dimensions from the first few
// KB of a compressed stream without inflating the whole image. Feed bytes via
// the Print interface (e.g. Epub::readItemContentsToStream with
// allowEarlyStop=true); write() returns short once the dimensions are known or
// the stream is known to be unusable, which the zip layer treats as a polite
// early stop rather than an error.
//
// JPEG: walks marker segments (skipping EXIF/APPn of any size statefully, so
// nothing is buffered) until a SOFn frame header yields the dimensions.
// PNG: reads the IHDR fields at their fixed offsets (bytes 16..23).
class ImageDimsProbe : public Print {
public:
size_t write(uint8_t b) override;
size_t write(const uint8_t* data, size_t len) override;
// True only when a valid header was found; fills `out`.
bool getDimensions(ImageDimensions& out) const;
private:
bool feed(uint8_t b); // returns false once parsing is finished (found or failed)
enum class State : uint8_t {
Sniff, // first byte decides the format
PngHeader, // PNG signature + IHDR at fixed offsets
JpegSoi, // second SOI byte (0xD8)
JpegFf, // expect a 0xFF marker prefix
JpegMarker, // marker type byte (0xFF padding allowed)
JpegLenHi, // segment length, high byte
JpegLenLo, // segment length, low byte
JpegSkip, // skipping a non-SOF segment body
JpegSof, // collecting the 5 SOF bytes: precision, height(2), width(2)
Done,
Failed,
};
State state = State::Sniff;
uint32_t pos = 0; // absolute stream offset (PNG fixed-offset parsing)
uint32_t skipLeft = 0; // remaining segment bytes to skip
uint16_t segLen = 0;
bool sofPending = false; // current segment is a SOF frame header
uint8_t sofBuf[5] = {0};
uint8_t sofFill = 0;
uint16_t width = 0;
uint16_t height = 0;
};
+29 -10
View File
@@ -15,6 +15,7 @@
#include "Epub.h"
#include "Epub/Page.h"
#include "Epub/converters/ImageDecoderFactory.h"
#include "Epub/converters/ImageDimsProbe.h"
#include "Epub/converters/ImageToFramebufferDecoder.h"
#include "Epub/htmlEntities.h"
@@ -554,7 +555,25 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
std::string cachedImagePath = self->imageBasePath + std::to_string(self->imageCounter++) + ext;
// Extract image to cache file
{
// Probe the dimensions from the entry's first bytes (early-aborted
// inflate, a few KB) instead of extracting the whole image now —
// extraction is deferred to the first render of the page (see
// ImageBlock's lazy extractor). This is what keeps first-open of an
// image-heavy chapter from stalling for seconds per image.
ImageDimensions dims = {0, 0};
ImageDimsProbe headerProbe;
self->epub->readItemContentsToStream(resolvedPath, headerProbe, 1024, /*allowEarlyStop=*/true);
bool gotDimensions = headerProbe.getDimensions(dims);
if (!gotDimensions) {
// No header within the stream (rare) — fall back to extracting the
// whole image and probing the file. That can take seconds, so
// surface the indexing popup first (single-shot per parser).
if (self->popupFn && !self->imagePopupFired) {
self->imagePopupFired = true;
self->popupFn();
}
HalFile cachedImageFile;
bool extractSuccess = false;
if (Storage.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) {
@@ -562,20 +581,21 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
cachedImageFile.flush();
cachedImageFile.close();
}
if (extractSuccess) {
// Get image dimensions, retrying to absorb SD-card sync latency on slow
// cards. Replaces a blanket delay(50) that cost ~50ms on every image, and
// closes the silent-drop bug where a single getDimensions failure was fatal.
ImageDimensions dims = {0, 0};
// Retry to absorb SD-card sync latency on slow cards, and to close
// the silent-drop bug where a single getDimensions failure was fatal.
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
bool gotDimensions = false;
for (int attempt = 0; attempt < 3 && !gotDimensions; attempt++) {
if (attempt > 0) {
delay(50); // Give a slow SD card time to finish syncing before retrying
}
gotDimensions = decoder && decoder->getDimensions(cachedImagePath, dims);
}
} else {
LOG_ERR("EHP", "Failed to extract image");
}
}
if (gotDimensions) {
LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height);
@@ -722,7 +742,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->currentPageNextY += imageMarginTop;
// Create ImageBlock and add to page
auto imageBlock = std::make_shared<ImageBlock>(cachedImagePath, displayWidth, displayHeight);
auto imageBlock =
std::make_shared<ImageBlock>(cachedImagePath, resolvedPath, displayWidth, displayHeight);
if (!imageBlock) {
LOG_ERR("EHP", "Failed to create ImageBlock");
return;
@@ -753,8 +774,6 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
LOG_ERR("EHP", "Failed to get image dimensions");
Storage.remove(cachedImagePath.c_str());
}
} else {
LOG_ERR("EHP", "Failed to extract image");
}
} // isFormatSupported
}
@@ -28,6 +28,7 @@ class ChapterHtmlSlimParser {
GfxRenderer& renderer;
std::function<void(std::unique_ptr<Page>, uint16_t, uint16_t)> completePageFn;
std::function<void()> popupFn; // Popup callback
bool imagePopupFired = false; // popupFn fired for the first image probe (single-shot)
int depth = 0;
int skipUntilDepth = INT_MAX;
int boldUntilDepth = INT_MAX;
+18 -4
View File
@@ -22,7 +22,21 @@ constexpr char DEVICE_ID[] = "crosspoint-reader";
// footprint is smaller than mbedTLS's old ~48KB peak, but keep a conservative
// floor. Check both total free heap and largest contiguous block so fragmented
// heap does not fall through into a failed TLS allocation path.
constexpr uint32_t MIN_HEAP_FOR_TLS = 55000;
// MEMFIX-PORT: TLS heap gate; portable
// Field data (July 2026): launching sync from a reader session lands at
// 51.9-58.2 KB free / 42-53 KB maxAlloc after WiFi comes up. wolfSSL handles
// allocation failure by returning MEMORY_E (no abort under -fno-exceptions),
// so an optimistic attempt degrades to the same clean "sync failed" as the
// gate — the gate only needs to keep out states where a doomed handshake
// would waste tens of seconds, not guarantee success.
//
// Free and largest-block have separate requirements: with SP ECC
// (WOLFSSL_HAVE_SP_ECC) the handshake's crypto uses fixed 256-bit arrays, so
// the largest single TLS allocation is the ~17 KB wolfSSL record buffer, not
// a run of fast-math bignums. A handshake was measured succeeding inside a
// 43 KB largest block; requiring 50 KB contiguous refused syncs that fit.
constexpr uint32_t MIN_FREE_FOR_TLS = 50000;
constexpr uint32_t MIN_BLOCK_FOR_TLS = 20000;
// Apply the shared KOSync auth headers after begin(). x-auth-* is the native
// KOSync scheme; Basic auth is added for Calibre-Web-Automated compatibility.
@@ -39,9 +53,9 @@ void applyAuthHeaders(freeink::SecureHttpClient& http) {
bool insufficientHeap() {
const uint32_t freeHeap = ESP.getFreeHeap();
const uint32_t maxAllocHeap = ESP.getMaxAllocHeap();
if (freeHeap < MIN_HEAP_FOR_TLS || maxAllocHeap < MIN_HEAP_FOR_TLS) {
LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free, %u max alloc (need %u)", freeHeap,
maxAllocHeap, MIN_HEAP_FOR_TLS);
if (freeHeap < MIN_FREE_FOR_TLS || maxAllocHeap < MIN_BLOCK_FOR_TLS) {
LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free (need %u), %u max alloc (need %u)", freeHeap,
MIN_FREE_FOR_TLS, maxAllocHeap, MIN_BLOCK_FOR_TLS);
return true;
}
return false;
+7 -2
View File
@@ -437,7 +437,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
return data;
}
bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t chunkSize) {
bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t chunkSize, const bool allowEarlyStop) {
const ScopedOpenClose zip{*this};
if (!zip) return false;
@@ -469,8 +469,9 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
}
if (out.write(buffer, dataRead) != dataRead) {
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
free(buffer);
if (allowEarlyStop) return true; // sink has what it needs
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
return false;
}
remaining -= dataRead;
@@ -525,7 +526,11 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
if (produced > 0) {
if (out.write(outputBuffer, produced) != produced) {
if (allowEarlyStop) {
success = true; // sink has what it needs
} else {
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
}
break;
}
}
+18 -3
View File
@@ -69,7 +69,10 @@ class ZipFile {
// Due to the memory required to run each of these, it is recommended to not preopen the zip file for multiple
// These functions will open and close the zip as needed
uint8_t* readFileToMemory(const char* filename, size_t* size = nullptr, bool trailingNullByte = false);
bool readFileToStream(const char* filename, Print& out, size_t chunkSize);
// allowEarlyStop: a short write from `out` is treated as the sink asking to
// stop (returns true) instead of a write failure — used by header probes
// that only need the first bytes of an entry.
bool readFileToStream(const char* filename, Print& out, size_t chunkSize, bool allowEarlyStop = false);
template <typename F>
bool enumerateFilePaths(F&& callback) {
@@ -80,6 +83,14 @@ class ZipFile {
return true;
}
return enumerateFileEntries([&callback](std::string_view path, uint32_t, uint32_t) { callback(path); });
}
// Callback receives (path, crc32, compressedSize) for each central-directory
// entry. Always scans the central directory: the slim-stat cache does not
// hold CRCs.
template <typename F>
bool enumerateFileEntries(F&& callback) {
const bool wasOpen = isOpen();
if (!wasOpen && !open()) {
return false;
@@ -103,7 +114,11 @@ class ZipFile {
break;
}
file.seekCur(24);
file.seekCur(12);
uint32_t crc32, compressedSize;
file.read(&crc32, 4);
file.read(&compressedSize, 4);
file.seekCur(4);
uint16_t nameLen, m, k;
file.read(&nameLen, 2);
file.read(&m, 2);
@@ -113,7 +128,7 @@ class ZipFile {
if (nameLen < sizeof(itemName)) {
file.read(itemName, nameLen);
itemName[nameLen] = '\0';
callback(std::string_view{itemName, nameLen});
callback(std::string_view{itemName, nameLen}, crc32, compressedSize);
} else {
file.seekCur(nameLen);
}
+57 -2
View File
@@ -13,7 +13,10 @@ framework = arduino
monitor_speed = 115200
upload_speed = 921600
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 (~400 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
board_upload.flash_size = 16MB
@@ -40,7 +43,14 @@ build_flags =
-DWOLFSSL_OPTIONS_H
-DWOLFSSL_CLIENT_EXAMPLE
-DWOLFSSL_TLS13
-DWOLFSSL_SP_RISCV32
# MEMFIX-PORT: single-precision ECC (sp_c32.c). Without it every P-256 operation
# (TLS 1.3 key_share keygen, ECDHE, ECDSA cert verify) runs on fast-math bignums
# that WOLFSSL_SMALL_STACK heap-allocates at FP_MAX_BITS size -- tens of KB of
# temporaries, which OOMs (MP_MEM) at the ~50KB free heap a reading session
# leaves. SP uses fixed 256-bit arrays: a few KB, and several times faster.
# SP_SMALL trades the large precomputed point tables for smaller flash.
-DWOLFSSL_HAVE_SP_ECC
-DWOLFSSL_SP_SMALL
-DHAVE_TLS_EXTENSIONS
-DHAVE_SUPPORTED_CURVES
-DHAVE_HKDF
@@ -61,6 +71,51 @@ board_build.flash_mode = dio
board_build.flash_size = 16MB
board_build.partitions = partitions.csv
; MEMFIX-PORT: custom_sdkconfig heap reclamation (~32-37 KB). Rebuilds the
; Arduino core libs on first build (slower once, cached after; needs the CMake
; pin in platformio.local.ini on macOS).
;
; If an interrupted rebuild fails with "multiple definition of 'app_main'"
; (stale generated scaffold), clean it up with:
; rm -rf .dummy CMakeLists.txt sdkconfig.default sdkconfig.defaults .pio/build/default
; Do NOT use `git clean -fdX` — it deletes platformio.local.ini.
custom_sdkconfig =
; Task stack right-sizing from measured high-water marks (heap block map +
; per-task stack audit, July 2026): esp_timer used ~0.8 KB of 8 KB across
; every capture; the FreeRTOS timer service used ~0.5 KB
; of 4 KB. Neither runs TLS or app code. ~7 KB back to the heap.
CONFIG_ESP_TIMER_TASK_STACK_SIZE=4096
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=2560
; Move the WiFi stack's non-critical hot paths out of IRAM into flash.
; On the C3, IRAM and DRAM share one SRAM pool, so the ~25-30 KB this
; frees lands directly in the heap — paid for with lower WiFi throughput
; during transfers (occasional sync/OTA use, not streaming: acceptable).
; IRAM cost is static, so the heap gain applies even with WiFi off.
CONFIG_ESP_WIFI_IRAM_OPT=n
CONFIG_ESP_WIFI_RX_IRAM_OPT=n
; 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
; ESP_SR (speech recognition) only exists in the S3 core; its wrapper includes
; ESP_I2S.h, which the isolated core rebuild can't resolve. Unused here anyway,
; so drop it or the sticky env fails to build.
CONFIG_ARDUINO_SELECTIVE_ESP_SR=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 =
pre:scripts/patch_wolfssl.py
pre:scripts/build_html.py
+5 -1
View File
@@ -12,8 +12,12 @@ OVERRIDES = f"""
#ifndef HAVE_FFDHE_2048
#define HAVE_FFDHE_2048
#endif
/* MEMFIX-PORT: 8192 handles up to RSA-4096 keys (the public-CA maximum,
ISRG Root X1 included) with half the per-bignum heap of 16384: with
WOLFSSL_SMALL_STACK each fast-math temp is FP_MAX_BITS/8 * 2 bytes on the
heap, and TLS cert verification allocates dozens at once. */
#undef FP_MAX_BITS
#define FP_MAX_BITS 16384
#define FP_MAX_BITS 8192
"""
+143 -28
View File
@@ -157,6 +157,11 @@ void EpubReaderActivity::onEnter() {
}
ImageBlock::clearSessionRenderFailures();
// Lazy image extraction: section builds only header-probe images, so the first
// render of an image page pulls the file out of the EPUB through this hook.
ImageBlock::setExtractor(epub.get(), [](void* ctx, const char* src, const char* dest) {
return static_cast<Epub*>(ctx)->extractItemToFile(src, dest);
});
// Configure screen orientation based on settings
// NOTE: This affects layout math and must be applied before any render calls.
@@ -209,6 +214,10 @@ void EpubReaderActivity::onEnter() {
void EpubReaderActivity::onExit() {
Activity::onExit();
// The extractor holds a raw pointer to this activity's epub; drop it before
// the activity (and the shared_ptr) goes away.
ImageBlock::setExtractor(nullptr, nullptr);
// Reset orientation back to portrait for the rest of the UI
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
@@ -258,6 +267,30 @@ void EpubReaderActivity::openReaderMenu() {
});
}
bool EpubReaderActivity::buildTickHeapGate() {
const size_t freeHeap = ESP.getFreeHeap();
const size_t maxBlock = ESP.getMaxAllocHeap();
// Below the floors: just wait. The tick is deferrable — page-turn transients
// free up between turns and the tick retries every loop pass. Track the
// paused state so skipLoopDelay() stops pinning the CPU at full speed while
// no build work is actually happening (the gate can stay closed for a long
// stretch if the retained build context itself holds the heap down).
buildHeapPaused = freeHeap < BACKGROUND_BUILD_MIN_FREE_HEAP || maxBlock < BACKGROUND_BUILD_MIN_MAX_ALLOC;
return !buildHeapPaused;
}
void EpubReaderActivity::showBuildPopup() {
// Mid-build indexing popup: only during onEnter's blocking build-to-target phase
// (buildPopupPending), at most once, and only when the framebuffer isn't on loan.
// If it fires while the loan is active (e.g. the parser's size-based call during
// startBuild), pending stays set and the deadline check retries after the loan.
if (!buildPopupPending || !renderer.hasFrameBuffer()) return;
GUI.drawPopup(renderer, tr(STR_INDEXING));
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts.
pagesUntilFullRefresh = 1;
buildPopupPending = false;
}
void EpubReaderActivity::openDictionaryWordSelect() {
if (SETTINGS.dictionaryName[0] == '\0') {
showDictionaryMessage = true;
@@ -288,6 +321,40 @@ void EpubReaderActivity::loop() {
return;
}
// Idle glyph prewarm for the likely next page (currentPage + 1). The scan
// pass draws nothing (FCM scan mode suppresses pixels), so the displayed
// framebuffer is untouched; endScanAndPrewarm loads only glyphs not already
// cached. Debounced past rapid page-flipping, one attempt per position, and
// deferred while a render/build owns the CPU or the heap is at the render
// floor. Cross-chapter prewarm is deliberately out of scope (next spine's
// section isn't loaded).
constexpr unsigned long IDLE_PREWARM_DEBOUNCE_MS = 400;
if (section && !section->isBuilding() && !RenderLock::peek() && renderer.hasFrameBuffer() &&
lastRenderCompleteMs != 0 && millis() - lastRenderCompleteMs > IDLE_PREWARM_DEBOUNCE_MS &&
ESP.getFreeHeap() > RENDER_MIN_FREE_HEAP && ESP.getMaxAllocHeap() > BACKGROUND_BUILD_MIN_MAX_ALLOC &&
(idlePrewarmSpine != currentSpineIndex || idlePrewarmPage != section->currentPage)) {
RenderLock lock; // the page table must not change under the scan
// Re-check under the lock: peek() and acquisition are not atomic, so the render
// task may have reset/replaced the section or moved the page in between.
if (section && !section->isBuilding() &&
(idlePrewarmSpine != currentSpineIndex || idlePrewarmPage != section->currentPage)) {
idlePrewarmSpine = currentSpineIndex;
idlePrewarmPage = section->currentPage;
const int nextPage = section->currentPage + 1;
if (nextPage < static_cast<int>(section->pageCount)) {
if (const auto p = section->loadPage(nextPage)) {
if (auto* fcm = renderer.getFontCacheManager()) {
const auto t0 = millis();
auto scope = fcm->createPrewarmScope();
p->render(renderer, SETTINGS.getReaderFontId(), 0, 0); // scan only, no pixels
scope.endScanAndPrewarm();
LOG_DBG("ERS", "Idle prewarm: page %d in %lums", nextPage, millis() - t0);
}
}
}
}
}
// Lazily resume a partial's extension build once the reader nears its watermark. Far from
// it the rebuild is all cost (whole-chapter re-layout from page 0) and no benefit this
// session, so reopening a partial deliberately does NOT start it (see the deferral in
@@ -323,14 +390,17 @@ void EpubReaderActivity::loop() {
// "far enough ahead" and stall the build at 0 pages -- then the first turn past the
// watermark re-parses the whole chapter synchronously. Keep ticking until it finalizes.
if (section && section->isBuilding() && !RenderLock::peek() &&
(section->isPartial() || static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD)) {
(section->isPartial() || static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) &&
buildTickHeapGate()) {
RenderLock lock;
// 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
// buildSomeMore() would fail and wrongly reset the section. cppcheck can't see the cross-task
// mutation, so it flags this as always true.
// buildSomeMore() would fail and wrongly reset the section. The heap gate must be re-read
// too: a render that won the lock race can expand retained glyph buffers, invalidating the
// pre-lock heap reading. cppcheck can't see the cross-task mutation, so it flags this as
// always true.
// cppcheck-suppress knownConditionTrueFalse
if (section->isBuilding()) {
if (section->isBuilding() && buildTickHeapGate()) {
if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) {
LOG_ERR("ERS", "Background section build failed");
section.reset();
@@ -1134,18 +1204,29 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
pagesUntilFullRefresh = 1;
}
// Lend the framebuffer's 48 KB to the blocking pre-render burst
// (startBuild inflates the whole spine HTML — the memory peak). The
// background buildSomeMore chunks in loop() do NOT get the loan: they
// deliberately interleave with page renders. Restored before render.
// Mid-build popup surfacing for slow builds the predictive gates can't
// see (image extraction/probing inside a single page, or any chunk
// overrunning the deadline). The parser fires the callback before the
// first image probe; buildPopupPending gates it to this blocking phase
// so a background build in loop() can never draw over a displayed page.
buildPopupPending = !showPopup;
const unsigned long buildStartMs = millis();
bool started;
{
// Lend the framebuffer's 48 KB to startBuild only (the spine HTML
// inflation peak). The chunk loop below runs without it so the popup
// can draw mid-build; background chunks never had the loan either.
GfxRenderer::FrameBufferLoan loan(renderer);
if (!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
started = section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled,
[this] { showBuildPopup(); });
}
if (!started) {
LOG_ERR("ERS", "Failed to start section build");
section.reset();
loan.end(); // restore before anything draws (showBuildError renders a popup)
buildPopupPending = false;
showBuildError();
return;
}
@@ -1154,15 +1235,19 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// Anchor jump: build until the anchor's page is laid out (usually page 0), checking a
// partial's on-disk anchor map too so an already-indexed anchor resolves immediately.
// Otherwise: build until the target page exists. loop() builds the rest behind it.
if (buildPopupPending && millis() - buildStartMs >= BUILD_POPUP_DEADLINE_MS) {
// The predictive gates guessed fast but the build blew the silent budget.
showBuildPopup();
}
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
LOG_ERR("ERS", "Failed during incremental section build");
section.reset();
loan.end(); // restore before anything draws (showBuildError renders a popup)
buildPopupPending = false;
showBuildError();
return;
}
}
loan.end();
buildPopupPending = false;
}
}
} else {
@@ -1323,6 +1408,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
const auto start = millis();
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
lastRenderCompleteMs = millis();
}
// Only persist when the position actually changed. render() also runs on menu,
// bookmark and screenshot re-renders, and writeAtomic is several FAT ops for 6 bytes.
@@ -1385,6 +1471,13 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const auto t0 = millis();
const int fontId = SETTINGS.getReaderFontId();
// The image pixel-cache RAM slot lives for exactly one page render (it feeds
// the BW double-refresh and every grayscale band pass); release it on every
// exit so nothing stays resident across page turns.
struct PxcSlotGuard {
~PxcSlotGuard() { ImageBlock::releaseRenderCache(); }
} pxcSlotGuard;
// Font prewarm: scan pass accumulates text, then prewarm, then real render
auto* fcm = renderer.getFontCacheManager();
auto scope = fcm->createPrewarmScope();
@@ -1398,9 +1491,11 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages;
const bool tiledGrayscale = needsAnyGrayscale && renderer.supportsStripGrayscale();
// Whole-plane buffering only pays when the BW refresh genuinely runs async
// underneath it; on blocking panels it would just spend ~50 KB for the
// identical serial timing.
const bool overlapRefresh = tiledGrayscale && renderer.supportsAsyncRefresh();
// underneath it; on blocking panels (X3) it would just spend ~50 KB for the
// identical serial timing. Image pages take the blocking double-FAST path
// below (no async refresh is ever started), so they'd spend the buffers with
// nothing in flight to overlap.
const bool overlapRefresh = tiledGrayscale && renderer.supportsAsyncRefresh() && !pageHasImages;
auto renderGrayscalePass = [&]() {
if (needsTextGrayscale) {
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
@@ -1446,9 +1541,9 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
// regardless of residue.
pagesUntilFullRefresh = 1;
} else {
// Deferred when a tiled grayscale pass follows: the plane rendering below
// then overlaps the panel's refresh time instead of following it.
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh, /*async=*/overlapRefresh);
// Async form: start the waveform and return so the grayscale plane rendering
// below overlaps the panel's refresh time instead of following it.
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh, overlapRefresh);
}
const auto tDisplay = millis();
@@ -1483,12 +1578,25 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
// Tiered on heap pressure: two plane buffers hide both plane renders
// inside the refresh wait; one hides the LSB render (its buffer is reused
// for MSB after streaming); none falls back to the strip-scratch flow with
// no overlap. The MSB buffer is only attempted when it leaves ~60 KB free
// so the pass never starves concurrent allocations. Blocking panels skip
// the buffers entirely (nothing to overlap).
auto lsbPlaneBuf = overlapRefresh ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
auto msbPlaneBuf =
(lsbPlaneBuf && ESP.getFreeHeap() >= planeBytes + 60000) ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
// no overlap. Each buffer is only attempted when it leaves ~60 KB free so
// the pass never starves concurrent allocations: the next page re-render
// allocates through throwing std::string paths that abort() on OOM under
// -fno-exceptions, so a plane buffer that "fits" but eats the render
// headroom is worse than the strip fallback. Blocking panels skip the
// buffers entirely (nothing to overlap).
constexpr size_t PLANE_BUF_HEADROOM = 60000;
// Free-heap alone ignores fragmentation: taking the largest block for a
// plane can leave only slivers behind even when total headroom looks fine.
// Require the block to fit the plane with 16 KB contiguous to spare, which
// also keeps the advance-table batch scratch viable mid-render (same
// rationale as BACKGROUND_BUILD_MIN_MAX_ALLOC).
constexpr size_t PLANE_BUF_MAX_ALLOC_RESERVE = 16 * 1024;
const auto planeBufFits = [planeBytes] {
return ESP.getFreeHeap() >= planeBytes + PLANE_BUF_HEADROOM &&
ESP.getMaxAllocHeap() >= planeBytes + PLANE_BUF_MAX_ALLOC_RESERVE;
};
auto lsbPlaneBuf = (overlapRefresh && planeBufFits()) ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
auto msbPlaneBuf = (lsbPlaneBuf && planeBufFits()) ? makeUniqueNoThrow<uint8_t[]>(planeBytes) : nullptr;
if (lsbPlaneBuf) {
renderPlaneToBuffer(true, lsbPlaneBuf.get());
@@ -1522,13 +1630,20 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tGrayRender - tDisplay, tWait - tGrayRender,
tGrayWrite - tWait, tGrayDisplay - tGrayWrite, tEnd - tGrayDisplay, tEnd - t0, msbPlaneBuf ? 2 : 1);
} else {
// Per-strip scratch tier: blocking panels and the OOM fallback. The
// strip writes below need the panel idle, so wait out any pending async
// refresh first (no-op on blocking panels).
// Per-strip scratch tier: blocking panels (X3) and the OOM fallback.
// The strip writes below need the panel idle, so wait out any pending
// async refresh first (no-op on blocking panels).
auto scratch = makeUniqueNoThrow<uint8_t[]>(static_cast<size_t>(gwBytes) * STRIP_ROWS);
renderer.waitRefreshComplete();
if (!scratch) {
LOG_ERR("ERS", "OOM: grayscale strip scratch (%d bytes); skipping AA this page", gwBytes * STRIP_ROWS);
if (overlapRefresh) {
// The BW refresh ran the shadow-free async path, so controller RAM's
// differential baseline was never rebuilt. Even with AA skipped it must
// be re-synced from the intact BW framebuffer, or the next differential
// update diffs against stale contents.
renderer.cleanupGrayscaleWithFrameBuffer();
}
} else {
// Bands may be streamed in any order: X4 windows each via setRamArea,
// X3 via PTL.
+48 -2
View File
@@ -44,6 +44,13 @@ class EpubReaderActivity final : public Activity {
unsigned long dictionaryMessageTime = 0UL;
bool ignoreNextConfirmRelease = false;
bool currentPageBookmarked = false;
// Idle-time glyph prewarm: after a page settles, scan the LIKELY next page
// (scan mode draws nothing) and load its missing glyphs from SD during idle,
// so the next turn's in-render prewarm is a cache hit instead of ~100 ms of
// SD reads on the page-turn critical path. One attempt per position.
int idlePrewarmSpine = -1;
int idlePrewarmPage = -1;
unsigned long lastRenderCompleteMs = 0;
bool bookmarkRemoved = false; // true when last toggle removed (controls popup text)
std::vector<BookmarkEntry> cachedBookmarks;
// Tracks whether this book is currently removed from Recent Books by the
@@ -89,6 +96,33 @@ class EpubReaderActivity final : public Activity {
// background build chunk never noticeably delays input or a pending render.
static constexpr int BUILD_PAGES_PER_CHUNK = 8;
static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2;
// MEMFIX-PORT: background-build heap floor; portable
// 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 under heap pressure). 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.
static constexpr size_t BACKGROUND_BUILD_MIN_FREE_HEAP = 32 * 1024;
// Fragmentation floor for the same gate: a tick passed the free-heap floor at
// 34.7 KB free but the largest block was ~11 KB, and a parse allocation inside the
// tick aborted anyway. Free heap says how much memory exists; maxAlloc says whether
// any single allocation can actually have it. 16 KB also keeps the advance-table
// batch path (16 KB scratch) viable during builds.
static constexpr size_t BACKGROUND_BUILD_MIN_MAX_ALLOC = 16 * 1024;
// Gate for a background build tick: true when the heap can take parse allocations.
// Updates buildHeapPaused as a side effect.
bool buildTickHeapGate();
// True while the background build is gated on the heap floors. Lets skipLoopDelay()
// return the loop to normal delay/power-saving during the pause: isBuilding() stays
// true the whole time, and without this the loop would spin at full CPU speed doing
// no build work — indefinitely, if the build context itself keeps the heap low.
bool buildHeapPaused = false;
// Heap floor for optional render-adjacent work (idle prewarm). Page
// deserialization (TextBlock word vectors/strings) and glyph caching allocate
// through throwing paths that abort() on OOM; skip deferrable work below it.
static constexpr size_t RENDER_MIN_FREE_HEAP = 24 * 1024;
// 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
// -- a tiny buffer is enough. The background build stops once the watermark is this far
@@ -112,6 +146,16 @@ class EpubReaderActivity final : public Activity {
// whole HTML must be inflated before page 1 can lay out (the giant single-spine case), which is
// a multi-second wait. Normal chapters are well under this and stay popup-free.
static constexpr size_t BUILD_POPUP_BYTE_THRESHOLD = 96 * 1024;
// Deadline backstop for the predictive gates above: if the blocking build-to-target still
// hasn't produced the landing page this long after the build started, surface the popup
// mid-build. Builds that finish under the deadline stay popup-free.
static constexpr unsigned long BUILD_POPUP_DEADLINE_MS = 1000;
// True only during onEnter's blocking build-to-target phase, until the popup has been
// drawn. Gates showBuildPopup() so the parser's popup callback (which persists into
// background buildSomeMore chunks) can never draw over a displayed page.
bool buildPopupPending = false;
// Draw the indexing popup mid-build (parser image-probe callback and deadline backstop).
void showBuildPopup();
// Remap the cached relative reading position once the section's real page count is known
// (used after a settings change re-paginates a chapter). Returns true if currentPage moved.
// No-op while the section is still building or when the pagination is unchanged (plain resume).
@@ -147,8 +191,10 @@ class EpubReaderActivity final : public Activity {
// Full CPU speed + fast loop ticks while a section build runs: at the low-power
// frequency a giant chapter's background rebuild stretches from ~40s to many
// minutes, so the reader exits before it can finalize and the next open restarts
// it from page 0. Reverts to normal power behavior the moment the build finishes.
bool skipLoopDelay() override { return section && section->isBuilding(); }
// it from page 0. Reverts to normal power behavior the moment the build finishes,
// and while the build is heap-paused (no work is happening, so spinning at full
// speed would only burn battery; the paused gate still retries every loop pass).
bool skipLoopDelay() override { return section && section->isBuilding() && !buildHeapPaused; }
bool isReaderActivity() const override { return true; }
ScreenshotInfo getScreenshotInfo() const override;
CrossPointPosition getCurrentPosition() const;
+17
View File
@@ -205,6 +205,14 @@ void CrossPointWebServer::begin() {
udpActive = udp.begin(LOCAL_UDP_PORT);
LOG_DBG("WEB", "Discovery UDP %s on port %d", udpActive ? "enabled" : "failed", LOCAL_UDP_PORT);
// All request handlers run on the task that calls handleClient(). Register
// that task before any handler can call esp_task_wdt_reset().
const esp_err_t watchdogResult = esp_task_wdt_add(nullptr);
watchdogTaskRegistered = watchdogResult == ESP_OK;
if (!watchdogTaskRegistered) {
LOG_ERR("WEB", "Failed to register web server task with watchdog: %s", esp_err_to_name(watchdogResult));
}
running = true;
LOG_DBG("WEB", "Web server started on port %d", port);
@@ -234,6 +242,10 @@ void CrossPointWebServer::abortWsUpload(const char* tag) {
void CrossPointWebServer::stop() {
if (!running || !server) {
LOG_DBG("WEB", "stop() called but already stopped (running=%d, server=%p)", running, server.get());
if (watchdogTaskRegistered) {
esp_task_wdt_delete(nullptr);
watchdogTaskRegistered = false;
}
return;
}
@@ -274,6 +286,11 @@ void CrossPointWebServer::stop() {
LOG_DBG("WEB", "Web server stopped and deleted");
LOG_DBG("WEB", "[MEM] Free heap after delete server: %d bytes", ESP.getFreeHeap());
if (watchdogTaskRegistered) {
esp_task_wdt_delete(nullptr);
watchdogTaskRegistered = false;
}
// Note: Static upload variables (uploadFileName, uploadPath, uploadError) are declared
// later in the file and will be cleared when they go out of scope or on next upload
LOG_DBG("WEB", "[MEM] Free heap final: %d bytes", ESP.getFreeHeap());
+1
View File
@@ -72,6 +72,7 @@ class CrossPointWebServer {
std::unique_ptr<WebServer> server = nullptr;
std::unique_ptr<WebSocketsServer> wsServer = nullptr;
bool running = false;
bool watchdogTaskRegistered = false;
bool apMode = false; // true when running in AP mode, false for STA mode
uint16_t port = 80;
uint16_t wsPort = 81; // WebSocket port