Merge branch 'master' into Discussion-#26-Rotation-Support

This commit is contained in:
Dave Allie 2025-12-28 19:06:19 +11:00
commit 9d28afdefc
No known key found for this signature in database
GPG Key ID: F2FDDB3AD8D0276F
86 changed files with 8305 additions and 2242 deletions

View File

@ -12,12 +12,6 @@ jobs:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
submodules: recursive submodules: recursive
- uses: actions/cache@v5
with:
path: |
~/.cache/pip
~/.platformio/.cache
key: ${{ runner.os }}-pio
- uses: actions/setup-python@v6 - uses: actions/setup-python@v6
with: with:
python-version: '3.14' python-version: '3.14'
@ -34,7 +28,7 @@ jobs:
sudo apt-get install -y clang-format-21 sudo apt-get install -y clang-format-21
- name: Run cppcheck - name: Run cppcheck
run: pio check --fail-on-defect medium --fail-on-defect high run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high
- name: Run clang-format - name: Run clang-format
run: PATH="/usr/lib/llvm-21/bin:$PATH" ./bin/clang-format-fix && git diff --exit-code || (echo "Please run 'bin/clang-format-fix' to fix formatting issues" && exit 1) run: PATH="/usr/lib/llvm-21/bin:$PATH" ./bin/clang-format-fix && git diff --exit-code || (echo "Please run 'bin/clang-format-fix' to fix formatting issues" && exit 1)

View File

@ -59,11 +59,32 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con
### 3.5 Settings ### 3.5 Settings
The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust:
- **White Sleep Screen**: Whether to use the white screen or black (inverted) default sleep screen - **Sleep Screen**: Which sleep screen to display when the device sleeps, options are:
- "Dark" (default) - The default dark sleep screen
- "Light" - The same default sleep screen, on a white background
- "Custom" - Custom images from the SD card, see [3.6 Sleep Screen](#36-sleep-screen) below for more information
- "Cover" - The book cover image (Note: this is experimental and may not work as expected)
- **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book, if disabled, - **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book, if disabled,
paragraphs will not have vertical space between them, but will have first word indentation. paragraphs will not have vertical space between them, but will have first word indentation.
- **Short Power Button Click**: Whether to trigger the power button on a short press or a long press. - **Short Power Button Click**: Whether to trigger the power button on a short press or a long press.
### 3.6 Sleep Screen
You can customize the sleep screen by placing custom images in specific locations on the SD card:
- **Single Image:** Place a file named `sleep.bmp` in the root directory.
- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images
inside. If images are found in this directory, they will take priority over the `sleep.png` file, and one will be
randomly selected each time the device sleeps.
> [!NOTE]
> You'll need to set the **Sleep Screen** setting to **Custom** in order to use these images.
> [!TIP]
> For best results:
> - Use uncompressed BMP files with 24-bit color depth
> - Use a resolution of 480x800 pixels to match the device's screen resolution.
--- ---
## 4. Reading Mode ## 4. Reading Mode

View File

@ -59,14 +59,28 @@ bool EpdFont::hasPrintableChars(const char* string) const {
const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const { const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const {
const EpdUnicodeInterval* intervals = data->intervals; const EpdUnicodeInterval* intervals = data->intervals;
for (int i = 0; i < data->intervalCount; i++) { const int count = data->intervalCount;
const EpdUnicodeInterval* interval = &intervals[i];
if (cp >= interval->first && cp <= interval->last) { if (count == 0) return nullptr;
// Binary search for O(log n) lookup instead of O(n)
// Critical for Korean fonts with many unicode intervals
int left = 0;
int right = count - 1;
while (left <= right) {
const int mid = left + (right - left) / 2;
const EpdUnicodeInterval* interval = &intervals[mid];
if (cp < interval->first) {
right = mid - 1;
} else if (cp > interval->last) {
left = mid + 1;
} else {
// Found: cp >= interval->first && cp <= interval->last
return &data->glyph[interval->offset + (cp - interval->first)]; return &data->glyph[interval->offset + (cp - interval->first)];
} }
if (cp < interval->first) {
return nullptr;
}
} }
return nullptr; return nullptr;
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,11 @@
#include "Epub.h" #include "Epub.h"
#include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <JpegToBmpConverter.h>
#include <SD.h> #include <SD.h>
#include <ZipFile.h> #include <ZipFile.h>
#include <map>
#include "Epub/FsHelpers.h"
#include "Epub/parsers/ContainerParser.h" #include "Epub/parsers/ContainerParser.h"
#include "Epub/parsers/ContentOpfParser.h" #include "Epub/parsers/ContentOpfParser.h"
#include "Epub/parsers/TocNcxParser.h" #include "Epub/parsers/TocNcxParser.h"
@ -30,31 +29,39 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
// Stream read (reusing your existing stream logic) // Stream read (reusing your existing stream logic)
if (!readItemContentsToStream(containerPath, containerParser, 512)) { if (!readItemContentsToStream(containerPath, containerParser, 512)) {
Serial.printf("[%lu] [EBP] Could not read META-INF/container.xml\n", millis()); Serial.printf("[%lu] [EBP] Could not read META-INF/container.xml\n", millis());
containerParser.teardown();
return false; return false;
} }
// Extract the result // Extract the result
if (containerParser.fullPath.empty()) { if (containerParser.fullPath.empty()) {
Serial.printf("[%lu] [EBP] Could not find valid rootfile in container.xml\n", millis()); Serial.printf("[%lu] [EBP] Could not find valid rootfile in container.xml\n", millis());
containerParser.teardown();
return false; return false;
} }
*contentOpfFile = std::move(containerParser.fullPath); *contentOpfFile = std::move(containerParser.fullPath);
containerParser.teardown();
return true; return true;
} }
bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
std::string contentOpfFilePath;
if (!findContentOpfFile(&contentOpfFilePath)) {
Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis());
return false;
}
contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1);
Serial.printf("[%lu] [EBP] Parsing content.opf: %s\n", millis(), contentOpfFilePath.c_str());
size_t contentOpfSize; size_t contentOpfSize;
if (!getItemSize(contentOpfFilePath, &contentOpfSize)) { if (!getItemSize(contentOpfFilePath, &contentOpfSize)) {
Serial.printf("[%lu] [EBP] Could not get size of content.opf\n", millis()); Serial.printf("[%lu] [EBP] Could not get size of content.opf\n", millis());
return false; return false;
} }
ContentOpfParser opfParser(getBasePath(), contentOpfSize); ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get());
Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(),
ESP.getHeapSize(), ESP.getMinFreeHeap());
if (!opfParser.setup()) { if (!opfParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup content.opf parser\n", millis()); Serial.printf("[%lu] [EBP] Could not setup content.opf parser\n", millis());
@ -63,137 +70,154 @@ bool Epub::parseContentOpf(const std::string& contentOpfFilePath) {
if (!readItemContentsToStream(contentOpfFilePath, opfParser, 1024)) { if (!readItemContentsToStream(contentOpfFilePath, opfParser, 1024)) {
Serial.printf("[%lu] [EBP] Could not read content.opf\n", millis()); Serial.printf("[%lu] [EBP] Could not read content.opf\n", millis());
opfParser.teardown();
return false; return false;
} }
// Grab data from opfParser into epub // Grab data from opfParser into epub
title = opfParser.title; bookMetadata.title = opfParser.title;
if (!opfParser.coverItemId.empty() && opfParser.items.count(opfParser.coverItemId) > 0) { // TODO: Parse author
coverImageItem = opfParser.items.at(opfParser.coverItemId); bookMetadata.author = "";
} bookMetadata.coverItemHref = opfParser.coverItemHref;
if (!opfParser.tocNcxPath.empty()) { if (!opfParser.tocNcxPath.empty()) {
tocNcxItem = opfParser.tocNcxPath; tocNcxItem = opfParser.tocNcxPath;
} }
for (auto& spineRef : opfParser.spineRefs) {
if (opfParser.items.count(spineRef)) {
spine.emplace_back(spineRef, opfParser.items.at(spineRef));
}
}
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis()); Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
opfParser.teardown();
return true; return true;
} }
bool Epub::parseTocNcxFile() { bool Epub::parseTocNcxFile() const {
// the ncx file should have been specified in the content.opf file // the ncx file should have been specified in the content.opf file
if (tocNcxItem.empty()) { if (tocNcxItem.empty()) {
Serial.printf("[%lu] [EBP] No ncx file specified\n", millis()); Serial.printf("[%lu] [EBP] No ncx file specified\n", millis());
return false; return false;
} }
size_t tocSize; Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str());
if (!getItemSize(tocNcxItem, &tocSize)) {
Serial.printf("[%lu] [EBP] Could not get size of toc ncx\n", millis()); const auto tmpNcxPath = getCachePath() + "/toc.ncx";
File tempNcxFile;
if (!FsHelpers::openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) {
return false; return false;
} }
readItemContentsToStream(tocNcxItem, tempNcxFile, 1024);
tempNcxFile.close();
if (!FsHelpers::openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
return false;
}
const auto ncxSize = tempNcxFile.size();
TocNcxParser ncxParser(contentBasePath, tocSize); TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get());
if (!ncxParser.setup()) { if (!ncxParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis()); Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis());
return false; return false;
} }
if (!readItemContentsToStream(tocNcxItem, ncxParser, 1024)) { const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024));
Serial.printf("[%lu] [EBP] Could not read toc ncx stream\n", millis()); if (!ncxBuffer) {
ncxParser.teardown(); Serial.printf("[%lu] [EBP] Could not allocate memory for toc ncx parser\n", millis());
return false; return false;
} }
this->toc = std::move(ncxParser.toc); while (tempNcxFile.available()) {
const auto readSize = tempNcxFile.read(ncxBuffer, 1024);
const auto processedSize = ncxParser.write(ncxBuffer, readSize);
Serial.printf("[%lu] [EBP] Parsed %d TOC items\n", millis(), this->toc.size()); if (processedSize != readSize) {
Serial.printf("[%lu] [EBP] Could not process all toc ncx data\n", millis());
free(ncxBuffer);
tempNcxFile.close();
return false;
}
}
ncxParser.teardown(); free(ncxBuffer);
tempNcxFile.close();
SD.remove(tmpNcxPath.c_str());
Serial.printf("[%lu] [EBP] Parsed TOC items\n", millis());
return true; return true;
} }
// load in the meta data for the epub file // load in the meta data for the epub file
bool Epub::load() { bool Epub::load() {
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
ZipFile zip("/sd" + filepath);
std::string contentOpfFilePath; // Initialize spine/TOC cache
if (!findContentOpfFile(&contentOpfFilePath)) { bookMetadataCache.reset(new BookMetadataCache(cachePath));
Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis());
// Try to load existing cache first
if (bookMetadataCache->load()) {
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
return true;
}
// Cache doesn't exist or is invalid, build it
Serial.printf("[%lu] [EBP] Cache not found, building spine/TOC cache\n", millis());
setupCacheDir();
// Begin building cache - stream entries to disk immediately
if (!bookMetadataCache->beginWrite()) {
Serial.printf("[%lu] [EBP] Could not begin writing cache\n", millis());
return false; return false;
} }
Serial.printf("[%lu] [EBP] Found content.opf at: %s\n", millis(), contentOpfFilePath.c_str()); // OPF Pass
BookMetadataCache::BookMetadata bookMetadata;
contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1); if (!bookMetadataCache->beginContentOpfPass()) {
Serial.printf("[%lu] [EBP] Could not begin writing content.opf pass\n", millis());
if (!parseContentOpf(contentOpfFilePath)) { return false;
}
if (!parseContentOpf(bookMetadata)) {
Serial.printf("[%lu] [EBP] Could not parse content.opf\n", millis()); Serial.printf("[%lu] [EBP] Could not parse content.opf\n", millis());
return false; return false;
} }
if (!bookMetadataCache->endContentOpfPass()) {
Serial.printf("[%lu] [EBP] Could not end writing content.opf pass\n", millis());
return false;
}
// TOC Pass
if (!bookMetadataCache->beginTocPass()) {
Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis());
return false;
}
if (!parseTocNcxFile()) { if (!parseTocNcxFile()) {
Serial.printf("[%lu] [EBP] Could not parse toc\n", millis()); Serial.printf("[%lu] [EBP] Could not parse toc\n", millis());
return false; return false;
} }
if (!bookMetadataCache->endTocPass()) {
initializeSpineItemSizes(); Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis());
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); return false;
return true;
}
void Epub::initializeSpineItemSizes() {
setupCacheDir();
size_t spineItemsCount = getSpineItemsCount();
size_t cumSpineItemSize = 0;
if (SD.exists((getCachePath() + "/spine_size.bin").c_str())) {
File f = SD.open((getCachePath() + "/spine_size.bin").c_str());
uint8_t data[4];
for (size_t i = 0; i < spineItemsCount; i++) {
f.read(data, 4);
cumSpineItemSize = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);
cumulativeSpineItemSize.emplace_back(cumSpineItemSize);
// Serial.printf("[%lu] [EBP] Loading item %d size %u to %u %u\n", millis(),
// i, cumSpineItemSize, data[1], data[0]);
}
f.close();
} else {
File f = SD.open((getCachePath() + "/spine_size.bin").c_str(), FILE_WRITE);
uint8_t data[4];
// determine size of spine items
for (size_t i = 0; i < spineItemsCount; i++) {
std::string spineItem = getSpineItem(i);
size_t s = 0;
getItemSize(spineItem, &s);
cumSpineItemSize += s;
cumulativeSpineItemSize.emplace_back(cumSpineItemSize);
// and persist to cache
data[0] = cumSpineItemSize & 0xFF;
data[1] = (cumSpineItemSize >> 8) & 0xFF;
data[2] = (cumSpineItemSize >> 16) & 0xFF;
data[3] = (cumSpineItemSize >> 24) & 0xFF;
// Serial.printf("[%lu] [EBP] Persisting item %d size %u to %u %u\n", millis(),
// i, cumSpineItemSize, data[1], data[0]);
f.write(data, 4);
}
f.close();
} }
Serial.printf("[%lu] [EBP] Book size: %lu\n", millis(), cumSpineItemSize);
// Close the cache files
if (!bookMetadataCache->endWrite()) {
Serial.printf("[%lu] [EBP] Could not end writing cache\n", millis());
return false;
}
// Build final book.bin
if (!bookMetadataCache->buildBookBin(filepath, bookMetadata)) {
Serial.printf("[%lu] [EBP] Could not update mappings and sizes\n", millis());
return false;
}
if (!bookMetadataCache->cleanupTmpFiles()) {
Serial.printf("[%lu] [EBP] Could not cleanup tmp files - ignoring\n", millis());
}
// Reload the cache from disk so it's in the correct state
bookMetadataCache.reset(new BookMetadataCache(cachePath));
if (!bookMetadataCache->load()) {
Serial.printf("[%lu] [EBP] Failed to reload cache after writing\n", millis());
return false;
}
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
return true;
} }
bool Epub::clearCache() const { bool Epub::clearCache() const {
@ -229,49 +253,76 @@ const std::string& Epub::getCachePath() const { return cachePath; }
const std::string& Epub::getPath() const { return filepath; } const std::string& Epub::getPath() const { return filepath; }
const std::string& Epub::getTitle() const { return title; } const std::string& Epub::getTitle() const {
static std::string blank;
const std::string& Epub::getCoverImageItem() const { return coverImageItem; } if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return blank;
std::string normalisePath(const std::string& path) {
std::vector<std::string> components;
std::string component;
for (const auto c : path) {
if (c == '/') {
if (!component.empty()) {
if (component == "..") {
if (!components.empty()) {
components.pop_back();
}
} else {
components.push_back(component);
}
component.clear();
}
} else {
component += c;
}
} }
if (!component.empty()) { return bookMetadataCache->coreMetadata.title;
components.push_back(component);
}
std::string result;
for (const auto& c : components) {
if (!result.empty()) {
result += "/";
}
result += c;
}
return result;
} }
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, bool trailingNullByte) const { std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
bool Epub::generateCoverBmp() const {
// Already generated, return true
if (SD.exists(getCoverBmpPath().c_str())) {
return true;
}
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] Cannot generate cover BMP, cache not loaded\n", millis());
return false;
}
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
if (coverImageHref.empty()) {
Serial.printf("[%lu] [EBP] No known cover image\n", millis());
return false;
}
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis());
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
File coverJpg;
if (!FsHelpers::openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
coverJpg.close();
if (!FsHelpers::openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
File coverBmp;
if (!FsHelpers::openFileForWrite("EBP", getCoverBmpPath(), coverBmp)) {
coverJpg.close();
return false;
}
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp);
coverJpg.close();
coverBmp.close();
SD.remove(coverJpgTempPath.c_str());
if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis());
SD.remove(getCoverBmpPath().c_str());
}
Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no");
return success;
} else {
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping\n", millis());
}
return false;
}
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
const ZipFile zip("/sd" + filepath); const ZipFile zip("/sd" + filepath);
const std::string path = normalisePath(itemHref); const std::string path = FsHelpers::normalisePath(itemHref);
const auto content = zip.readFileToMemory(path.c_str(), size, trailingNullByte); const auto content = zip.readFileToMemory(path.c_str(), size, trailingNullByte);
if (!content) { if (!content) {
@ -284,95 +335,104 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
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 {
const ZipFile zip("/sd" + filepath); const ZipFile zip("/sd" + filepath);
const std::string path = normalisePath(itemHref); const std::string path = FsHelpers::normalisePath(itemHref);
return zip.readFileToStream(path.c_str(), out, chunkSize); return zip.readFileToStream(path.c_str(), out, chunkSize);
} }
bool Epub::getItemSize(const std::string& itemHref, size_t* size) const { bool Epub::getItemSize(const std::string& itemHref, size_t* size) const {
const ZipFile zip("/sd" + filepath); const ZipFile zip("/sd" + filepath);
const std::string path = normalisePath(itemHref); return getItemSize(zip, itemHref, size);
}
bool Epub::getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size) {
const std::string path = FsHelpers::normalisePath(itemHref);
return zip.getInflatedFileSize(path.c_str(), size); return zip.getInflatedFileSize(path.c_str(), size);
} }
int Epub::getSpineItemsCount() const { return spine.size(); } int Epub::getSpineItemsCount() const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return 0;
}
return bookMetadataCache->getSpineCount();
}
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return cumulativeSpineItemSize.at(spineIndex); } size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return getSpineItem(spineIndex).cumulativeSize; }
std::string& Epub::getSpineItem(const int spineIndex) { BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
if (spineIndex < 0 || spineIndex >= spine.size()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getSpineItem called but cache not loaded\n", millis());
return {};
}
if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) {
Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex); Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex);
return spine.at(0).second; return bookMetadataCache->getSpineEntry(0);
} }
return spine.at(spineIndex).second; return bookMetadataCache->getSpineEntry(spineIndex);
} }
EpubTocEntry& Epub::getTocItem(const int tocTndex) { BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const {
if (tocTndex < 0 || tocTndex >= toc.size()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocTndex); Serial.printf("[%lu] [EBP] getTocItem called but cache not loaded\n", millis());
return toc.at(0); return {};
} }
return toc.at(tocTndex); if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocIndex);
return {};
}
return bookMetadataCache->getTocEntry(tocIndex);
} }
int Epub::getTocItemsCount() const { return toc.size(); } int Epub::getTocItemsCount() const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return 0;
}
return bookMetadataCache->getTocCount();
}
// work out the section index for a toc index // work out the section index for a toc index
int Epub::getSpineIndexForTocIndex(const int tocIndex) const { int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
if (tocIndex < 0 || tocIndex >= toc.size()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex called but cache not loaded\n", millis());
return 0;
}
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex: tocIndex %d out of range\n", millis(), tocIndex); Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex: tocIndex %d out of range\n", millis(), tocIndex);
return 0; return 0;
} }
// the toc entry should have an href that matches the spine item const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex;
// so we can find the spine index by looking for the href if (spineIndex < 0) {
for (int i = 0; i < spine.size(); i++) { Serial.printf("[%lu] [EBP] Section not found for TOC index %d\n", millis(), tocIndex);
if (spine[i].second == toc[tocIndex].href) { return 0;
return i;
}
} }
Serial.printf("[%lu] [EBP] Section not found\n", millis()); return spineIndex;
// not found - default to the start of the book
return 0;
} }
int Epub::getTocIndexForSpineIndex(const int spineIndex) const { int Epub::getTocIndexForSpineIndex(const int spineIndex) const { return getSpineItem(spineIndex).tocIndex; }
if (spineIndex < 0 || spineIndex >= spine.size()) {
Serial.printf("[%lu] [EBP] getTocIndexForSpineIndex: spineIndex %d out of range\n", millis(), spineIndex);
return -1;
}
// the toc entry should have an href that matches the spine item
// so we can find the toc index by looking for the href
for (int i = 0; i < toc.size(); i++) {
if (toc[i].href == spine[spineIndex].second) {
return i;
}
}
Serial.printf("[%lu] [EBP] TOC item not found\n", millis());
return -1;
}
size_t Epub::getBookSize() const { size_t Epub::getBookSize() const {
if (spine.empty()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded() || bookMetadataCache->getSpineCount() == 0) {
return 0; return 0;
} }
return getCumulativeSpineItemSize(getSpineItemsCount() - 1); return getCumulativeSpineItemSize(getSpineItemsCount() - 1);
} }
// Calculate progress in book // Calculate progress in book
uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) { uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const {
size_t bookSize = getBookSize(); const size_t bookSize = getBookSize();
if (bookSize == 0) { if (bookSize == 0) {
return 0; return 0;
} }
size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0; const size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize; const size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize;
size_t sectionProgSize = currentSpineRead * curChapterSize; const size_t sectionProgSize = currentSpineRead * curChapterSize;
return round(static_cast<float>(prevChapterSize + sectionProgSize) / bookSize * 100.0); return round(static_cast<float>(prevChapterSize + sectionProgSize) / bookSize * 100.0);
} }

View File

@ -1,38 +1,30 @@
#pragma once #pragma once
#include <Print.h>
#include <memory>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
#include "Epub/EpubTocEntry.h" #include "Epub/BookMetadataCache.h"
class ZipFile; class ZipFile;
class Epub { class Epub {
// the title read from the EPUB meta data
std::string title;
// the cover image
std::string coverImageItem;
// the ncx file // the ncx file
std::string tocNcxItem; std::string tocNcxItem;
// where is the EPUBfile? // where is the EPUBfile?
std::string filepath; std::string filepath;
// the spine of the EPUB file
std::vector<std::pair<std::string, std::string>> spine;
// the file size of the spine items (proxy to book progress)
std::vector<size_t> cumulativeSpineItemSize;
// the toc of the EPUB file
std::vector<EpubTocEntry> toc;
// the base path for items in the EPUB file // the base path for items in the EPUB file
std::string contentBasePath; std::string contentBasePath;
// Uniq cache key based on filepath // Uniq cache key based on filepath
std::string cachePath; std::string cachePath;
// Spine and TOC cache
std::unique_ptr<BookMetadataCache> bookMetadataCache;
bool findContentOpfFile(std::string* contentOpfFile) const; bool findContentOpfFile(std::string* contentOpfFile) const;
bool parseContentOpf(const std::string& contentOpfFilePath); bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
bool parseTocNcxFile(); bool parseTocNcxFile() const;
void initializeSpineItemSizes(); static bool getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size);
public: public:
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) { explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
@ -47,19 +39,20 @@ class Epub {
const std::string& getCachePath() const; const std::string& getCachePath() const;
const std::string& getPath() const; const std::string& getPath() const;
const std::string& getTitle() const; const std::string& getTitle() const;
const std::string& getCoverImageItem() const; std::string getCoverBmpPath() const;
bool generateCoverBmp() const;
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
bool trailingNullByte = false) const; 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) const;
bool getItemSize(const std::string& itemHref, size_t* size) const; bool getItemSize(const std::string& itemHref, size_t* size) const;
std::string& getSpineItem(int spineIndex); BookMetadataCache::SpineEntry getSpineItem(int spineIndex) const;
BookMetadataCache::TocEntry getTocItem(int tocIndex) const;
int getSpineItemsCount() const; int getSpineItemsCount() const;
size_t getCumulativeSpineItemSize(const int spineIndex) const;
EpubTocEntry& getTocItem(int tocIndex);
int getTocItemsCount() const; int getTocItemsCount() const;
int getSpineIndexForTocIndex(int tocIndex) const; int getSpineIndexForTocIndex(int tocIndex) const;
int getTocIndexForSpineIndex(int spineIndex) const; int getTocIndexForSpineIndex(int spineIndex) const;
size_t getCumulativeSpineItemSize(int spineIndex) const;
size_t getBookSize() const; size_t getBookSize() const;
uint8_t calculateProgress(const int currentSpineIndex, const float currentSpineRead); uint8_t calculateProgress(const int currentSpineIndex, const float currentSpineRead) const;
}; };

View File

@ -0,0 +1,326 @@
#include "BookMetadataCache.h"
#include <HardwareSerial.h>
#include <SD.h>
#include <Serialization.h>
#include <ZipFile.h>
#include <vector>
#include "FsHelpers.h"
namespace {
constexpr uint8_t BOOK_CACHE_VERSION = 1;
constexpr char bookBinFile[] = "/book.bin";
constexpr char tmpSpineBinFile[] = "/spine.bin.tmp";
constexpr char tmpTocBinFile[] = "/toc.bin.tmp";
} // namespace
/* ============= WRITING / BUILDING FUNCTIONS ================ */
bool BookMetadataCache::beginWrite() {
buildMode = true;
spineCount = 0;
tocCount = 0;
Serial.printf("[%lu] [BMC] Entering write mode\n", millis());
return true;
}
bool BookMetadataCache::beginContentOpfPass() {
Serial.printf("[%lu] [BMC] Beginning content opf pass\n", millis());
// Open spine file for writing
return FsHelpers::openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile);
}
bool BookMetadataCache::endContentOpfPass() {
spineFile.close();
return true;
}
bool BookMetadataCache::beginTocPass() {
Serial.printf("[%lu] [BMC] Beginning toc pass\n", millis());
// Open spine file for reading
if (!FsHelpers::openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
return false;
}
if (!FsHelpers::openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) {
spineFile.close();
return false;
}
return true;
}
bool BookMetadataCache::endTocPass() {
tocFile.close();
spineFile.close();
return true;
}
bool BookMetadataCache::endWrite() {
if (!buildMode) {
Serial.printf("[%lu] [BMC] endWrite called but not in build mode\n", millis());
return false;
}
buildMode = false;
Serial.printf("[%lu] [BMC] Wrote %d spine, %d TOC entries\n", millis(), spineCount, tocCount);
return true;
}
bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMetadata& metadata) {
// Open all three files, writing to meta, reading from spine and toc
if (!FsHelpers::openFileForWrite("BMC", cachePath + bookBinFile, bookFile)) {
return false;
}
if (!FsHelpers::openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
bookFile.close();
return false;
}
if (!FsHelpers::openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) {
bookFile.close();
spineFile.close();
return false;
}
constexpr size_t headerASize =
sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(size_t) + sizeof(spineCount) + sizeof(tocCount);
const size_t metadataSize =
metadata.title.size() + metadata.author.size() + metadata.coverItemHref.size() + sizeof(uint32_t) * 3;
const size_t lutSize = sizeof(size_t) * spineCount + sizeof(size_t) * tocCount;
const size_t lutOffset = headerASize + metadataSize;
// Header A
serialization::writePod(bookFile, BOOK_CACHE_VERSION);
serialization::writePod(bookFile, lutOffset);
serialization::writePod(bookFile, spineCount);
serialization::writePod(bookFile, tocCount);
// Metadata
serialization::writeString(bookFile, metadata.title);
serialization::writeString(bookFile, metadata.author);
serialization::writeString(bookFile, metadata.coverItemHref);
// Loop through spine entries, writing LUT positions
spineFile.seek(0);
for (int i = 0; i < spineCount; i++) {
auto pos = spineFile.position();
auto spineEntry = readSpineEntry(spineFile);
serialization::writePod(bookFile, pos + lutOffset + lutSize);
}
// Loop through toc entries, writing LUT positions
tocFile.seek(0);
for (int i = 0; i < tocCount; i++) {
auto pos = tocFile.position();
auto tocEntry = readTocEntry(tocFile);
serialization::writePod(bookFile, pos + lutOffset + lutSize + spineFile.position());
}
// LUTs complete
// Loop through spines from spine file matching up TOC indexes, calculating cumulative size and writing to book.bin
const ZipFile zip("/sd" + epubPath);
size_t cumSize = 0;
spineFile.seek(0);
for (int i = 0; i < spineCount; i++) {
auto spineEntry = readSpineEntry(spineFile);
tocFile.seek(0);
for (int j = 0; j < tocCount; j++) {
auto tocEntry = readTocEntry(tocFile);
if (tocEntry.spineIndex == i) {
spineEntry.tocIndex = j;
break;
}
}
// Not a huge deal if we don't fine a TOC entry for the spine entry, this is expected behaviour for EPUBs
// Logging here is for debugging
if (spineEntry.tocIndex == -1) {
Serial.printf("[%lu] [BMC] Warning: Could not find TOC entry for spine item %d: %s\n", millis(), i,
spineEntry.href.c_str());
}
// Calculate size for cumulative size
size_t itemSize = 0;
const std::string path = FsHelpers::normalisePath(spineEntry.href);
if (zip.getInflatedFileSize(path.c_str(), &itemSize)) {
cumSize += itemSize;
spineEntry.cumulativeSize = cumSize;
} else {
Serial.printf("[%lu] [BMC] Warning: Could not get size for spine item: %s\n", millis(), path.c_str());
}
// Write out spine data to book.bin
writeSpineEntry(bookFile, spineEntry);
}
// Loop through toc entries from toc file writing to book.bin
tocFile.seek(0);
for (int i = 0; i < tocCount; i++) {
auto tocEntry = readTocEntry(tocFile);
writeTocEntry(bookFile, tocEntry);
}
bookFile.close();
spineFile.close();
tocFile.close();
Serial.printf("[%lu] [BMC] Successfully built book.bin\n", millis());
return true;
}
bool BookMetadataCache::cleanupTmpFiles() const {
if (SD.exists((cachePath + tmpSpineBinFile).c_str())) {
SD.remove((cachePath + tmpSpineBinFile).c_str());
}
if (SD.exists((cachePath + tmpTocBinFile).c_str())) {
SD.remove((cachePath + tmpTocBinFile).c_str());
}
return true;
}
size_t BookMetadataCache::writeSpineEntry(File& file, const SpineEntry& entry) const {
const auto pos = file.position();
serialization::writeString(file, entry.href);
serialization::writePod(file, entry.cumulativeSize);
serialization::writePod(file, entry.tocIndex);
return pos;
}
size_t BookMetadataCache::writeTocEntry(File& file, const TocEntry& entry) const {
const auto pos = file.position();
serialization::writeString(file, entry.title);
serialization::writeString(file, entry.href);
serialization::writeString(file, entry.anchor);
serialization::writePod(file, entry.level);
serialization::writePod(file, entry.spineIndex);
return pos;
}
// Note: for the LUT to be accurate, this **MUST** be called for all spine items before `addTocEntry` is ever called
// this is because in this function we're marking positions of the items
void BookMetadataCache::createSpineEntry(const std::string& href) {
if (!buildMode || !spineFile) {
Serial.printf("[%lu] [BMC] createSpineEntry called but not in build mode\n", millis());
return;
}
const SpineEntry entry(href, 0, -1);
writeSpineEntry(spineFile, entry);
spineCount++;
}
void BookMetadataCache::createTocEntry(const std::string& title, const std::string& href, const std::string& anchor,
const uint8_t level) {
if (!buildMode || !tocFile || !spineFile) {
Serial.printf("[%lu] [BMC] createTocEntry called but not in build mode\n", millis());
return;
}
int spineIndex = -1;
// find spine index
spineFile.seek(0);
for (int i = 0; i < spineCount; i++) {
auto spineEntry = readSpineEntry(spineFile);
if (spineEntry.href == href) {
spineIndex = i;
break;
}
}
if (spineIndex == -1) {
Serial.printf("[%lu] [BMC] addTocEntry: Could not find spine item for TOC href %s\n", millis(), href.c_str());
}
const TocEntry entry(title, href, anchor, level, spineIndex);
writeTocEntry(tocFile, entry);
tocCount++;
}
/* ============= READING / LOADING FUNCTIONS ================ */
bool BookMetadataCache::load() {
if (!FsHelpers::openFileForRead("BMC", cachePath + bookBinFile, bookFile)) {
return false;
}
uint8_t version;
serialization::readPod(bookFile, version);
if (version != BOOK_CACHE_VERSION) {
Serial.printf("[%lu] [BMC] Cache version mismatch: expected %d, got %d\n", millis(), BOOK_CACHE_VERSION, version);
bookFile.close();
return false;
}
serialization::readPod(bookFile, lutOffset);
serialization::readPod(bookFile, spineCount);
serialization::readPod(bookFile, tocCount);
serialization::readString(bookFile, coreMetadata.title);
serialization::readString(bookFile, coreMetadata.author);
serialization::readString(bookFile, coreMetadata.coverItemHref);
loaded = true;
Serial.printf("[%lu] [BMC] Loaded cache data: %d spine, %d TOC entries\n", millis(), spineCount, tocCount);
return true;
}
BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) {
if (!loaded) {
Serial.printf("[%lu] [BMC] getSpineEntry called but cache not loaded\n", millis());
return {};
}
if (index < 0 || index >= static_cast<int>(spineCount)) {
Serial.printf("[%lu] [BMC] getSpineEntry index %d out of range\n", millis(), index);
return {};
}
// Seek to spine LUT item, read from LUT and get out data
bookFile.seek(lutOffset + sizeof(size_t) * index);
size_t spineEntryPos;
serialization::readPod(bookFile, spineEntryPos);
bookFile.seek(spineEntryPos);
return readSpineEntry(bookFile);
}
BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
if (!loaded) {
Serial.printf("[%lu] [BMC] getTocEntry called but cache not loaded\n", millis());
return {};
}
if (index < 0 || index >= static_cast<int>(tocCount)) {
Serial.printf("[%lu] [BMC] getTocEntry index %d out of range\n", millis(), index);
return {};
}
// Seek to TOC LUT item, read from LUT and get out data
bookFile.seek(lutOffset + sizeof(size_t) * spineCount + sizeof(size_t) * index);
size_t tocEntryPos;
serialization::readPod(bookFile, tocEntryPos);
bookFile.seek(tocEntryPos);
return readTocEntry(bookFile);
}
BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(File& file) const {
SpineEntry entry;
serialization::readString(file, entry.href);
serialization::readPod(file, entry.cumulativeSize);
serialization::readPod(file, entry.tocIndex);
return entry;
}
BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(File& file) const {
TocEntry entry;
serialization::readString(file, entry.title);
serialization::readString(file, entry.href);
serialization::readString(file, entry.anchor);
serialization::readPod(file, entry.level);
serialization::readPod(file, entry.spineIndex);
return entry;
}

View File

@ -0,0 +1,87 @@
#pragma once
#include <SD.h>
#include <string>
class BookMetadataCache {
public:
struct BookMetadata {
std::string title;
std::string author;
std::string coverItemHref;
};
struct SpineEntry {
std::string href;
size_t cumulativeSize;
int16_t tocIndex;
SpineEntry() : cumulativeSize(0), tocIndex(-1) {}
SpineEntry(std::string href, const size_t cumulativeSize, const int16_t tocIndex)
: href(std::move(href)), cumulativeSize(cumulativeSize), tocIndex(tocIndex) {}
};
struct TocEntry {
std::string title;
std::string href;
std::string anchor;
uint8_t level;
int16_t spineIndex;
TocEntry() : level(0), spineIndex(-1) {}
TocEntry(std::string title, std::string href, std::string anchor, const uint8_t level, const int16_t spineIndex)
: title(std::move(title)),
href(std::move(href)),
anchor(std::move(anchor)),
level(level),
spineIndex(spineIndex) {}
};
private:
std::string cachePath;
size_t lutOffset;
uint16_t spineCount;
uint16_t tocCount;
bool loaded;
bool buildMode;
File bookFile;
// Temp file handles during build
File spineFile;
File tocFile;
size_t writeSpineEntry(File& file, const SpineEntry& entry) const;
size_t writeTocEntry(File& file, const TocEntry& entry) const;
SpineEntry readSpineEntry(File& file) const;
TocEntry readTocEntry(File& file) const;
public:
BookMetadata coreMetadata;
explicit BookMetadataCache(std::string cachePath)
: cachePath(std::move(cachePath)), lutOffset(0), spineCount(0), tocCount(0), loaded(false), buildMode(false) {}
~BookMetadataCache() = default;
// Building phase (stream to disk immediately)
bool beginWrite();
bool beginContentOpfPass();
void createSpineEntry(const std::string& href);
bool endContentOpfPass();
bool beginTocPass();
void createTocEntry(const std::string& title, const std::string& href, const std::string& anchor, uint8_t level);
bool endTocPass();
bool endWrite();
bool cleanupTmpFiles() const;
// Post-processing to update mappings and sizes
bool buildBookBin(const std::string& epubPath, const BookMetadata& metadata);
// Reading phase (read mode)
bool load();
SpineEntry getSpineEntry(int index);
TocEntry getTocEntry(int index);
int getSpineCount() const { return spineCount; }
int getTocCount() const { return tocCount; }
bool isLoaded() const { return loaded; }
};

View File

@ -1,13 +0,0 @@
#pragma once
#include <string>
class EpubTocEntry {
public:
std::string title;
std::string href;
std::string anchor;
int level;
EpubTocEntry(std::string title, std::string href, std::string anchor, const int level)
: title(std::move(title)), href(std::move(href)), anchor(std::move(anchor)), level(level) {}
};

View File

@ -2,6 +2,26 @@
#include <SD.h> #include <SD.h>
#include <vector>
bool FsHelpers::openFileForRead(const char* moduleName, const std::string& path, File& file) {
file = SD.open(path.c_str(), FILE_READ);
if (!file) {
Serial.printf("[%lu] [%s] Failed to open file for reading: %s\n", millis(), moduleName, path.c_str());
return false;
}
return true;
}
bool FsHelpers::openFileForWrite(const char* moduleName, const std::string& path, File& file) {
file = SD.open(path.c_str(), FILE_WRITE, true);
if (!file) {
Serial.printf("[%lu] [%s] Failed to open file for writing: %s\n", millis(), moduleName, path.c_str());
return false;
}
return true;
}
bool FsHelpers::removeDir(const char* path) { bool FsHelpers::removeDir(const char* path) {
// 1. Open the directory // 1. Open the directory
File dir = SD.open(path); File dir = SD.open(path);
@ -34,3 +54,39 @@ bool FsHelpers::removeDir(const char* path) {
return SD.rmdir(path); return SD.rmdir(path);
} }
std::string FsHelpers::normalisePath(const std::string& path) {
std::vector<std::string> components;
std::string component;
for (const auto c : path) {
if (c == '/') {
if (!component.empty()) {
if (component == "..") {
if (!components.empty()) {
components.pop_back();
}
} else {
components.push_back(component);
}
component.clear();
}
} else {
component += c;
}
}
if (!component.empty()) {
components.push_back(component);
}
std::string result;
for (const auto& c : components) {
if (!result.empty()) {
result += "/";
}
result += c;
}
return result;
}

View File

@ -1,6 +1,12 @@
#pragma once #pragma once
#include <FS.h>
#include <string>
class FsHelpers { class FsHelpers {
public: public:
static bool openFileForRead(const char* moduleName, const std::string& path, File& file);
static bool openFileForWrite(const char* moduleName, const std::string& path, File& file);
static bool removeDir(const char* path); static bool removeDir(const char* path);
static std::string normalisePath(const std::string& path);
}; };

View File

@ -9,21 +9,21 @@ constexpr uint8_t PAGE_FILE_VERSION = 3;
void PageLine::render(GfxRenderer& renderer, const int fontId) { block->render(renderer, fontId, xPos, yPos); } void PageLine::render(GfxRenderer& renderer, const int fontId) { block->render(renderer, fontId, xPos, yPos); }
void PageLine::serialize(std::ostream& os) { void PageLine::serialize(File& file) {
serialization::writePod(os, xPos); serialization::writePod(file, xPos);
serialization::writePod(os, yPos); serialization::writePod(file, yPos);
// serialize TextBlock pointed to by PageLine // serialize TextBlock pointed to by PageLine
block->serialize(os); block->serialize(file);
} }
std::unique_ptr<PageLine> PageLine::deserialize(std::istream& is) { std::unique_ptr<PageLine> PageLine::deserialize(File& file) {
int16_t xPos; int16_t xPos;
int16_t yPos; int16_t yPos;
serialization::readPod(is, xPos); serialization::readPod(file, xPos);
serialization::readPod(is, yPos); serialization::readPod(file, yPos);
auto tb = TextBlock::deserialize(is); auto tb = TextBlock::deserialize(file);
return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos)); return std::unique_ptr<PageLine>(new PageLine(std::move(tb), xPos, yPos));
} }
@ -33,22 +33,22 @@ void Page::render(GfxRenderer& renderer, const int fontId) const {
} }
} }
void Page::serialize(std::ostream& os) const { void Page::serialize(File& file) const {
serialization::writePod(os, PAGE_FILE_VERSION); serialization::writePod(file, PAGE_FILE_VERSION);
const uint32_t count = elements.size(); const uint32_t count = elements.size();
serialization::writePod(os, count); serialization::writePod(file, count);
for (const auto& el : elements) { for (const auto& el : elements) {
// Only PageLine exists currently // Only PageLine exists currently
serialization::writePod(os, static_cast<uint8_t>(TAG_PageLine)); serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine));
el->serialize(os); el->serialize(file);
} }
} }
std::unique_ptr<Page> Page::deserialize(std::istream& is) { std::unique_ptr<Page> Page::deserialize(File& file) {
uint8_t version; uint8_t version;
serialization::readPod(is, version); serialization::readPod(file, version);
if (version != PAGE_FILE_VERSION) { if (version != PAGE_FILE_VERSION) {
Serial.printf("[%lu] [PGE] Deserialization failed: Unknown version %u\n", millis(), version); Serial.printf("[%lu] [PGE] Deserialization failed: Unknown version %u\n", millis(), version);
return nullptr; return nullptr;
@ -57,14 +57,14 @@ std::unique_ptr<Page> Page::deserialize(std::istream& is) {
auto page = std::unique_ptr<Page>(new Page()); auto page = std::unique_ptr<Page>(new Page());
uint32_t count; uint32_t count;
serialization::readPod(is, count); serialization::readPod(file, count);
for (uint32_t i = 0; i < count; i++) { for (uint32_t i = 0; i < count; i++) {
uint8_t tag; uint8_t tag;
serialization::readPod(is, tag); serialization::readPod(file, tag);
if (tag == TAG_PageLine) { if (tag == TAG_PageLine) {
auto pl = PageLine::deserialize(is); auto pl = PageLine::deserialize(file);
page->elements.push_back(std::move(pl)); page->elements.push_back(std::move(pl));
} else { } else {
Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag); Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag);

View File

@ -1,4 +1,6 @@
#pragma once #pragma once
#include <FS.h>
#include <utility> #include <utility>
#include <vector> #include <vector>
@ -16,7 +18,7 @@ class PageElement {
explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {} explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {}
virtual ~PageElement() = default; virtual ~PageElement() = default;
virtual void render(GfxRenderer& renderer, int fontId) = 0; virtual void render(GfxRenderer& renderer, int fontId) = 0;
virtual void serialize(std::ostream& os) = 0; virtual void serialize(File& file) = 0;
}; };
// a line from a block element // a line from a block element
@ -27,8 +29,8 @@ class PageLine final : public PageElement {
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos) PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), block(std::move(block)) {} : PageElement(xPos, yPos), block(std::move(block)) {}
void render(GfxRenderer& renderer, int fontId) override; void render(GfxRenderer& renderer, int fontId) override;
void serialize(std::ostream& os) override; void serialize(File& file) override;
static std::unique_ptr<PageLine> deserialize(std::istream& is); static std::unique_ptr<PageLine> deserialize(File& file);
}; };
class Page { class Page {
@ -36,6 +38,6 @@ class Page {
// the list of block index and line numbers on this page // the list of block index and line numbers on this page
std::vector<std::shared_ptr<PageElement>> elements; std::vector<std::shared_ptr<PageElement>> elements;
void render(GfxRenderer& renderer, int fontId) const; void render(GfxRenderer& renderer, int fontId) const;
void serialize(std::ostream& os) const; void serialize(File& file) const;
static std::unique_ptr<Page> deserialize(std::istream& is); static std::unique_ptr<Page> deserialize(File& file);
}; };

View File

@ -19,14 +19,25 @@ void ParsedText::addWord(std::string word, const EpdFontStyle fontStyle) {
// Consumes data to minimize memory usage // Consumes data to minimize memory usage
void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const int horizontalMargin, void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const int horizontalMargin,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine) { const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
const bool includeLastLine) {
if (words.empty()) { if (words.empty()) {
return; return;
} }
const size_t totalWordCount = words.size();
const int pageWidth = renderer.getScreenWidth() - horizontalMargin; const int pageWidth = renderer.getScreenWidth() - horizontalMargin;
const int spaceWidth = renderer.getSpaceWidth(fontId); const int spaceWidth = renderer.getSpaceWidth(fontId);
const auto wordWidths = calculateWordWidths(renderer, fontId);
const auto lineBreakIndices = computeLineBreaks(pageWidth, spaceWidth, wordWidths);
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
for (size_t i = 0; i < lineCount; ++i) {
extractLine(i, pageWidth, spaceWidth, wordWidths, lineBreakIndices, processLine);
}
}
std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) {
const size_t totalWordCount = words.size();
std::vector<uint16_t> wordWidths; std::vector<uint16_t> wordWidths;
wordWidths.reserve(totalWordCount); wordWidths.reserve(totalWordCount);
@ -47,6 +58,13 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
std::advance(wordStylesIt, 1); std::advance(wordStylesIt, 1);
} }
return wordWidths;
}
std::vector<size_t> ParsedText::computeLineBreaks(const int pageWidth, const int spaceWidth,
const std::vector<uint16_t>& wordWidths) const {
const size_t totalWordCount = words.size();
// DP table to store the minimum badness (cost) of lines starting at index i // DP table to store the minimum badness (cost) of lines starting at index i
std::vector<int> dp(totalWordCount); std::vector<int> dp(totalWordCount);
// 'ans[i]' stores the index 'j' of the *last word* in the optimal line starting at 'i' // 'ans[i]' stores the index 'j' of the *last word* in the optimal line starting at 'i'
@ -88,84 +106,90 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
ans[i] = j; // j is the index of the last word in this optimal line ans[i] = j; // j is the index of the last word in this optimal line
} }
} }
// Handle oversized word: if no valid configuration found, force single-word line
// This prevents cascade failure where one oversized word breaks all preceding words
if (dp[i] == MAX_COST) {
ans[i] = i; // Just this word on its own line
// Inherit cost from next word to allow subsequent words to find valid configurations
if (i + 1 < static_cast<int>(totalWordCount)) {
dp[i] = dp[i + 1];
} else {
dp[i] = 0;
}
}
} }
// Stores the index of the word that starts the next line (last_word_index + 1) // Stores the index of the word that starts the next line (last_word_index + 1)
std::vector<size_t> lineBreakIndices; std::vector<size_t> lineBreakIndices;
size_t currentWordIndex = 0; size_t currentWordIndex = 0;
constexpr size_t MAX_LINES = 1000;
while (currentWordIndex < totalWordCount) { while (currentWordIndex < totalWordCount) {
if (lineBreakIndices.size() >= MAX_LINES) { size_t nextBreakIndex = ans[currentWordIndex] + 1;
break;
// Safety check: prevent infinite loop if nextBreakIndex doesn't advance
if (nextBreakIndex <= currentWordIndex) {
// Force advance by at least one word to avoid infinite loop
nextBreakIndex = currentWordIndex + 1;
} }
size_t nextBreakIndex = ans[currentWordIndex] + 1;
lineBreakIndices.push_back(nextBreakIndex); lineBreakIndices.push_back(nextBreakIndex);
currentWordIndex = nextBreakIndex; currentWordIndex = nextBreakIndex;
} }
// Initialize iterators for consumption return lineBreakIndices;
auto wordStartIt = words.begin(); }
auto wordStyleStartIt = wordStyles.begin();
size_t wordWidthIndex = 0; void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth,
const std::vector<uint16_t>& wordWidths, const std::vector<size_t>& lineBreakIndices,
size_t lastBreakAt = 0; const std::function<void(std::shared_ptr<TextBlock>)>& processLine) {
for (const size_t lineBreak : lineBreakIndices) { const size_t lineBreak = lineBreakIndices[breakIndex];
const size_t lineWordCount = lineBreak - lastBreakAt; const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0;
const size_t lineWordCount = lineBreak - lastBreakAt;
// Calculate end iterators for the range to splice
auto wordEndIt = wordStartIt; // Calculate total word width for this line
auto wordStyleEndIt = wordStyleStartIt; int lineWordWidthSum = 0;
std::advance(wordEndIt, lineWordCount); for (size_t i = lastBreakAt; i < lineBreak; i++) {
std::advance(wordStyleEndIt, lineWordCount); lineWordWidthSum += wordWidths[i];
}
// Calculate total word width for this line
int lineWordWidthSum = 0; // Calculate spacing
for (size_t i = 0; i < lineWordCount; ++i) { const int spareSpace = pageWidth - lineWordWidthSum;
lineWordWidthSum += wordWidths[wordWidthIndex + i];
} int spacing = spaceWidth;
const bool isLastLine = breakIndex == lineBreakIndices.size() - 1;
// Calculate spacing
int spareSpace = pageWidth - lineWordWidthSum; if (style == TextBlock::JUSTIFIED && !isLastLine && lineWordCount >= 2) {
spacing = spareSpace / (lineWordCount - 1);
int spacing = spaceWidth; }
const bool isLastLine = lineBreak == totalWordCount;
// Calculate initial x position
if (style == TextBlock::JUSTIFIED && !isLastLine && lineWordCount >= 2) { uint16_t xpos = 0;
spacing = spareSpace / (lineWordCount - 1); if (style == TextBlock::RIGHT_ALIGN) {
} xpos = spareSpace - (lineWordCount - 1) * spaceWidth;
} else if (style == TextBlock::CENTER_ALIGN) {
// Calculate initial x position xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
uint16_t xpos = 0; }
if (style == TextBlock::RIGHT_ALIGN) {
xpos = spareSpace - (lineWordCount - 1) * spaceWidth; // Pre-calculate X positions for words
} else if (style == TextBlock::CENTER_ALIGN) { std::list<uint16_t> lineXPos;
xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2; for (size_t i = lastBreakAt; i < lineBreak; i++) {
} const uint16_t currentWordWidth = wordWidths[i];
lineXPos.push_back(xpos);
// Pre-calculate X positions for words xpos += currentWordWidth + spacing;
std::list<uint16_t> lineXPos; }
for (size_t i = 0; i < lineWordCount; ++i) {
const uint16_t currentWordWidth = wordWidths[wordWidthIndex + i]; // Iterators always start at the beginning as we are moving content with splice below
lineXPos.push_back(xpos); auto wordEndIt = words.begin();
xpos += currentWordWidth + spacing; auto wordStyleEndIt = wordStyles.begin();
} std::advance(wordEndIt, lineWordCount);
std::advance(wordStyleEndIt, lineWordCount);
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
std::list<std::string> lineWords; // *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
lineWords.splice(lineWords.begin(), words, wordStartIt, wordEndIt); std::list<std::string> lineWords;
std::list<EpdFontStyle> lineWordStyles; lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyleStartIt, wordStyleEndIt); std::list<EpdFontStyle> lineWordStyles;
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
processLine(
std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style)); processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style));
// Update pointers/indices for the next line
wordStartIt = wordEndIt;
wordStyleStartIt = wordStyleEndIt;
wordWidthIndex += lineWordCount;
lastBreakAt = lineBreak;
}
} }

View File

@ -2,11 +2,11 @@
#include <EpdFontFamily.h> #include <EpdFontFamily.h>
#include <cstdint>
#include <functional> #include <functional>
#include <list> #include <list>
#include <memory> #include <memory>
#include <string> #include <string>
#include <vector>
#include "blocks/TextBlock.h" #include "blocks/TextBlock.h"
@ -18,6 +18,12 @@ class ParsedText {
TextBlock::BLOCK_STYLE style; TextBlock::BLOCK_STYLE style;
bool extraParagraphSpacing; bool extraParagraphSpacing;
std::vector<size_t> computeLineBreaks(int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths) const;
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths,
const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine);
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
public: public:
explicit ParsedText(const TextBlock::BLOCK_STYLE style, const bool extraParagraphSpacing) explicit ParsedText(const TextBlock::BLOCK_STYLE style, const bool extraParagraphSpacing)
: style(style), extraParagraphSpacing(extraParagraphSpacing) {} : style(style), extraParagraphSpacing(extraParagraphSpacing) {}
@ -26,7 +32,9 @@ class ParsedText {
void addWord(std::string word, EpdFontStyle fontStyle); void addWord(std::string word, EpdFontStyle fontStyle);
void setStyle(const TextBlock::BLOCK_STYLE style) { this->style = style; } void setStyle(const TextBlock::BLOCK_STYLE style) { this->style = style; }
TextBlock::BLOCK_STYLE getStyle() const { return style; } TextBlock::BLOCK_STYLE getStyle() const { return style; }
size_t size() const { return words.size(); }
bool isEmpty() const { return words.empty(); } bool isEmpty() const { return words.empty(); }
void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, int horizontalMargin, void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, int horizontalMargin,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine); const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
bool includeLastLine = true);
}; };

View File

@ -1,11 +1,9 @@
#include "Section.h" #include "Section.h"
#include <FsHelpers.h>
#include <SD.h> #include <SD.h>
#include <Serialization.h> #include <Serialization.h>
#include <fstream>
#include "FsHelpers.h"
#include "Page.h" #include "Page.h"
#include "parsers/ChapterHtmlSlimParser.h" #include "parsers/ChapterHtmlSlimParser.h"
@ -16,7 +14,10 @@ constexpr uint8_t SECTION_FILE_VERSION = 6;
void Section::onPageComplete(std::unique_ptr<Page> page) { void Section::onPageComplete(std::unique_ptr<Page> page) {
const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin"; const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin";
std::ofstream outputFile("/sd" + filePath); File outputFile;
if (!FsHelpers::openFileForWrite("SCT", filePath, outputFile)) {
return;
}
page->serialize(outputFile); page->serialize(outputFile);
outputFile.close(); outputFile.close();
@ -29,7 +30,10 @@ void Section::writeCacheMetadata(const int fontId, const float lineCompression,
const int marginRight, const int marginBottom, const int marginLeft, const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing, const int screenWidth, const bool extraParagraphSpacing, const int screenWidth,
const int screenHeight) const { const int screenHeight) const {
std::ofstream outputFile(("/sd" + cachePath + "/section.bin").c_str()); File outputFile;
if (!FsHelpers::openFileForWrite("SCT", cachePath + "/section.bin", outputFile)) {
return;
}
serialization::writePod(outputFile, SECTION_FILE_VERSION); serialization::writePod(outputFile, SECTION_FILE_VERSION);
serialization::writePod(outputFile, fontId); serialization::writePod(outputFile, fontId);
serialization::writePod(outputFile, lineCompression); serialization::writePod(outputFile, lineCompression);
@ -47,17 +51,12 @@ void Section::writeCacheMetadata(const int fontId, const float lineCompression,
bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const int marginTop, bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const int marginTop,
const int marginRight, const int marginBottom, const int marginLeft, const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing, const int screenWidth, const int screenHeight) { const bool extraParagraphSpacing, const int screenWidth, const int screenHeight) {
if (!SD.exists(cachePath.c_str())) {
return false;
}
const auto sectionFilePath = cachePath + "/section.bin"; const auto sectionFilePath = cachePath + "/section.bin";
if (!SD.exists(sectionFilePath.c_str())) { File inputFile;
if (!FsHelpers::openFileForRead("SCT", sectionFilePath, inputFile)) {
return false; return false;
} }
std::ifstream inputFile(("/sd" + sectionFilePath).c_str());
// Match parameters // Match parameters
{ {
uint8_t version; uint8_t version;
@ -123,29 +122,57 @@ bool Section::clearCache() const {
bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop, bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop,
const int marginRight, const int marginBottom, const int marginLeft, const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing, const int screenWidth, const int screenHeight) { const bool extraParagraphSpacing, const int screenWidth, const int screenHeight,
const auto localPath = epub->getSpineItem(spineIndex); const std::function<void()>& progressSetupFn,
const std::function<void(int)>& progressFn) {
// TODO: Should we get rid of this file all together? constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
// It currently saves us a bit of memory by allowing for all the inflation bits to be released const auto localPath = epub->getSpineItem(spineIndex).href;
// before loading the XML parser
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
File f = SD.open(tmpHtmlPath.c_str(), FILE_WRITE, true);
bool success = epub->readItemContentsToStream(localPath, f, 1024); // Retry logic for SD card timing issues
f.close(); bool success = false;
size_t fileSize = 0;
for (int attempt = 0; attempt < 3 && !success; attempt++) {
if (attempt > 0) {
Serial.printf("[%lu] [SCT] Retrying stream (attempt %d)...\n", millis(), attempt + 1);
delay(50); // Brief delay before retry
}
// Remove any incomplete file from previous attempt before retrying
if (SD.exists(tmpHtmlPath.c_str())) {
SD.remove(tmpHtmlPath.c_str());
}
File tmpHtml;
if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
continue;
}
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
fileSize = tmpHtml.size();
tmpHtml.close();
// If streaming failed, remove the incomplete file immediately
if (!success && SD.exists(tmpHtmlPath.c_str())) {
SD.remove(tmpHtmlPath.c_str());
Serial.printf("[%lu] [SCT] Removed incomplete temp file after failed attempt\n", millis());
}
}
if (!success) { if (!success) {
Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file\n", millis()); Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file after retries\n", millis());
return false; return false;
} }
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s\n", millis(), tmpHtmlPath.c_str()); Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize);
const auto sdTmpHtmlPath = "/sd" + tmpHtmlPath; // Only show progress bar for larger chapters where rendering overhead is worth it
if (progressSetupFn && fileSize >= MIN_SIZE_FOR_PROGRESS) {
progressSetupFn();
}
ChapterHtmlSlimParser visitor(sdTmpHtmlPath.c_str(), renderer, fontId, lineCompression, marginTop, marginRight, ChapterHtmlSlimParser visitor(
marginBottom, marginLeft, extraParagraphSpacing, tmpHtmlPath, renderer, fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft,
[this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); }); extraParagraphSpacing, [this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); }, progressFn);
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();
SD.remove(tmpHtmlPath.c_str()); SD.remove(tmpHtmlPath.c_str());
@ -161,13 +188,12 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression,
} }
std::unique_ptr<Page> Section::loadPageFromSD() const { std::unique_ptr<Page> Section::loadPageFromSD() const {
const auto filePath = "/sd" + cachePath + "/page_" + std::to_string(currentPage) + ".bin"; const auto filePath = cachePath + "/page_" + std::to_string(currentPage) + ".bin";
if (!SD.exists(filePath.c_str() + 3)) {
Serial.printf("[%lu] [SCT] Page file does not exist: %s\n", millis(), filePath.c_str()); File inputFile;
if (!FsHelpers::openFileForRead("SCT", filePath, inputFile)) {
return nullptr; return nullptr;
} }
std::ifstream inputFile(filePath);
auto page = Page::deserialize(inputFile); auto page = Page::deserialize(inputFile);
inputFile.close(); inputFile.close();
return page; return page;

View File

@ -1,4 +1,5 @@
#pragma once #pragma once
#include <functional>
#include <memory> #include <memory>
#include "Epub.h" #include "Epub.h"
@ -21,15 +22,18 @@ class Section {
int currentPage = 0; int currentPage = 0;
explicit Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer) explicit Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
: epub(epub), spineIndex(spineIndex), renderer(renderer) { : epub(epub),
cachePath = epub->getCachePath() + "/" + std::to_string(spineIndex); spineIndex(spineIndex),
} renderer(renderer),
cachePath(epub->getCachePath() + "/" + std::to_string(spineIndex)) {}
~Section() = default; ~Section() = default;
bool loadCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, bool loadCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom,
int marginLeft, bool extraParagraphSpacing, int screenWidth, int screenHeight); int marginLeft, bool extraParagraphSpacing, int screenWidth, int screenHeight);
void setupCacheDir() const; void setupCacheDir() const;
bool clearCache() const; bool clearCache() const;
bool persistPageDataToSD(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, bool persistPageDataToSD(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom,
int marginLeft, bool extraParagraphSpacing, int screenWidth, int screenHeight); int marginLeft, bool extraParagraphSpacing, int screenWidth, int screenHeight,
const std::function<void()>& progressSetupFn = nullptr,
const std::function<void(int)>& progressFn = nullptr);
std::unique_ptr<Page> loadPageFromSD() const; std::unique_ptr<Page> loadPageFromSD() const;
}; };

View File

@ -4,11 +4,18 @@
#include <Serialization.h> #include <Serialization.h>
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
// Validate iterator bounds before rendering
if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
Serial.printf("[%lu] [TXB] Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(),
(uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size());
return;
}
auto wordIt = words.begin(); auto wordIt = words.begin();
auto wordStylesIt = wordStyles.begin(); auto wordStylesIt = wordStyles.begin();
auto wordXposIt = wordXpos.begin(); auto wordXposIt = wordXpos.begin();
for (int i = 0; i < words.size(); i++) { for (size_t i = 0; i < words.size(); i++) {
renderer.drawText(fontId, *wordXposIt + x, y, wordIt->c_str(), true, *wordStylesIt); renderer.drawText(fontId, *wordXposIt + x, y, wordIt->c_str(), true, *wordStylesIt);
std::advance(wordIt, 1); std::advance(wordIt, 1);
@ -17,27 +24,27 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
} }
} }
void TextBlock::serialize(std::ostream& os) const { void TextBlock::serialize(File& file) const {
// words // words
const uint32_t wc = words.size(); const uint32_t wc = words.size();
serialization::writePod(os, wc); serialization::writePod(file, wc);
for (const auto& w : words) serialization::writeString(os, w); for (const auto& w : words) serialization::writeString(file, w);
// wordXpos // wordXpos
const uint32_t xc = wordXpos.size(); const uint32_t xc = wordXpos.size();
serialization::writePod(os, xc); serialization::writePod(file, xc);
for (auto x : wordXpos) serialization::writePod(os, x); for (auto x : wordXpos) serialization::writePod(file, x);
// wordStyles // wordStyles
const uint32_t sc = wordStyles.size(); const uint32_t sc = wordStyles.size();
serialization::writePod(os, sc); serialization::writePod(file, sc);
for (auto s : wordStyles) serialization::writePod(os, s); for (auto s : wordStyles) serialization::writePod(file, s);
// style // style
serialization::writePod(os, style); serialization::writePod(file, style);
} }
std::unique_ptr<TextBlock> TextBlock::deserialize(std::istream& is) { std::unique_ptr<TextBlock> TextBlock::deserialize(File& file) {
uint32_t wc, xc, sc; uint32_t wc, xc, sc;
std::list<std::string> words; std::list<std::string> words;
std::list<uint16_t> wordXpos; std::list<uint16_t> wordXpos;
@ -45,22 +52,36 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(std::istream& is) {
BLOCK_STYLE style; BLOCK_STYLE style;
// words // words
serialization::readPod(is, wc); serialization::readPod(file, wc);
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
if (wc > 10000) {
Serial.printf("[%lu] [TXB] Deserialization failed: word count %u exceeds maximum\n", millis(), wc);
return nullptr;
}
words.resize(wc); words.resize(wc);
for (auto& w : words) serialization::readString(is, w); for (auto& w : words) serialization::readString(file, w);
// wordXpos // wordXpos
serialization::readPod(is, xc); serialization::readPod(file, xc);
wordXpos.resize(xc); wordXpos.resize(xc);
for (auto& x : wordXpos) serialization::readPod(is, x); for (auto& x : wordXpos) serialization::readPod(file, x);
// wordStyles // wordStyles
serialization::readPod(is, sc); serialization::readPod(file, sc);
wordStyles.resize(sc); wordStyles.resize(sc);
for (auto& s : wordStyles) serialization::readPod(is, s); for (auto& s : wordStyles) serialization::readPod(file, s);
// Validate data consistency: all three lists must have the same size
if (wc != xc || wc != sc) {
Serial.printf("[%lu] [TXB] Deserialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(), wc,
xc, sc);
return nullptr;
}
// style // style
serialization::readPod(is, style); serialization::readPod(file, style);
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style)); return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style));
} }

View File

@ -1,5 +1,6 @@
#pragma once #pragma once
#include <EpdFontFamily.h> #include <EpdFontFamily.h>
#include <FS.h>
#include <list> #include <list>
#include <memory> #include <memory>
@ -35,6 +36,6 @@ class TextBlock final : public Block {
// given a renderer works out where to break the words into lines // given a renderer works out where to break the words into lines
void render(const GfxRenderer& renderer, int fontId, int x, int y) const; void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
BlockType getType() override { return TEXT_BLOCK; } BlockType getType() override { return TEXT_BLOCK; }
void serialize(std::ostream& os) const; void serialize(File& file) const;
static std::unique_ptr<TextBlock> deserialize(std::istream& is); static std::unique_ptr<TextBlock> deserialize(File& file);
}; };

View File

@ -1,5 +1,6 @@
#include "ChapterHtmlSlimParser.h" #include "ChapterHtmlSlimParser.h"
#include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <expat.h> #include <expat.h>
@ -10,13 +11,16 @@
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
const char* BLOCK_TAGS[] = {"p", "li", "div", "br"}; // Minimum file size (in bytes) to show progress bar - smaller chapters don't benefit from it
constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
const char* BOLD_TAGS[] = {"b"}; const char* BOLD_TAGS[] = {"b", "strong"};
constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]); constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]);
const char* ITALIC_TAGS[] = {"i"}; const char* ITALIC_TAGS[] = {"i", "em"};
constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]); constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]);
const char* IMAGE_TAGS[] = {"img"}; const char* IMAGE_TAGS[] = {"img"};
@ -143,6 +147,17 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
self->partWordBuffer[self->partWordBufferIndex++] = s[i]; self->partWordBuffer[self->partWordBufferIndex++] = s[i];
} }
// If we have > 750 words buffered up, perform the layout and consume out all but the last line
// There should be enough here to build out 1-2 full pages and doing this will free up a lot of
// memory.
// Spotted when reading Intermezzo, there are some really long text blocks in there.
if (self->currentTextBlock->size() > 750) {
Serial.printf("[%lu] [EHP] Text block too long, splitting into multiple pages\n", millis());
self->currentTextBlock->layoutAndExtractLines(
self->renderer, self->fontId, self->marginLeft + self->marginRight,
[self](const std::shared_ptr<TextBlock>& textBlock) { self->addLineToPage(textBlock); }, false);
}
} }
void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) { void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) {
@ -203,48 +218,75 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false; return false;
} }
XML_SetUserData(parser, this); File file;
XML_SetElementHandler(parser, startElement, endElement); if (!FsHelpers::openFileForRead("EHP", filepath, file)) {
XML_SetCharacterDataHandler(parser, characterData);
FILE* file = fopen(filepath, "r");
if (!file) {
Serial.printf("[%lu] [EHP] Couldn't open file %s\n", millis(), filepath);
XML_ParserFree(parser); XML_ParserFree(parser);
return false; return false;
} }
// Get file size for progress calculation
const size_t totalSize = file.size();
size_t bytesRead = 0;
int lastProgress = -1;
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
do { do {
void* const buf = XML_GetBuffer(parser, 1024); void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) { if (!buf) {
Serial.printf("[%lu] [EHP] Couldn't allocate memory for buffer\n", millis()); Serial.printf("[%lu] [EHP] Couldn't allocate memory for buffer\n", millis());
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser); XML_ParserFree(parser);
fclose(file); file.close();
return false; return false;
} }
const size_t len = fread(buf, 1, 1024, file); const size_t len = file.read(static_cast<uint8_t*>(buf), 1024);
if (ferror(file)) { if (len == 0) {
Serial.printf("[%lu] [EHP] File read error\n", millis()); Serial.printf("[%lu] [EHP] File read error\n", millis());
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser); XML_ParserFree(parser);
fclose(file); file.close();
return false; return false;
} }
done = feof(file); // Update progress (call every 10% change to avoid too frequent updates)
// Only show progress for larger chapters where rendering overhead is worth it
bytesRead += len;
if (progressFn && totalSize >= MIN_SIZE_FOR_PROGRESS) {
const int progress = static_cast<int>((bytesRead * 100) / totalSize);
if (lastProgress / 10 != progress / 10) {
lastProgress = progress;
progressFn(progress);
}
}
done = file.available() == 0;
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), XML_GetCurrentLineNumber(parser), Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser))); XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser); XML_ParserFree(parser);
fclose(file); file.close();
return false; return false;
} }
} while (!done); } while (!done);
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser); XML_ParserFree(parser);
fclose(file); file.close();
// Process last page if there is still text // Process last page if there is still text
if (currentTextBlock) { if (currentTextBlock) {

View File

@ -15,9 +15,10 @@ class GfxRenderer;
#define MAX_WORD_SIZE 200 #define MAX_WORD_SIZE 200
class ChapterHtmlSlimParser { class ChapterHtmlSlimParser {
const char* filepath; const std::string& filepath;
GfxRenderer& renderer; GfxRenderer& renderer;
std::function<void(std::unique_ptr<Page>)> completePageFn; std::function<void(std::unique_ptr<Page>)> completePageFn;
std::function<void(int)> progressFn; // Progress callback (0-100)
int depth = 0; int depth = 0;
int skipUntilDepth = INT_MAX; int skipUntilDepth = INT_MAX;
int boldUntilDepth = INT_MAX; int boldUntilDepth = INT_MAX;
@ -45,10 +46,11 @@ class ChapterHtmlSlimParser {
static void XMLCALL endElement(void* userData, const XML_Char* name); static void XMLCALL endElement(void* userData, const XML_Char* name);
public: public:
explicit ChapterHtmlSlimParser(const char* filepath, GfxRenderer& renderer, const int fontId, explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId,
const float lineCompression, const int marginTop, const int marginRight, const float lineCompression, const int marginTop, const int marginRight,
const int marginBottom, const int marginLeft, const bool extraParagraphSpacing, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing,
const std::function<void(std::unique_ptr<Page>)>& completePageFn) const std::function<void(std::unique_ptr<Page>)>& completePageFn,
const std::function<void(int)>& progressFn = nullptr)
: filepath(filepath), : filepath(filepath),
renderer(renderer), renderer(renderer),
fontId(fontId), fontId(fontId),
@ -58,7 +60,8 @@ class ChapterHtmlSlimParser {
marginBottom(marginBottom), marginBottom(marginBottom),
marginLeft(marginLeft), marginLeft(marginLeft),
extraParagraphSpacing(extraParagraphSpacing), extraParagraphSpacing(extraParagraphSpacing),
completePageFn(completePageFn) {} completePageFn(completePageFn),
progressFn(progressFn) {}
~ChapterHtmlSlimParser() = default; ~ChapterHtmlSlimParser() = default;
bool parseAndBuildPages(); bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line); void addLineToPage(std::shared_ptr<TextBlock> line);

View File

@ -14,12 +14,13 @@ bool ContainerParser::setup() {
return true; return true;
} }
bool ContainerParser::teardown() { ContainerParser::~ContainerParser() {
if (parser) { if (parser) {
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_ParserFree(parser); XML_ParserFree(parser);
parser = nullptr; parser = nullptr;
} }
return true;
} }
size_t ContainerParser::write(const uint8_t data) { return write(&data, 1); } size_t ContainerParser::write(const uint8_t data) { return write(&data, 1); }

View File

@ -23,9 +23,9 @@ class ContainerParser final : public Print {
std::string fullPath; std::string fullPath;
explicit ContainerParser(const size_t xmlSize) : remainingSize(xmlSize) {} explicit ContainerParser(const size_t xmlSize) : remainingSize(xmlSize) {}
~ContainerParser() override;
bool setup(); bool setup();
bool teardown();
size_t write(uint8_t) override; size_t write(uint8_t) override;
size_t write(const uint8_t* buffer, size_t size) override; size_t write(const uint8_t* buffer, size_t size) override;

View File

@ -1,11 +1,16 @@
#include "ContentOpfParser.h" #include "ContentOpfParser.h"
#include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <Serialization.h>
#include <ZipFile.h> #include <ZipFile.h>
#include "../BookMetadataCache.h"
namespace { namespace {
constexpr const char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml"; constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml";
} constexpr char itemCacheFile[] = "/.items.bin";
} // namespace
bool ContentOpfParser::setup() { bool ContentOpfParser::setup() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
@ -20,12 +25,20 @@ bool ContentOpfParser::setup() {
return true; return true;
} }
bool ContentOpfParser::teardown() { ContentOpfParser::~ContentOpfParser() {
if (parser) { if (parser) {
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser); XML_ParserFree(parser);
parser = nullptr; parser = nullptr;
} }
return true; if (tempItemStore) {
tempItemStore.close();
}
if (SD.exists((cachePath + itemCacheFile).c_str())) {
SD.remove((cachePath + itemCacheFile).c_str());
}
} }
size_t ContentOpfParser::write(const uint8_t data) { return write(&data, 1); } size_t ContentOpfParser::write(const uint8_t data) { return write(&data, 1); }
@ -41,6 +54,9 @@ size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) {
if (!buf) { if (!buf) {
Serial.printf("[%lu] [COF] Couldn't allocate memory for buffer\n", millis()); Serial.printf("[%lu] [COF] Couldn't allocate memory for buffer\n", millis());
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser); XML_ParserFree(parser);
parser = nullptr; parser = nullptr;
return 0; return 0;
@ -52,6 +68,9 @@ size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) {
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [COF] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), Serial.printf("[%lu] [COF] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser))); XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser); XML_ParserFree(parser);
parser = nullptr; parser = nullptr;
return 0; return 0;
@ -86,11 +105,21 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) { if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) {
self->state = IN_MANIFEST; self->state = IN_MANIFEST;
if (!FsHelpers::openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
Serial.printf(
"[%lu] [COF] Couldn't open temp items file for writing. This is probably going to be a fatal error.\n",
millis());
}
return; return;
} }
if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) { if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) {
self->state = IN_SPINE; self->state = IN_SPINE;
if (!FsHelpers::openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
Serial.printf(
"[%lu] [COF] Couldn't open temp items file for reading. This is probably going to be a fatal error.\n",
millis());
}
return; return;
} }
@ -127,7 +156,13 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
} }
} }
self->items[itemId] = href; // Write items down to SD card
serialization::writeString(self->tempItemStore, itemId);
serialization::writeString(self->tempItemStore, href);
if (itemId == self->coverItemId) {
self->coverItemHref = href;
}
if (mediaType == MEDIA_TYPE_NCX) { if (mediaType == MEDIA_TYPE_NCX) {
if (self->tocNcxPath.empty()) { if (self->tocNcxPath.empty()) {
@ -140,14 +175,29 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
return; return;
} }
if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) { // NOTE: This relies on spine appearing after item manifest (which is pretty safe as it's part of the EPUB spec)
for (int i = 0; atts[i]; i += 2) { // Only run the spine parsing if there's a cache to add it to
if (strcmp(atts[i], "idref") == 0) { if (self->cache) {
self->spineRefs.emplace_back(atts[i + 1]); if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) {
break; for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "idref") == 0) {
const std::string idref = atts[i + 1];
// Resolve the idref to href using items map
self->tempItemStore.seek(0);
std::string itemId;
std::string href;
while (self->tempItemStore.available()) {
serialization::readString(self->tempItemStore, itemId);
serialization::readString(self->tempItemStore, href);
if (itemId == idref) {
self->cache->createSpineEntry(href);
break;
}
}
}
} }
return;
} }
return;
} }
} }
@ -166,11 +216,13 @@ void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name)
if (self->state == IN_SPINE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) { if (self->state == IN_SPINE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) {
self->state = IN_PACKAGE; self->state = IN_PACKAGE;
self->tempItemStore.close();
return; return;
} }
if (self->state == IN_MANIFEST && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) { if (self->state == IN_MANIFEST && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) {
self->state = IN_PACKAGE; self->state = IN_PACKAGE;
self->tempItemStore.close();
return; return;
} }

View File

@ -1,11 +1,11 @@
#pragma once #pragma once
#include <Print.h> #include <Print.h>
#include <map>
#include "Epub.h" #include "Epub.h"
#include "expat.h" #include "expat.h"
class BookMetadataCache;
class ContentOpfParser final : public Print { class ContentOpfParser final : public Print {
enum ParserState { enum ParserState {
START, START,
@ -16,10 +16,14 @@ class ContentOpfParser final : public Print {
IN_SPINE, IN_SPINE,
}; };
const std::string& cachePath;
const std::string& baseContentPath; const std::string& baseContentPath;
size_t remainingSize; size_t remainingSize;
XML_Parser parser = nullptr; XML_Parser parser = nullptr;
ParserState state = START; ParserState state = START;
BookMetadataCache* cache;
File tempItemStore;
std::string coverItemId;
static void startElement(void* userData, const XML_Char* name, const XML_Char** atts); static void startElement(void* userData, const XML_Char* name, const XML_Char** atts);
static void characterData(void* userData, const XML_Char* s, int len); static void characterData(void* userData, const XML_Char* s, int len);
@ -28,15 +32,14 @@ class ContentOpfParser final : public Print {
public: public:
std::string title; std::string title;
std::string tocNcxPath; std::string tocNcxPath;
std::string coverItemId; std::string coverItemHref;
std::map<std::string, std::string> items;
std::vector<std::string> spineRefs;
explicit ContentOpfParser(const std::string& baseContentPath, const size_t xmlSize) explicit ContentOpfParser(const std::string& cachePath, const std::string& baseContentPath, const size_t xmlSize,
: baseContentPath(baseContentPath), remainingSize(xmlSize) {} BookMetadataCache* cache)
: cachePath(cachePath), baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {}
~ContentOpfParser() override;
bool setup(); bool setup();
bool teardown();
size_t write(uint8_t) override; size_t write(uint8_t) override;
size_t write(const uint8_t* buffer, size_t size) override; size_t write(const uint8_t* buffer, size_t size) override;

View File

@ -2,6 +2,8 @@
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include "../BookMetadataCache.h"
bool TocNcxParser::setup() { bool TocNcxParser::setup() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
if (!parser) { if (!parser) {
@ -15,12 +17,14 @@ bool TocNcxParser::setup() {
return true; return true;
} }
bool TocNcxParser::teardown() { TocNcxParser::~TocNcxParser() {
if (parser) { if (parser) {
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser); XML_ParserFree(parser);
parser = nullptr; parser = nullptr;
} }
return true;
} }
size_t TocNcxParser::write(const uint8_t data) { return write(&data, 1); } size_t TocNcxParser::write(const uint8_t data) { return write(&data, 1); }
@ -35,6 +39,11 @@ size_t TocNcxParser::write(const uint8_t* buffer, const size_t size) {
void* const buf = XML_GetBuffer(parser, 1024); void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) { if (!buf) {
Serial.printf("[%lu] [TOC] Couldn't allocate memory for buffer\n", millis()); Serial.printf("[%lu] [TOC] Couldn't allocate memory for buffer\n", millis());
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
return 0; return 0;
} }
@ -44,6 +53,11 @@ size_t TocNcxParser::write(const uint8_t* buffer, const size_t size) {
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [TOC] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), Serial.printf("[%lu] [TOC] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser))); XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
parser = nullptr;
return 0; return 0;
} }
@ -154,8 +168,9 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) {
href = href.substr(0, pos); href = href.substr(0, pos);
} }
// Push to vector if (self->cache) {
self->toc.emplace_back(self->currentLabel, href, anchor, self->currentDepth); self->cache->createTocEntry(self->currentLabel, href, anchor, self->currentDepth);
}
// Clear them so we don't re-add them if there are weird XML structures // Clear them so we don't re-add them if there are weird XML structures
self->currentLabel.clear(); self->currentLabel.clear();

View File

@ -1,11 +1,10 @@
#pragma once #pragma once
#include <Print.h> #include <Print.h>
#include <expat.h>
#include <string> #include <string>
#include <vector>
#include "Epub/EpubTocEntry.h" class BookMetadataCache;
#include "expat.h"
class TocNcxParser final : public Print { class TocNcxParser final : public Print {
enum ParserState { START, IN_NCX, IN_NAV_MAP, IN_NAV_POINT, IN_NAV_LABEL, IN_NAV_LABEL_TEXT, IN_CONTENT }; enum ParserState { START, IN_NCX, IN_NAV_MAP, IN_NAV_POINT, IN_NAV_LABEL, IN_NAV_LABEL_TEXT, IN_CONTENT };
@ -14,23 +13,22 @@ class TocNcxParser final : public Print {
size_t remainingSize; size_t remainingSize;
XML_Parser parser = nullptr; XML_Parser parser = nullptr;
ParserState state = START; ParserState state = START;
BookMetadataCache* cache;
std::string currentLabel; std::string currentLabel;
std::string currentSrc; std::string currentSrc;
size_t currentDepth = 0; uint8_t currentDepth = 0;
static void startElement(void* userData, const XML_Char* name, const XML_Char** atts); static void startElement(void* userData, const XML_Char* name, const XML_Char** atts);
static void characterData(void* userData, const XML_Char* s, int len); static void characterData(void* userData, const XML_Char* s, int len);
static void endElement(void* userData, const XML_Char* name); static void endElement(void* userData, const XML_Char* name);
public: public:
std::vector<EpubTocEntry> toc; explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache)
: baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {}
explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize) ~TocNcxParser() override;
: baseContentPath(baseContentPath), remainingSize(xmlSize) {}
bool setup(); bool setup();
bool teardown();
size_t write(uint8_t) override; size_t write(uint8_t) override;
size_t write(const uint8_t* buffer, size_t size) override; size_t write(const uint8_t* buffer, size_t size) override;

112
lib/FsHelpers/FsHelpers.cpp Normal file
View File

@ -0,0 +1,112 @@
#include "FsHelpers.h"
#include <SD.h>
#include <vector>
bool FsHelpers::openFileForRead(const char* moduleName, const char* path, File& file) {
if (!SD.exists(path)) {
return false;
}
file = SD.open(path, FILE_READ);
if (!file) {
Serial.printf("[%lu] [%s] Failed to open file for reading: %s\n", millis(), moduleName, path);
return false;
}
return true;
}
bool FsHelpers::openFileForRead(const char* moduleName, const std::string& path, File& file) {
return openFileForRead(moduleName, path.c_str(), file);
}
bool FsHelpers::openFileForRead(const char* moduleName, const String& path, File& file) {
return openFileForRead(moduleName, path.c_str(), file);
}
bool FsHelpers::openFileForWrite(const char* moduleName, const char* path, File& file) {
file = SD.open(path, FILE_WRITE, true);
if (!file) {
Serial.printf("[%lu] [%s] Failed to open file for writing: %s\n", millis(), moduleName, path);
return false;
}
return true;
}
bool FsHelpers::openFileForWrite(const char* moduleName, const std::string& path, File& file) {
return openFileForWrite(moduleName, path.c_str(), file);
}
bool FsHelpers::openFileForWrite(const char* moduleName, const String& path, File& file) {
return openFileForWrite(moduleName, path.c_str(), file);
}
bool FsHelpers::removeDir(const char* path) {
// 1. Open the directory
File dir = SD.open(path);
if (!dir) {
return false;
}
if (!dir.isDirectory()) {
return false;
}
File file = dir.openNextFile();
while (file) {
String filePath = path;
if (!filePath.endsWith("/")) {
filePath += "/";
}
filePath += file.name();
if (file.isDirectory()) {
if (!removeDir(filePath.c_str())) {
return false;
}
} else {
if (!SD.remove(filePath.c_str())) {
return false;
}
}
file = dir.openNextFile();
}
return SD.rmdir(path);
}
std::string FsHelpers::normalisePath(const std::string& path) {
std::vector<std::string> components;
std::string component;
for (const auto c : path) {
if (c == '/') {
if (!component.empty()) {
if (component == "..") {
if (!components.empty()) {
components.pop_back();
}
} else {
components.push_back(component);
}
component.clear();
}
} else {
component += c;
}
}
if (!component.empty()) {
components.push_back(component);
}
std::string result;
for (const auto& c : components) {
if (!result.empty()) {
result += "/";
}
result += c;
}
return result;
}

14
lib/FsHelpers/FsHelpers.h Normal file
View File

@ -0,0 +1,14 @@
#pragma once
#include <FS.h>
class FsHelpers {
public:
static bool openFileForRead(const char* moduleName, const char* path, File& file);
static bool openFileForRead(const char* moduleName, const std::string& path, File& file);
static bool openFileForRead(const char* moduleName, const String& path, File& file);
static bool openFileForWrite(const char* moduleName, const char* path, File& file);
static bool openFileForWrite(const char* moduleName, const std::string& path, File& file);
static bool openFileForWrite(const char* moduleName, const String& path, File& file);
static bool removeDir(const char* path);
static std::string normalisePath(const std::string& path);
};

View File

@ -3,6 +3,126 @@
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
// ============================================================================
// IMAGE PROCESSING OPTIONS - Toggle these to test different configurations
// ============================================================================
// Note: For cover images, dithering is done in JpegToBmpConverter.cpp
// This file handles BMP reading - use simple quantization to avoid double-dithering
constexpr bool USE_FLOYD_STEINBERG = false; // Disabled - dithering done at JPEG conversion
constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering
// Brightness adjustments:
constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments
constexpr int BRIGHTNESS_BOOST = 20; // Brightness offset (0-50), only if USE_BRIGHTNESS=true
constexpr bool GAMMA_CORRECTION = false; // Gamma curve, only if USE_BRIGHTNESS=true
// ============================================================================
// Integer approximation of gamma correction (brightens midtones)
static inline int applyGamma(int gray) {
if (!GAMMA_CORRECTION) return gray;
const int product = gray * 255;
int x = gray;
if (x > 0) {
x = (x + product / x) >> 1;
x = (x + product / x) >> 1;
}
return x > 255 ? 255 : x;
}
// Simple quantization without dithering - just divide into 4 levels
static inline uint8_t quantizeSimple(int gray) {
if (USE_BRIGHTNESS) {
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
gray = applyGamma(gray);
}
return static_cast<uint8_t>(gray >> 6);
}
// Hash-based noise dithering - survives downsampling without moiré artifacts
static inline uint8_t quantizeNoise(int gray, int x, int y) {
if (USE_BRIGHTNESS) {
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
gray = applyGamma(gray);
}
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24);
const int scaled = gray * 3;
if (scaled < 255) {
return (scaled + threshold >= 255) ? 1 : 0;
} else if (scaled < 510) {
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
} else {
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
}
}
// Main quantization function
static inline uint8_t quantize(int gray, int x, int y) {
if (USE_NOISE_DITHERING) {
return quantizeNoise(gray, x, y);
} else {
return quantizeSimple(gray);
}
}
// Floyd-Steinberg quantization with error diffusion and serpentine scanning
// Returns 2-bit value (0-3) and updates error buffers
static inline uint8_t quantizeFloydSteinberg(int gray, int x, int width, int16_t* errorCurRow, int16_t* errorNextRow,
bool reverseDir) {
// Add accumulated error to this pixel
int adjusted = gray + errorCurRow[x + 1];
// Clamp to valid range
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels (0, 85, 170, 255)
uint8_t quantized;
int quantizedValue;
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
// Calculate error
int error = adjusted - quantizedValue;
// Distribute error to neighbors (serpentine: direction-aware)
if (!reverseDir) {
// Left to right
errorCurRow[x + 2] += (error * 7) >> 4; // Right: 7/16
errorNextRow[x] += (error * 3) >> 4; // Bottom-left: 3/16
errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16
errorNextRow[x + 2] += (error) >> 4; // Bottom-right: 1/16
} else {
// Right to left (mirrored)
errorCurRow[x] += (error * 7) >> 4; // Left: 7/16
errorNextRow[x + 2] += (error * 3) >> 4; // Bottom-right: 3/16
errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16
errorNextRow[x] += (error) >> 4; // Bottom-left: 1/16
}
return quantized;
}
Bitmap::~Bitmap() {
delete[] errorCurRow;
delete[] errorNextRow;
}
uint16_t Bitmap::readLE16(File& f) { uint16_t Bitmap::readLE16(File& f) {
const int c0 = f.read(); const int c0 = f.read();
const int c1 = f.read(); const int c1 = f.read();
@ -46,6 +166,8 @@ const char* Bitmap::errorToString(BmpReaderError err) {
return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)"; return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)";
case BmpReaderError::BadDimensions: case BmpReaderError::BadDimensions:
return "BadDimensions"; return "BadDimensions";
case BmpReaderError::ImageTooLarge:
return "ImageTooLarge (max 2048x3072)";
case BmpReaderError::PaletteTooLarge: case BmpReaderError::PaletteTooLarge:
return "PaletteTooLarge"; return "PaletteTooLarge";
@ -99,6 +221,13 @@ BmpReaderError Bitmap::parseHeaders() {
if (width <= 0 || height <= 0) return BmpReaderError::BadDimensions; if (width <= 0 || height <= 0) return BmpReaderError::BadDimensions;
// Safety limits to prevent memory issues on ESP32
constexpr int MAX_IMAGE_WIDTH = 2048;
constexpr int MAX_IMAGE_HEIGHT = 3072;
if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT) {
return BmpReaderError::ImageTooLarge;
}
// Pre-calculate Row Bytes to avoid doing this every row // Pre-calculate Row Bytes to avoid doing this every row
rowBytes = (width * bpp + 31) / 32 * 4; rowBytes = (width * bpp + 31) / 32 * 4;
@ -115,21 +244,56 @@ BmpReaderError Bitmap::parseHeaders() {
return BmpReaderError::SeekPixelDataFailed; return BmpReaderError::SeekPixelDataFailed;
} }
// Allocate Floyd-Steinberg error buffers if enabled
if (USE_FLOYD_STEINBERG) {
delete[] errorCurRow;
delete[] errorNextRow;
errorCurRow = new int16_t[width + 2](); // +2 for boundary handling
errorNextRow = new int16_t[width + 2]();
lastRowY = -1;
}
return BmpReaderError::Ok; return BmpReaderError::Ok;
} }
// packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white // packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white
BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const { BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const {
// Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes' // Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes'
if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow;
// Handle Floyd-Steinberg error buffer progression
const bool useFS = USE_FLOYD_STEINBERG && errorCurRow && errorNextRow;
if (useFS) {
// Check if we need to advance to next row (or reset if jumping)
if (rowY != lastRowY + 1 && rowY != 0) {
// Non-sequential row access - reset error buffers
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
} else if (rowY > 0) {
// Sequential access - swap buffers
int16_t* temp = errorCurRow;
errorCurRow = errorNextRow;
errorNextRow = temp;
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
}
lastRowY = rowY;
}
uint8_t* outPtr = data; uint8_t* outPtr = data;
uint8_t currentOutByte = 0; uint8_t currentOutByte = 0;
int bitShift = 6; int bitShift = 6;
int currentX = 0;
// Helper lambda to pack 2bpp color into the output stream // Helper lambda to pack 2bpp color into the output stream
auto packPixel = [&](uint8_t lum) { auto packPixel = [&](const uint8_t lum) {
uint8_t color = (lum >> 6); // Simple 2-bit reduction: 0-255 -> 0-3 uint8_t color;
if (useFS) {
// Floyd-Steinberg error diffusion
color = quantizeFloydSteinberg(lum, currentX, width, errorCurRow, errorNextRow, false);
} else {
// Simple quantization or noise dithering
color = quantize(lum, currentX, rowY);
}
currentOutByte |= (color << bitShift); currentOutByte |= (color << bitShift);
if (bitShift == 0) { if (bitShift == 0) {
*outPtr++ = currentOutByte; *outPtr++ = currentOutByte;
@ -138,40 +302,52 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const {
} else { } else {
bitShift -= 2; bitShift -= 2;
} }
currentX++;
}; };
uint8_t lum;
switch (bpp) { switch (bpp) {
case 8: { case 32: {
const uint8_t* p = rowBuffer;
for (int x = 0; x < width; x++) { for (int x = 0; x < width; x++) {
packPixel(paletteLum[rowBuffer[x]]); lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
packPixel(lum);
p += 4;
} }
break; break;
} }
case 24: { case 24: {
const uint8_t* p = rowBuffer; const uint8_t* p = rowBuffer;
for (int x = 0; x < width; x++) { for (int x = 0; x < width; x++) {
uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
packPixel(lum); packPixel(lum);
p += 3; p += 3;
} }
break; break;
} }
case 8: {
for (int x = 0; x < width; x++) {
packPixel(paletteLum[rowBuffer[x]]);
}
break;
}
case 2: {
for (int x = 0; x < width; x++) {
lum = paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03];
packPixel(lum);
}
break;
}
case 1: { case 1: {
for (int x = 0; x < width; x++) { for (int x = 0; x < width; x++) {
uint8_t lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00; lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00;
packPixel(lum); packPixel(lum);
} }
break; break;
} }
case 32: { default:
const uint8_t* p = rowBuffer; return BmpReaderError::UnsupportedBpp;
for (int x = 0; x < width; x++) {
uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
packPixel(lum);
p += 4;
}
break;
}
} }
// Flush remaining bits if width is not a multiple of 4 // Flush remaining bits if width is not a multiple of 4
@ -185,5 +361,12 @@ BmpReaderError Bitmap::rewindToData() const {
return BmpReaderError::SeekPixelDataFailed; return BmpReaderError::SeekPixelDataFailed;
} }
// Reset Floyd-Steinberg error buffers when rewinding
if (USE_FLOYD_STEINBERG && errorCurRow && errorNextRow) {
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
lastRowY = -1;
}
return BmpReaderError::Ok; return BmpReaderError::Ok;
} }

View File

@ -15,6 +15,7 @@ enum class BmpReaderError : uint8_t {
UnsupportedCompression, UnsupportedCompression,
BadDimensions, BadDimensions,
ImageTooLarge,
PaletteTooLarge, PaletteTooLarge,
SeekPixelDataFailed, SeekPixelDataFailed,
@ -28,8 +29,9 @@ class Bitmap {
static const char* errorToString(BmpReaderError err); static const char* errorToString(BmpReaderError err);
explicit Bitmap(File& file) : file(file) {} explicit Bitmap(File& file) : file(file) {}
~Bitmap();
BmpReaderError parseHeaders(); BmpReaderError parseHeaders();
BmpReaderError readRow(uint8_t* data, uint8_t* rowBuffer) const; BmpReaderError readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const;
BmpReaderError rewindToData() const; BmpReaderError rewindToData() const;
int getWidth() const { return width; } int getWidth() const { return width; }
int getHeight() const { return height; } int getHeight() const { return height; }
@ -49,4 +51,9 @@ class Bitmap {
uint16_t bpp = 0; uint16_t bpp = 0;
int rowBytes = 0; int rowBytes = 0;
uint8_t paletteLum[256] = {}; uint8_t paletteLum[256] = {};
// Floyd-Steinberg dithering state (mutable for const methods)
mutable int16_t* errorCurRow = nullptr;
mutable int16_t* errorNextRow = nullptr;
mutable int lastRowY = -1; // Track row progression for error propagation
}; };

View File

@ -164,10 +164,19 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
isScaled = true; isScaled = true;
} }
const uint8_t outputRowSize = (bitmap.getWidth() + 3) / 4; // Calculate output row size (2 bits per pixel, packed into bytes)
// IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels wide
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize)); auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes())); auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) {
Serial.printf("[%lu] [GFX] !! Failed to allocate BMP row buffers\n", millis());
free(outputRow);
free(rowBytes);
return;
}
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
// The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative). // The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative).
// Screen's (0, 0) is the top-left corner. // Screen's (0, 0) is the top-left corner.
@ -179,7 +188,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
break; break;
} }
if (bitmap.readRow(outputRow, rowBytes) != BmpReaderError::Ok) { if (bitmap.readRow(outputRow, rowBytes, bmpY) != BmpReaderError::Ok) {
Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY); Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY);
free(outputRow); free(outputRow);
free(rowBytes); free(rowBytes);
@ -215,6 +224,10 @@ void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScre
void GfxRenderer::invertScreen() const { void GfxRenderer::invertScreen() const {
uint8_t* buffer = einkDisplay.getFrameBuffer(); uint8_t* buffer = einkDisplay.getFrameBuffer();
if (!buffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis());
return;
}
for (int i = 0; i < EInkDisplay::BUFFER_SIZE; i++) { for (int i = 0; i < EInkDisplay::BUFFER_SIZE; i++) {
buffer[i] = ~buffer[i]; buffer[i] = ~buffer[i];
} }
@ -293,6 +306,28 @@ int GfxRenderer::getLineHeight(const int fontId) const {
return fontMap.at(fontId).getData(REGULAR)->advanceY; return fontMap.at(fontId).getData(REGULAR)->advanceY;
} }
void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const {
const int pageHeight = getScreenHeight();
constexpr int buttonWidth = 106;
constexpr int buttonHeight = 40;
constexpr int buttonY = 40; // Distance from bottom
constexpr int textYOffset = 5; // Distance from top of button to text baseline
constexpr int buttonPositions[] = {25, 130, 245, 350};
const char* labels[] = {btn1, btn2, btn3, btn4};
for (int i = 0; i < 4; i++) {
// Only draw if the label is non-empty
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int x = buttonPositions[i];
drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight);
const int textWidth = getTextWidth(fontId, labels[i]);
const int textX = x + (buttonWidth - 1 - textWidth) / 2;
drawText(fontId, textX, pageHeight - buttonY + textYOffset, labels[i]);
}
}
}
uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); } uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; } size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }
@ -321,6 +356,10 @@ void GfxRenderer::freeBwBufferChunks() {
*/ */
void GfxRenderer::storeBwBuffer() { void GfxRenderer::storeBwBuffer() {
const uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); const uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
if (!frameBuffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis());
return;
}
// Allocate and copy each chunk // Allocate and copy each chunk
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
@ -371,6 +410,12 @@ void GfxRenderer::restoreBwBuffer() {
} }
uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
if (!frameBuffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis());
freeBwBufferChunks();
return;
}
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
// Check if chunk is missing // Check if chunk is missing
if (!bwBufferChunks[i]) { if (!bwBufferChunks[i]) {

View File

@ -71,6 +71,9 @@ class GfxRenderer {
int getSpaceWidth(int fontId) const; int getSpaceWidth(int fontId) const;
int getLineHeight(int fontId) const; int getLineHeight(int fontId) const;
// UI Components
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const;
// Grayscale functions // Grayscale functions
void setRenderMode(const RenderMode mode) { this->renderMode = mode; } void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
void copyGrayscaleLsbBuffers() const; void copyGrayscaleLsbBuffers() const;

View File

@ -0,0 +1,736 @@
#include "JpegToBmpConverter.h"
#include <picojpeg.h>
#include <cstdio>
#include <cstring>
// Context structure for picojpeg callback
struct JpegReadContext {
File& file;
uint8_t buffer[512];
size_t bufferPos;
size_t bufferFilled;
};
// ============================================================================
// IMAGE PROCESSING OPTIONS - Toggle these to test different configurations
// ============================================================================
constexpr bool USE_8BIT_OUTPUT = false; // true: 8-bit grayscale (no quantization), false: 2-bit (4 levels)
// Dithering method selection (only one should be true, or all false for simple quantization):
constexpr bool USE_ATKINSON = true; // Atkinson dithering (cleaner than F-S, less error diffusion)
constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts)
constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling)
// Brightness/Contrast adjustments:
constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments
constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50)
constexpr bool GAMMA_CORRECTION = true; // Gamma curve (brightens midtones)
constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast)
// Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling)
constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering
constexpr int TARGET_MAX_WIDTH = 480; // Max width for cover images (portrait display width)
constexpr int TARGET_MAX_HEIGHT = 800; // Max height for cover images (portrait display height)
// ============================================================================
// Integer approximation of gamma correction (brightens midtones)
// Uses a simple curve: out = 255 * sqrt(in/255) ≈ sqrt(in * 255)
static inline int applyGamma(int gray) {
if (!GAMMA_CORRECTION) return gray;
// Fast integer square root approximation for gamma ~0.5 (brightening)
// This brightens dark/mid tones while preserving highlights
const int product = gray * 255;
// Newton-Raphson integer sqrt (2 iterations for good accuracy)
int x = gray;
if (x > 0) {
x = (x + product / x) >> 1;
x = (x + product / x) >> 1;
}
return x > 255 ? 255 : x;
}
// Apply contrast adjustment around midpoint (128)
// factor > 1.0 increases contrast, < 1.0 decreases
static inline int applyContrast(int gray) {
// Integer-based contrast: (gray - 128) * factor + 128
// Using fixed-point: factor 1.15 ≈ 115/100
constexpr int factorNum = static_cast<int>(CONTRAST_FACTOR * 100);
int adjusted = ((gray - 128) * factorNum) / 100 + 128;
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
return adjusted;
}
// Combined brightness/contrast/gamma adjustment
static inline int adjustPixel(int gray) {
if (!USE_BRIGHTNESS) return gray;
// Order: contrast first, then brightness, then gamma
gray = applyContrast(gray);
gray += BRIGHTNESS_BOOST;
if (gray > 255) gray = 255;
if (gray < 0) gray = 0;
gray = applyGamma(gray);
return gray;
}
// Simple quantization without dithering - just divide into 4 levels
static inline uint8_t quantizeSimple(int gray) {
gray = adjustPixel(gray);
// Simple 2-bit quantization: 0-63=0, 64-127=1, 128-191=2, 192-255=3
return static_cast<uint8_t>(gray >> 6);
}
// Hash-based noise dithering - survives downsampling without moiré artifacts
// Uses integer hash to generate pseudo-random threshold per pixel
static inline uint8_t quantizeNoise(int gray, int x, int y) {
gray = adjustPixel(gray);
// Generate noise threshold using integer hash (no regular pattern to alias)
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
hash = (hash ^ (hash >> 13)) * 1274126177u;
const int threshold = static_cast<int>(hash >> 24); // 0-255
// Map gray (0-255) to 4 levels with dithering
const int scaled = gray * 3;
if (scaled < 255) {
return (scaled + threshold >= 255) ? 1 : 0;
} else if (scaled < 510) {
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
} else {
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
}
}
// Main quantization function - selects between methods based on config
static inline uint8_t quantize(int gray, int x, int y) {
if (USE_NOISE_DITHERING) {
return quantizeNoise(gray, x, y);
} else {
return quantizeSimple(gray);
}
}
// Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results
// Error distribution pattern:
// X 1/8 1/8
// 1/8 1/8 1/8
// 1/8
// Less error buildup = fewer artifacts than Floyd-Steinberg
class AtkinsonDitherer {
public:
AtkinsonDitherer(int width) : width(width) {
errorRow0 = new int16_t[width + 4](); // Current row
errorRow1 = new int16_t[width + 4](); // Next row
errorRow2 = new int16_t[width + 4](); // Row after next
}
~AtkinsonDitherer() {
delete[] errorRow0;
delete[] errorRow1;
delete[] errorRow2;
}
uint8_t processPixel(int gray, int x) {
// Apply brightness/contrast/gamma adjustments
gray = adjustPixel(gray);
// Add accumulated error
int adjusted = gray + errorRow0[x + 2];
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels
uint8_t quantized;
int quantizedValue;
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
// Calculate error (only distribute 6/8 = 75%)
int error = (adjusted - quantizedValue) >> 3; // error/8
// Distribute 1/8 to each of 6 neighbors
errorRow0[x + 3] += error; // Right
errorRow0[x + 4] += error; // Right+1
errorRow1[x + 1] += error; // Bottom-left
errorRow1[x + 2] += error; // Bottom
errorRow1[x + 3] += error; // Bottom-right
errorRow2[x + 2] += error; // Two rows down
return quantized;
}
void nextRow() {
int16_t* temp = errorRow0;
errorRow0 = errorRow1;
errorRow1 = errorRow2;
errorRow2 = temp;
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
void reset() {
memset(errorRow0, 0, (width + 4) * sizeof(int16_t));
memset(errorRow1, 0, (width + 4) * sizeof(int16_t));
memset(errorRow2, 0, (width + 4) * sizeof(int16_t));
}
private:
int width;
int16_t* errorRow0;
int16_t* errorRow1;
int16_t* errorRow2;
};
// Floyd-Steinberg error diffusion dithering with serpentine scanning
// Serpentine scanning alternates direction each row to reduce "worm" artifacts
// Error distribution pattern (left-to-right):
// X 7/16
// 3/16 5/16 1/16
// Error distribution pattern (right-to-left, mirrored):
// 1/16 5/16 3/16
// 7/16 X
class FloydSteinbergDitherer {
public:
FloydSteinbergDitherer(int width) : width(width), rowCount(0) {
errorCurRow = new int16_t[width + 2](); // +2 for boundary handling
errorNextRow = new int16_t[width + 2]();
}
~FloydSteinbergDitherer() {
delete[] errorCurRow;
delete[] errorNextRow;
}
// Process a single pixel and return quantized 2-bit value
// x is the logical x position (0 to width-1), direction handled internally
uint8_t processPixel(int gray, int x, bool reverseDirection) {
// Add accumulated error to this pixel
int adjusted = gray + errorCurRow[x + 1];
// Clamp to valid range
if (adjusted < 0) adjusted = 0;
if (adjusted > 255) adjusted = 255;
// Quantize to 4 levels (0, 85, 170, 255)
uint8_t quantized;
int quantizedValue;
if (adjusted < 43) {
quantized = 0;
quantizedValue = 0;
} else if (adjusted < 128) {
quantized = 1;
quantizedValue = 85;
} else if (adjusted < 213) {
quantized = 2;
quantizedValue = 170;
} else {
quantized = 3;
quantizedValue = 255;
}
// Calculate error
int error = adjusted - quantizedValue;
// Distribute error to neighbors (serpentine: direction-aware)
if (!reverseDirection) {
// Left to right: standard distribution
// Right: 7/16
errorCurRow[x + 2] += (error * 7) >> 4;
// Bottom-left: 3/16
errorNextRow[x] += (error * 3) >> 4;
// Bottom: 5/16
errorNextRow[x + 1] += (error * 5) >> 4;
// Bottom-right: 1/16
errorNextRow[x + 2] += (error) >> 4;
} else {
// Right to left: mirrored distribution
// Left: 7/16
errorCurRow[x] += (error * 7) >> 4;
// Bottom-right: 3/16
errorNextRow[x + 2] += (error * 3) >> 4;
// Bottom: 5/16
errorNextRow[x + 1] += (error * 5) >> 4;
// Bottom-left: 1/16
errorNextRow[x] += (error) >> 4;
}
return quantized;
}
// Call at the end of each row to swap buffers
void nextRow() {
// Swap buffers
int16_t* temp = errorCurRow;
errorCurRow = errorNextRow;
errorNextRow = temp;
// Clear the next row buffer
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
rowCount++;
}
// Check if current row should be processed in reverse
bool isReverseRow() const { return (rowCount & 1) != 0; }
// Reset for a new image or MCU block
void reset() {
memset(errorCurRow, 0, (width + 2) * sizeof(int16_t));
memset(errorNextRow, 0, (width + 2) * sizeof(int16_t));
rowCount = 0;
}
private:
int width;
int rowCount;
int16_t* errorCurRow;
int16_t* errorNextRow;
};
inline void write16(Print& out, const uint16_t value) {
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
}
inline void write32(Print& out, const uint32_t value) {
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
out.write((value >> 16) & 0xFF);
out.write((value >> 24) & 0xFF);
}
inline void write32Signed(Print& out, const int32_t value) {
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
out.write((value >> 16) & 0xFF);
out.write((value >> 24) & 0xFF);
}
// Helper function: Write BMP header with 8-bit grayscale (256 levels)
void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) {
// Calculate row padding (each row must be multiple of 4 bytes)
const int bytesPerRow = (width + 3) / 4 * 4; // 8 bits per pixel, padded
const int imageSize = bytesPerRow * height;
const uint32_t paletteSize = 256 * 4; // 256 colors * 4 bytes (BGRA)
const uint32_t fileSize = 14 + 40 + paletteSize + imageSize;
// BMP File Header (14 bytes)
bmpOut.write('B');
bmpOut.write('M');
write32(bmpOut, fileSize);
write32(bmpOut, 0); // Reserved
write32(bmpOut, 14 + 40 + paletteSize); // Offset to pixel data
// DIB Header (BITMAPINFOHEADER - 40 bytes)
write32(bmpOut, 40);
write32Signed(bmpOut, width);
write32Signed(bmpOut, -height); // Negative height = top-down bitmap
write16(bmpOut, 1); // Color planes
write16(bmpOut, 8); // Bits per pixel (8 bits)
write32(bmpOut, 0); // BI_RGB (no compression)
write32(bmpOut, imageSize);
write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI)
write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI)
write32(bmpOut, 256); // colorsUsed
write32(bmpOut, 256); // colorsImportant
// Color Palette (256 grayscale entries x 4 bytes = 1024 bytes)
for (int i = 0; i < 256; i++) {
bmpOut.write(static_cast<uint8_t>(i)); // Blue
bmpOut.write(static_cast<uint8_t>(i)); // Green
bmpOut.write(static_cast<uint8_t>(i)); // Red
bmpOut.write(static_cast<uint8_t>(0)); // Reserved
}
}
// Helper function: Write BMP header with 2-bit color depth
void JpegToBmpConverter::writeBmpHeader(Print& bmpOut, const int width, const int height) {
// Calculate row padding (each row must be multiple of 4 bytes)
const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up
const int imageSize = bytesPerRow * height;
const uint32_t fileSize = 70 + imageSize; // 14 (file header) + 40 (DIB header) + 16 (palette) + image
// BMP File Header (14 bytes)
bmpOut.write('B');
bmpOut.write('M');
write32(bmpOut, fileSize); // File size
write32(bmpOut, 0); // Reserved
write32(bmpOut, 70); // Offset to pixel data
// DIB Header (BITMAPINFOHEADER - 40 bytes)
write32(bmpOut, 40);
write32Signed(bmpOut, width);
write32Signed(bmpOut, -height); // Negative height = top-down bitmap
write16(bmpOut, 1); // Color planes
write16(bmpOut, 2); // Bits per pixel (2 bits)
write32(bmpOut, 0); // BI_RGB (no compression)
write32(bmpOut, imageSize);
write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI)
write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI)
write32(bmpOut, 4); // colorsUsed
write32(bmpOut, 4); // colorsImportant
// Color Palette (4 colors x 4 bytes = 16 bytes)
// Format: Blue, Green, Red, Reserved (BGRA)
uint8_t palette[16] = {
0x00, 0x00, 0x00, 0x00, // Color 0: Black
0x55, 0x55, 0x55, 0x00, // Color 1: Dark gray (85)
0xAA, 0xAA, 0xAA, 0x00, // Color 2: Light gray (170)
0xFF, 0xFF, 0xFF, 0x00 // Color 3: White
};
for (const uint8_t i : palette) {
bmpOut.write(i);
}
}
// Callback function for picojpeg to read JPEG data
unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data) {
auto* context = static_cast<JpegReadContext*>(pCallback_data);
if (!context || !context->file) {
return PJPG_STREAM_READ_ERROR;
}
// Check if we need to refill our context buffer
if (context->bufferPos >= context->bufferFilled) {
context->bufferFilled = context->file.read(context->buffer, sizeof(context->buffer));
context->bufferPos = 0;
if (context->bufferFilled == 0) {
// EOF or error
*pBytes_actually_read = 0;
return 0; // Success (EOF is normal)
}
}
// Copy available bytes to picojpeg's buffer
const size_t available = context->bufferFilled - context->bufferPos;
const size_t toRead = available < buf_size ? available : buf_size;
memcpy(pBuf, context->buffer + context->bufferPos, toRead);
context->bufferPos += toRead;
*pBytes_actually_read = static_cast<unsigned char>(toRead);
return 0; // Success
}
// Core function: Convert JPEG file to 2-bit BMP
bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) {
Serial.printf("[%lu] [JPG] Converting JPEG to BMP\n", millis());
// Setup context for picojpeg callback
JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0};
// Initialize picojpeg decoder
pjpeg_image_info_t imageInfo;
const unsigned char status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
if (status != 0) {
Serial.printf("[%lu] [JPG] JPEG decode init failed with error code: %d\n", millis(), status);
return false;
}
Serial.printf("[%lu] [JPG] JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d\n", millis(), imageInfo.m_width,
imageInfo.m_height, imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol);
// Safety limits to prevent memory issues on ESP32
constexpr int MAX_IMAGE_WIDTH = 2048;
constexpr int MAX_IMAGE_HEIGHT = 3072;
constexpr int MAX_MCU_ROW_BYTES = 65536;
if (imageInfo.m_width > MAX_IMAGE_WIDTH || imageInfo.m_height > MAX_IMAGE_HEIGHT) {
Serial.printf("[%lu] [JPG] Image too large (%dx%d), max supported: %dx%d\n", millis(), imageInfo.m_width,
imageInfo.m_height, MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT);
return false;
}
// Calculate output dimensions (pre-scale to fit display exactly)
int outWidth = imageInfo.m_width;
int outHeight = imageInfo.m_height;
// Use fixed-point scaling (16.16) for sub-pixel accuracy
uint32_t scaleX_fp = 65536; // 1.0 in 16.16 fixed point
uint32_t scaleY_fp = 65536;
bool needsScaling = false;
if (USE_PRESCALE && (imageInfo.m_width > TARGET_MAX_WIDTH || imageInfo.m_height > TARGET_MAX_HEIGHT)) {
// Calculate scale to fit within target dimensions while maintaining aspect ratio
const float scaleToFitWidth = static_cast<float>(TARGET_MAX_WIDTH) / imageInfo.m_width;
const float scaleToFitHeight = static_cast<float>(TARGET_MAX_HEIGHT) / imageInfo.m_height;
const float scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
outWidth = static_cast<int>(imageInfo.m_width * scale);
outHeight = static_cast<int>(imageInfo.m_height * scale);
// Ensure at least 1 pixel
if (outWidth < 1) outWidth = 1;
if (outHeight < 1) outHeight = 1;
// Calculate fixed-point scale factors (source pixels per output pixel)
// scaleX_fp = (srcWidth << 16) / outWidth
scaleX_fp = (static_cast<uint32_t>(imageInfo.m_width) << 16) / outWidth;
scaleY_fp = (static_cast<uint32_t>(imageInfo.m_height) << 16) / outHeight;
needsScaling = true;
Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width,
imageInfo.m_height, outWidth, outHeight, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT);
}
// Write BMP header with output dimensions
int bytesPerRow;
if (USE_8BIT_OUTPUT) {
writeBmpHeader8bit(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth + 3) / 4 * 4;
} else {
writeBmpHeader(bmpOut, outWidth, outHeight);
bytesPerRow = (outWidth * 2 + 31) / 32 * 4;
}
// Allocate row buffer
auto* rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow));
if (!rowBuffer) {
Serial.printf("[%lu] [JPG] Failed to allocate row buffer\n", millis());
return false;
}
// Allocate a buffer for one MCU row worth of grayscale pixels
// This is the minimal memory needed for streaming conversion
const int mcuPixelHeight = imageInfo.m_MCUHeight;
const int mcuRowPixels = imageInfo.m_width * mcuPixelHeight;
// Validate MCU row buffer size before allocation
if (mcuRowPixels > MAX_MCU_ROW_BYTES) {
Serial.printf("[%lu] [JPG] MCU row buffer too large (%d bytes), max: %d\n", millis(), mcuRowPixels,
MAX_MCU_ROW_BYTES);
free(rowBuffer);
return false;
}
auto* mcuRowBuffer = static_cast<uint8_t*>(malloc(mcuRowPixels));
if (!mcuRowBuffer) {
Serial.printf("[%lu] [JPG] Failed to allocate MCU row buffer (%d bytes)\n", millis(), mcuRowPixels);
free(rowBuffer);
return false;
}
// Create ditherer if enabled (only for 2-bit output)
// Use OUTPUT dimensions for dithering (after prescaling)
AtkinsonDitherer* atkinsonDitherer = nullptr;
FloydSteinbergDitherer* fsDitherer = nullptr;
if (!USE_8BIT_OUTPUT) {
if (USE_ATKINSON) {
atkinsonDitherer = new AtkinsonDitherer(outWidth);
} else if (USE_FLOYD_STEINBERG) {
fsDitherer = new FloydSteinbergDitherer(outWidth);
}
}
// For scaling: accumulate source rows into scaled output rows
// We need to track which source Y maps to which output Y
// Using fixed-point: srcY_fp = outY * scaleY_fp (gives source Y in 16.16 format)
uint32_t* rowAccum = nullptr; // Accumulator for each output X (32-bit for larger sums)
uint16_t* rowCount = nullptr; // Count of source pixels accumulated per output X
int currentOutY = 0; // Current output row being accumulated
uint32_t nextOutY_srcStart = 0; // Source Y where next output row starts (16.16 fixed point)
if (needsScaling) {
rowAccum = new uint32_t[outWidth]();
rowCount = new uint16_t[outWidth]();
nextOutY_srcStart = scaleY_fp; // First boundary is at scaleY_fp (source Y for outY=1)
}
// Process MCUs row-by-row and write to BMP as we go (top-down)
const int mcuPixelWidth = imageInfo.m_MCUWidth;
for (int mcuY = 0; mcuY < imageInfo.m_MCUSPerCol; mcuY++) {
// Clear the MCU row buffer
memset(mcuRowBuffer, 0, mcuRowPixels);
// Decode one row of MCUs
for (int mcuX = 0; mcuX < imageInfo.m_MCUSPerRow; mcuX++) {
const unsigned char mcuStatus = pjpeg_decode_mcu();
if (mcuStatus != 0) {
if (mcuStatus == PJPG_NO_MORE_BLOCKS) {
Serial.printf("[%lu] [JPG] Unexpected end of blocks at MCU (%d, %d)\n", millis(), mcuX, mcuY);
} else {
Serial.printf("[%lu] [JPG] JPEG decode MCU failed at (%d, %d) with error code: %d\n", millis(), mcuX, mcuY,
mcuStatus);
}
free(mcuRowBuffer);
free(rowBuffer);
return false;
}
// picojpeg stores MCU data in 8x8 blocks
// Block layout: H2V2(16x16)=0,64,128,192 H2V1(16x8)=0,64 H1V2(8x16)=0,128
for (int blockY = 0; blockY < mcuPixelHeight; blockY++) {
for (int blockX = 0; blockX < mcuPixelWidth; blockX++) {
const int pixelX = mcuX * mcuPixelWidth + blockX;
if (pixelX >= imageInfo.m_width) continue;
// Calculate proper block offset for picojpeg buffer
const int blockCol = blockX / 8;
const int blockRow = blockY / 8;
const int localX = blockX % 8;
const int localY = blockY % 8;
const int blocksPerRow = mcuPixelWidth / 8;
const int blockIndex = blockRow * blocksPerRow + blockCol;
const int pixelOffset = blockIndex * 64 + localY * 8 + localX;
uint8_t gray;
if (imageInfo.m_comps == 1) {
gray = imageInfo.m_pMCUBufR[pixelOffset];
} else {
const uint8_t r = imageInfo.m_pMCUBufR[pixelOffset];
const uint8_t g = imageInfo.m_pMCUBufG[pixelOffset];
const uint8_t b = imageInfo.m_pMCUBufB[pixelOffset];
gray = (r * 25 + g * 50 + b * 25) / 100;
}
mcuRowBuffer[blockY * imageInfo.m_width + pixelX] = gray;
}
}
}
// Process source rows from this MCU row
const int startRow = mcuY * mcuPixelHeight;
const int endRow = (mcuY + 1) * mcuPixelHeight;
for (int y = startRow; y < endRow && y < imageInfo.m_height; y++) {
const int bufferY = y - startRow;
if (!needsScaling) {
// No scaling - direct output (1:1 mapping)
memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT) {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
rowBuffer[x] = adjustPixel(gray);
}
} else {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
uint8_t twoBit;
if (atkinsonDitherer) {
twoBit = atkinsonDitherer->processPixel(gray, x);
} else if (fsDitherer) {
twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow());
} else {
twoBit = quantize(gray, x, y);
}
const int byteIndex = (x * 2) / 8;
const int bitOffset = 6 - ((x * 2) % 8);
rowBuffer[byteIndex] |= (twoBit << bitOffset);
}
if (atkinsonDitherer)
atkinsonDitherer->nextRow();
else if (fsDitherer)
fsDitherer->nextRow();
}
bmpOut.write(rowBuffer, bytesPerRow);
} else {
// Fixed-point area averaging for exact fit scaling
// For each output pixel X, accumulate source pixels that map to it
// srcX range for outX: [outX * scaleX_fp >> 16, (outX+1) * scaleX_fp >> 16)
const uint8_t* srcRow = mcuRowBuffer + bufferY * imageInfo.m_width;
for (int outX = 0; outX < outWidth; outX++) {
// Calculate source X range for this output pixel
const int srcXStart = (static_cast<uint32_t>(outX) * scaleX_fp) >> 16;
const int srcXEnd = (static_cast<uint32_t>(outX + 1) * scaleX_fp) >> 16;
// Accumulate all source pixels in this range
int sum = 0;
int count = 0;
for (int srcX = srcXStart; srcX < srcXEnd && srcX < imageInfo.m_width; srcX++) {
sum += srcRow[srcX];
count++;
}
// Handle edge case: if no pixels in range, use nearest
if (count == 0 && srcXStart < imageInfo.m_width) {
sum = srcRow[srcXStart];
count = 1;
}
rowAccum[outX] += sum;
rowCount[outX] += count;
}
// Check if we've crossed into the next output row
// Current source Y in fixed point: y << 16
const uint32_t srcY_fp = static_cast<uint32_t>(y + 1) << 16;
// Output row when source Y crosses the boundary
if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) {
memset(rowBuffer, 0, bytesPerRow);
if (USE_8BIT_OUTPUT) {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
rowBuffer[x] = adjustPixel(gray);
}
} else {
for (int x = 0; x < outWidth; x++) {
const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0;
uint8_t twoBit;
if (atkinsonDitherer) {
twoBit = atkinsonDitherer->processPixel(gray, x);
} else if (fsDitherer) {
twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow());
} else {
twoBit = quantize(gray, x, currentOutY);
}
const int byteIndex = (x * 2) / 8;
const int bitOffset = 6 - ((x * 2) % 8);
rowBuffer[byteIndex] |= (twoBit << bitOffset);
}
if (atkinsonDitherer)
atkinsonDitherer->nextRow();
else if (fsDitherer)
fsDitherer->nextRow();
}
bmpOut.write(rowBuffer, bytesPerRow);
currentOutY++;
// Reset accumulators for next output row
memset(rowAccum, 0, outWidth * sizeof(uint32_t));
memset(rowCount, 0, outWidth * sizeof(uint16_t));
// Update boundary for next output row
nextOutY_srcStart = static_cast<uint32_t>(currentOutY + 1) * scaleY_fp;
}
}
}
}
// Clean up
if (rowAccum) {
delete[] rowAccum;
}
if (rowCount) {
delete[] rowCount;
}
if (atkinsonDitherer) {
delete atkinsonDitherer;
}
if (fsDitherer) {
delete fsDitherer;
}
free(mcuRowBuffer);
free(rowBuffer);
Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis());
return true;
}

View File

@ -0,0 +1,15 @@
#pragma once
#include <FS.h>
class ZipFile;
class JpegToBmpConverter {
static void writeBmpHeader(Print& bmpOut, int width, int height);
// [COMMENTED OUT] static uint8_t grayscaleTo2Bit(uint8_t grayscale, int x, int y);
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data);
public:
static bool jpegFileToBmpStream(File& jpegFile, Print& bmpOut);
};

View File

@ -1,4 +1,6 @@
#pragma once #pragma once
#include <FS.h>
#include <iostream> #include <iostream>
namespace serialization { namespace serialization {
@ -7,21 +9,44 @@ static void writePod(std::ostream& os, const T& value) {
os.write(reinterpret_cast<const char*>(&value), sizeof(T)); os.write(reinterpret_cast<const char*>(&value), sizeof(T));
} }
template <typename T>
static void writePod(File& file, const T& value) {
file.write(reinterpret_cast<const uint8_t*>(&value), sizeof(T));
}
template <typename T> template <typename T>
static void readPod(std::istream& is, T& value) { static void readPod(std::istream& is, T& value) {
is.read(reinterpret_cast<char*>(&value), sizeof(T)); is.read(reinterpret_cast<char*>(&value), sizeof(T));
} }
template <typename T>
static void readPod(File& file, T& value) {
file.read(reinterpret_cast<uint8_t*>(&value), sizeof(T));
}
static void writeString(std::ostream& os, const std::string& s) { static void writeString(std::ostream& os, const std::string& s) {
const uint32_t len = s.size(); const uint32_t len = s.size();
writePod(os, len); writePod(os, len);
os.write(s.data(), len); os.write(s.data(), len);
} }
static void writeString(File& file, const std::string& s) {
const uint32_t len = s.size();
writePod(file, len);
file.write(reinterpret_cast<const uint8_t*>(s.data()), len);
}
static void readString(std::istream& is, std::string& s) { static void readString(std::istream& is, std::string& s) {
uint32_t len; uint32_t len;
readPod(is, len); readPod(is, len);
s.resize(len); s.resize(len);
is.read(&s[0], len); is.read(&s[0], len);
} }
static void readString(File& file, std::string& s) {
uint32_t len;
readPod(file, len);
s.resize(len);
file.read(reinterpret_cast<uint8_t*>(&s[0]), len);
}
} // namespace serialization } // namespace serialization

View File

@ -27,31 +27,28 @@ bool inflateOneShot(const uint8_t* inputBuf, const size_t deflatedSize, uint8_t*
return true; return true;
} }
bool ZipFile::loadFileStat(const char* filename, mz_zip_archive_file_stat* fileStat) const { ZipFile::ZipFile(std::string filePath) : filePath(std::move(filePath)) {
mz_zip_archive zipArchive = {}; const bool status = mz_zip_reader_init_file(&zipArchive, this->filePath.c_str(), 0);
const bool status = mz_zip_reader_init_file(&zipArchive, filePath.c_str(), 0);
if (!status) { if (!status) {
Serial.printf("[%lu] [ZIP] mz_zip_reader_init_file() failed! Error: %s\n", millis(), Serial.printf("[%lu] [ZIP] mz_zip_reader_init_file() failed for %s! Error: %s\n", millis(), this->filePath.c_str(),
mz_zip_get_error_string(zipArchive.m_last_error)); mz_zip_get_error_string(zipArchive.m_last_error));
return false;
} }
}
bool ZipFile::loadFileStat(const char* filename, mz_zip_archive_file_stat* fileStat) const {
// find the file // find the file
mz_uint32 fileIndex = 0; mz_uint32 fileIndex = 0;
if (!mz_zip_reader_locate_file_v2(&zipArchive, filename, nullptr, 0, &fileIndex)) { if (!mz_zip_reader_locate_file_v2(&zipArchive, filename, nullptr, 0, &fileIndex)) {
Serial.printf("[%lu] [ZIP] Could not find file %s\n", millis(), filename); Serial.printf("[%lu] [ZIP] Could not find file %s\n", millis(), filename);
mz_zip_reader_end(&zipArchive);
return false; return false;
} }
if (!mz_zip_reader_file_stat(&zipArchive, fileIndex, fileStat)) { if (!mz_zip_reader_file_stat(&zipArchive, fileIndex, fileStat)) {
Serial.printf("[%lu] [ZIP] mz_zip_reader_file_stat() failed! Error: %s\n", millis(), Serial.printf("[%lu] [ZIP] mz_zip_reader_file_stat() failed! Error: %s\n", millis(),
mz_zip_get_error_string(zipArchive.m_last_error)); mz_zip_get_error_string(zipArchive.m_last_error));
mz_zip_reader_end(&zipArchive);
return false; return false;
} }
mz_zip_reader_end(&zipArchive);
return true; return true;
} }
@ -118,6 +115,11 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
const auto inflatedDataSize = static_cast<size_t>(fileStat.m_uncomp_size); const auto inflatedDataSize = static_cast<size_t>(fileStat.m_uncomp_size);
const auto dataSize = trailingNullByte ? inflatedDataSize + 1 : inflatedDataSize; const auto dataSize = trailingNullByte ? inflatedDataSize + 1 : inflatedDataSize;
const auto data = static_cast<uint8_t*>(malloc(dataSize)); const auto data = static_cast<uint8_t*>(malloc(dataSize));
if (data == nullptr) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for output buffer (%zu bytes)\n", millis(), dataSize);
fclose(file);
return nullptr;
}
if (fileStat.m_method == MZ_NO_COMPRESSION) { if (fileStat.m_method == MZ_NO_COMPRESSION) {
// no deflation, just read content // no deflation, just read content

View File

@ -1,19 +1,19 @@
#pragma once #pragma once
#include <Print.h> #include <Print.h>
#include <functional>
#include <string> #include <string>
#include "miniz.h" #include "miniz.h"
class ZipFile { class ZipFile {
std::string filePath; std::string filePath;
mutable mz_zip_archive zipArchive = {};
bool loadFileStat(const char* filename, mz_zip_archive_file_stat* fileStat) const; bool loadFileStat(const char* filename, mz_zip_archive_file_stat* fileStat) const;
long getDataOffset(const mz_zip_archive_file_stat& fileStat) const; long getDataOffset(const mz_zip_archive_file_stat& fileStat) const;
public: public:
explicit ZipFile(std::string filePath) : filePath(std::move(filePath)) {} explicit ZipFile(std::string filePath);
~ZipFile() = default; ~ZipFile() { mz_zip_reader_end(&zipArchive); }
bool getInflatedFileSize(const char* filename, size_t* size) const; bool getInflatedFileSize(const char* filename, size_t* size) const;
uint8_t* readFileToMemory(const char* filename, size_t* size = nullptr, bool trailingNullByte = false) const; uint8_t* readFileToMemory(const char* filename, size_t* size = nullptr, bool trailingNullByte = false) const;
bool readFileToStream(const char* filename, Print& out, size_t chunkSize) const; bool readFileToStream(const char* filename, Print& out, size_t chunkSize) const;

2087
lib/picojpeg/picojpeg.c Normal file

File diff suppressed because it is too large Load Diff

124
lib/picojpeg/picojpeg.h Normal file
View File

@ -0,0 +1,124 @@
//------------------------------------------------------------------------------
// picojpeg - Public domain, Rich Geldreich <richgel99@gmail.com>
//------------------------------------------------------------------------------
#ifndef PICOJPEG_H
#define PICOJPEG_H
#ifdef __cplusplus
extern "C" {
#endif
// Error codes
enum {
PJPG_NO_MORE_BLOCKS = 1,
PJPG_BAD_DHT_COUNTS,
PJPG_BAD_DHT_INDEX,
PJPG_BAD_DHT_MARKER,
PJPG_BAD_DQT_MARKER,
PJPG_BAD_DQT_TABLE,
PJPG_BAD_PRECISION,
PJPG_BAD_HEIGHT,
PJPG_BAD_WIDTH,
PJPG_TOO_MANY_COMPONENTS,
PJPG_BAD_SOF_LENGTH,
PJPG_BAD_VARIABLE_MARKER,
PJPG_BAD_DRI_LENGTH,
PJPG_BAD_SOS_LENGTH,
PJPG_BAD_SOS_COMP_ID,
PJPG_W_EXTRA_BYTES_BEFORE_MARKER,
PJPG_NO_ARITHMITIC_SUPPORT,
PJPG_UNEXPECTED_MARKER,
PJPG_NOT_JPEG,
PJPG_UNSUPPORTED_MARKER,
PJPG_BAD_DQT_LENGTH,
PJPG_TOO_MANY_BLOCKS,
PJPG_UNDEFINED_QUANT_TABLE,
PJPG_UNDEFINED_HUFF_TABLE,
PJPG_NOT_SINGLE_SCAN,
PJPG_UNSUPPORTED_COLORSPACE,
PJPG_UNSUPPORTED_SAMP_FACTORS,
PJPG_DECODE_ERROR,
PJPG_BAD_RESTART_MARKER,
PJPG_ASSERTION_ERROR,
PJPG_BAD_SOS_SPECTRAL,
PJPG_BAD_SOS_SUCCESSIVE,
PJPG_STREAM_READ_ERROR,
PJPG_NOTENOUGHMEM,
PJPG_UNSUPPORTED_COMP_IDENT,
PJPG_UNSUPPORTED_QUANT_TABLE,
PJPG_UNSUPPORTED_MODE, // picojpeg doesn't support progressive JPEG's
};
// Scan types
typedef enum { PJPG_GRAYSCALE, PJPG_YH1V1, PJPG_YH2V1, PJPG_YH1V2, PJPG_YH2V2 } pjpeg_scan_type_t;
typedef struct {
// Image resolution
int m_width;
int m_height;
// Number of components (1 or 3)
int m_comps;
// Total number of minimum coded units (MCU's) per row/col.
int m_MCUSPerRow;
int m_MCUSPerCol;
// Scan type
pjpeg_scan_type_t m_scanType;
// MCU width/height in pixels (each is either 8 or 16 depending on the scan type)
int m_MCUWidth;
int m_MCUHeight;
// m_pMCUBufR, m_pMCUBufG, and m_pMCUBufB are pointers to internal MCU Y or RGB pixel component buffers.
// Each time pjpegDecodeMCU() is called successfully these buffers will be filled with 8x8 pixel blocks of Y or RGB
// pixels. Each MCU consists of (m_MCUWidth/8)*(m_MCUHeight/8) Y/RGB blocks: 1 for greyscale/no subsampling, 2 for
// H1V2/H2V1, or 4 blocks for H2V2 sampling factors. Each block is a contiguous array of 64 (8x8) bytes of a single
// component: either Y for grayscale images, or R, G or B components for color images.
//
// The 8x8 pixel blocks are organized in these byte arrays like this:
//
// PJPG_GRAYSCALE: Each MCU is decoded to a single block of 8x8 grayscale pixels.
// Only the values in m_pMCUBufR are valid. Each 8 bytes is a row of pixels (raster order: left to right, top to
// bottom) from the 8x8 block.
//
// PJPG_H1V1: Each MCU contains is decoded to a single block of 8x8 RGB pixels.
//
// PJPG_YH2V1: Each MCU is decoded to 2 blocks, or 16x8 pixels.
// The 2 RGB blocks are at byte offsets: 0, 64
//
// PJPG_YH1V2: Each MCU is decoded to 2 blocks, or 8x16 pixels.
// The 2 RGB blocks are at byte offsets: 0,
// 128
//
// PJPG_YH2V2: Each MCU is decoded to 4 blocks, or 16x16 pixels.
// The 2x2 block array is organized at byte offsets: 0, 64,
// 128, 192
//
// It is up to the caller to copy or blit these pixels from these buffers into the destination bitmap.
unsigned char* m_pMCUBufR;
unsigned char* m_pMCUBufG;
unsigned char* m_pMCUBufB;
} pjpeg_image_info_t;
typedef unsigned char (*pjpeg_need_bytes_callback_t)(unsigned char* pBuf, unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data);
// Initializes the decompressor. Returns 0 on success, or one of the above error codes on failure.
// pNeed_bytes_callback will be called to fill the decompressor's internal input buffer.
// If reduce is 1, only the first pixel of each block will be decoded. This mode is much faster because it skips the AC
// dequantization, IDCT and chroma upsampling of every image pixel. Not thread safe.
unsigned char pjpeg_decode_init(pjpeg_image_info_t* pInfo, pjpeg_need_bytes_callback_t pNeed_bytes_callback,
void* pCallback_data, unsigned char reduce);
// Decompresses the file's next MCU. Returns 0 on success, PJPG_NO_MORE_BLOCKS if no more blocks are available, or an
// error code. Must be called a total of m_MCUSPerRow*m_MCUSPerCol times to completely decompress the image. Not thread
// safe.
unsigned char pjpeg_decode_mcu(void);
#ifdef __cplusplus
}
#endif
#endif // PICOJPEG_H

View File

@ -1,16 +1,16 @@
[platformio] [platformio]
crosspoint_version = 0.7.0 crosspoint_version = 0.9.0
default_envs = default default_envs = default
[base] [base]
platform = espressif32 platform = espressif32 @ 6.12.0
board = esp32-c3-devkitm-1 board = esp32-c3-devkitm-1
framework = arduino 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
check_skip_packages = yes check_skip_packages = yes
check_severity = medium, high
board_upload.flash_size = 16MB board_upload.flash_size = 16MB
board_upload.maximum_size = 16777216 board_upload.maximum_size = 16777216
@ -39,6 +39,8 @@ lib_deps =
BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor
InputManager=symlink://open-x4-sdk/libs/hardware/InputManager InputManager=symlink://open-x4-sdk/libs/hardware/InputManager
EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay
ArduinoJson @ 7.4.2
QRCode @ 0.0.1
[env:default] [env:default]
extends = base extends = base

View File

@ -1,32 +1,35 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SD.h> #include <SD.h>
#include <Serialization.h> #include <Serialization.h>
#include <cstdint>
#include <fstream>
// Initialize the static instance // Initialize the static instance
CrossPointSettings CrossPointSettings::instance; CrossPointSettings CrossPointSettings::instance;
namespace { namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1; constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields // Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 5; constexpr uint8_t SETTINGS_COUNT = 6;
constexpr char SETTINGS_FILE[] = "/sd/.crosspoint/settings.bin"; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace } // namespace
bool CrossPointSettings::saveToFile() const { bool CrossPointSettings::saveToFile() const {
// Make sure the directory exists // Make sure the directory exists
SD.mkdir("/.crosspoint"); SD.mkdir("/.crosspoint");
std::ofstream outputFile(SETTINGS_FILE); File outputFile;
if (!FsHelpers::openFileForWrite("CPS", SETTINGS_FILE, outputFile)) {
return false;
}
serialization::writePod(outputFile, SETTINGS_FILE_VERSION); serialization::writePod(outputFile, SETTINGS_FILE_VERSION);
serialization::writePod(outputFile, SETTINGS_COUNT); serialization::writePod(outputFile, SETTINGS_COUNT);
serialization::writePod(outputFile, whiteSleepScreen); serialization::writePod(outputFile, sleepScreen);
serialization::writePod(outputFile, extraParagraphSpacing); serialization::writePod(outputFile, extraParagraphSpacing);
serialization::writePod(outputFile, shortPwrBtn); serialization::writePod(outputFile, shortPwrBtn);
serialization::writePod(outputFile, statusBar);
serialization::writePod(outputFile, landscapeReading); serialization::writePod(outputFile, landscapeReading);
serialization::writePod(outputFile, landscapeFlipped); serialization::writePod(outputFile, landscapeFlipped);
outputFile.close(); outputFile.close();
@ -36,13 +39,11 @@ bool CrossPointSettings::saveToFile() const {
} }
bool CrossPointSettings::loadFromFile() { bool CrossPointSettings::loadFromFile() {
if (!SD.exists(SETTINGS_FILE + 3)) { // +3 to skip "/sd" prefix File inputFile;
Serial.printf("[%lu] [CPS] Settings file does not exist, using defaults\n", millis()); if (!FsHelpers::openFileForRead("CPS", SETTINGS_FILE, inputFile)) {
return false; return false;
} }
std::ifstream inputFile(SETTINGS_FILE);
uint8_t version; uint8_t version;
serialization::readPod(inputFile, version); serialization::readPod(inputFile, version);
if (version != SETTINGS_FILE_VERSION) { if (version != SETTINGS_FILE_VERSION) {
@ -57,12 +58,14 @@ bool CrossPointSettings::loadFromFile() {
// load settings that exist (support older files with fewer fields) // load settings that exist (support older files with fewer fields)
uint8_t settingsRead = 0; uint8_t settingsRead = 0;
do { do {
serialization::readPod(inputFile, whiteSleepScreen); serialization::readPod(inputFile, sleepScreen);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, extraParagraphSpacing); serialization::readPod(inputFile, extraParagraphSpacing);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, shortPwrBtn); serialization::readPod(inputFile, shortPwrBtn);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, statusBar);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, landscapeReading); serialization::readPod(inputFile, landscapeReading);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, landscapeFlipped); serialization::readPod(inputFile, landscapeFlipped);

View File

@ -15,8 +15,16 @@ class CrossPointSettings {
CrossPointSettings(const CrossPointSettings&) = delete; CrossPointSettings(const CrossPointSettings&) = delete;
CrossPointSettings& operator=(const CrossPointSettings&) = delete; CrossPointSettings& operator=(const CrossPointSettings&) = delete;
// Should match with SettingsActivity text
enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3 };
// Status bar display type enum
enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2 };
// Sleep screen settings // Sleep screen settings
uint8_t whiteSleepScreen = 0; uint8_t sleepScreen = DARK;
// Status bar settings
uint8_t statusBar = FULL;
// Text rendering settings // Text rendering settings
uint8_t extraParagraphSpacing = 1; uint8_t extraParagraphSpacing = 1;
// Duration of the power button press // Duration of the power button press

View File

@ -1,20 +1,22 @@
#include "CrossPointState.h" #include "CrossPointState.h"
#include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SD.h>
#include <Serialization.h> #include <Serialization.h>
#include <fstream>
namespace { namespace {
constexpr uint8_t STATE_FILE_VERSION = 1; constexpr uint8_t STATE_FILE_VERSION = 1;
constexpr char STATE_FILE[] = "/sd/.crosspoint/state.bin"; constexpr char STATE_FILE[] = "/.crosspoint/state.bin";
} // namespace } // namespace
CrossPointState CrossPointState::instance; CrossPointState CrossPointState::instance;
bool CrossPointState::saveToFile() const { bool CrossPointState::saveToFile() const {
std::ofstream outputFile(STATE_FILE); File outputFile;
if (!FsHelpers::openFileForWrite("CPS", STATE_FILE, outputFile)) {
return false;
}
serialization::writePod(outputFile, STATE_FILE_VERSION); serialization::writePod(outputFile, STATE_FILE_VERSION);
serialization::writeString(outputFile, openEpubPath); serialization::writeString(outputFile, openEpubPath);
outputFile.close(); outputFile.close();
@ -22,7 +24,10 @@ bool CrossPointState::saveToFile() const {
} }
bool CrossPointState::loadFromFile() { bool CrossPointState::loadFromFile() {
std::ifstream inputFile(STATE_FILE); File inputFile;
if (!FsHelpers::openFileForRead("CPS", STATE_FILE, inputFile)) {
return false;
}
uint8_t version; uint8_t version;
serialization::readPod(inputFile, version); serialization::readPod(inputFile, version);

View File

@ -1,11 +1,10 @@
#include "WifiCredentialStore.h" #include "WifiCredentialStore.h"
#include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SD.h> #include <SD.h>
#include <Serialization.h> #include <Serialization.h>
#include <fstream>
// Initialize the static instance // Initialize the static instance
WifiCredentialStore WifiCredentialStore::instance; WifiCredentialStore WifiCredentialStore::instance;
@ -14,7 +13,7 @@ namespace {
constexpr uint8_t WIFI_FILE_VERSION = 1; constexpr uint8_t WIFI_FILE_VERSION = 1;
// WiFi credentials file path // WiFi credentials file path
constexpr char WIFI_FILE[] = "/sd/.crosspoint/wifi.bin"; constexpr char WIFI_FILE[] = "/.crosspoint/wifi.bin";
// Obfuscation key - "CrossPoint" in ASCII // Obfuscation key - "CrossPoint" in ASCII
// This is NOT cryptographic security, just prevents casual file reading // This is NOT cryptographic security, just prevents casual file reading
@ -33,9 +32,8 @@ bool WifiCredentialStore::saveToFile() const {
// Make sure the directory exists // Make sure the directory exists
SD.mkdir("/.crosspoint"); SD.mkdir("/.crosspoint");
std::ofstream file(WIFI_FILE, std::ios::binary); File file;
if (!file) { if (!FsHelpers::openFileForWrite("WCS", WIFI_FILE, file)) {
Serial.printf("[%lu] [WCS] Failed to open wifi.bin for writing\n", millis());
return false; return false;
} }
@ -62,14 +60,8 @@ bool WifiCredentialStore::saveToFile() const {
} }
bool WifiCredentialStore::loadFromFile() { bool WifiCredentialStore::loadFromFile() {
if (!SD.exists(WIFI_FILE + 3)) { // +3 to skip "/sd" prefix File file;
Serial.printf("[%lu] [WCS] WiFi credentials file does not exist\n", millis()); if (!FsHelpers::openFileForRead("WCS", WIFI_FILE, file)) {
return false;
}
std::ifstream file(WIFI_FILE, std::ios::binary);
if (!file) {
Serial.printf("[%lu] [WCS] Failed to open wifi.bin for reading\n", millis());
return false; return false;
} }
@ -111,12 +103,12 @@ bool WifiCredentialStore::loadFromFile() {
bool WifiCredentialStore::addCredential(const std::string& ssid, const std::string& password) { bool WifiCredentialStore::addCredential(const std::string& ssid, const std::string& password) {
// Check if this SSID already exists and update it // Check if this SSID already exists and update it
for (auto& cred : credentials) { const auto cred = find_if(credentials.begin(), credentials.end(),
if (cred.ssid == ssid) { [&ssid](const WifiCredential& cred) { return cred.ssid == ssid; });
cred.password = password; if (cred != credentials.end()) {
Serial.printf("[%lu] [WCS] Updated credentials for: %s\n", millis(), ssid.c_str()); cred->password = password;
return saveToFile(); Serial.printf("[%lu] [WCS] Updated credentials for: %s\n", millis(), ssid.c_str());
} return saveToFile();
} }
// Check if we've reached the limit // Check if we've reached the limit
@ -132,22 +124,24 @@ bool WifiCredentialStore::addCredential(const std::string& ssid, const std::stri
} }
bool WifiCredentialStore::removeCredential(const std::string& ssid) { bool WifiCredentialStore::removeCredential(const std::string& ssid) {
for (auto it = credentials.begin(); it != credentials.end(); ++it) { const auto cred = find_if(credentials.begin(), credentials.end(),
if (it->ssid == ssid) { [&ssid](const WifiCredential& cred) { return cred.ssid == ssid; });
credentials.erase(it); if (cred != credentials.end()) {
Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str()); credentials.erase(cred);
return saveToFile(); Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str());
} return saveToFile();
} }
return false; // Not found return false; // Not found
} }
const WifiCredential* WifiCredentialStore::findCredential(const std::string& ssid) const { const WifiCredential* WifiCredentialStore::findCredential(const std::string& ssid) const {
for (const auto& cred : credentials) { const auto cred = find_if(credentials.begin(), credentials.end(),
if (cred.ssid == ssid) { [&ssid](const WifiCredential& cred) { return cred.ssid == ssid; });
return &cred;
} if (cred != credentials.end()) {
return &*cred;
} }
return nullptr; return nullptr;
} }

View File

@ -1,19 +1,25 @@
#pragma once #pragma once
#include <InputManager.h>
#include <HardwareSerial.h>
#include <string>
#include <utility>
class InputManager;
class GfxRenderer; class GfxRenderer;
class Activity { class Activity {
protected: protected:
std::string name;
GfxRenderer& renderer; GfxRenderer& renderer;
InputManager& inputManager; InputManager& inputManager;
public: public:
explicit Activity(GfxRenderer& renderer, InputManager& inputManager) explicit Activity(std::string name, GfxRenderer& renderer, InputManager& inputManager)
: renderer(renderer), inputManager(inputManager) {} : name(std::move(name)), renderer(renderer), inputManager(inputManager) {}
virtual ~Activity() = default; virtual ~Activity() = default;
virtual void onEnter() {} virtual void onEnter() { Serial.printf("[%lu] [ACT] Entering activity: %s\n", millis(), name.c_str()); }
virtual void onExit() {} virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); }
virtual void loop() {} virtual void loop() {}
virtual bool skipLoopDelay() { return false; } virtual bool skipLoopDelay() { return false; }
}; };

View File

@ -18,4 +18,7 @@ void ActivityWithSubactivity::loop() {
} }
} }
void ActivityWithSubactivity::onExit() { exitActivity(); } void ActivityWithSubactivity::onExit() {
Activity::onExit();
exitActivity();
}

View File

@ -10,8 +10,8 @@ class ActivityWithSubactivity : public Activity {
void enterNewActivity(Activity* activity); void enterNewActivity(Activity* activity);
public: public:
explicit ActivityWithSubactivity(GfxRenderer& renderer, InputManager& inputManager) explicit ActivityWithSubactivity(std::string name, GfxRenderer& renderer, InputManager& inputManager)
: Activity(renderer, inputManager) {} : Activity(std::move(name), renderer, inputManager) {}
void loop() override; void loop() override;
void onExit() override; void onExit() override;
}; };

View File

@ -6,6 +6,8 @@
#include "images/CrossLarge.h" #include "images/CrossLarge.h"
void BootActivity::onEnter() { void BootActivity::onEnter() {
Activity::onEnter();
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight(); const auto pageHeight = GfxRenderer::getScreenHeight();

View File

@ -3,6 +3,6 @@
class BootActivity final : public Activity { class BootActivity final : public Activity {
public: public:
explicit BootActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity(renderer, inputManager) {} explicit BootActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity("Boot", renderer, inputManager) {}
void onEnter() override; void onEnter() override;
}; };

View File

@ -1,16 +1,47 @@
#include "SleepActivity.h" #include "SleepActivity.h"
#include <Epub.h>
#include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SD.h>
#include <vector> #include <vector>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "SD.h" #include "CrossPointState.h"
#include "config.h" #include "config.h"
#include "images/CrossLarge.h" #include "images/CrossLarge.h"
void SleepActivity::onEnter() { void SleepActivity::onEnter() {
Activity::onEnter();
renderPopup("Entering Sleep..."); renderPopup("Entering Sleep...");
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM) {
return renderCustomSleepScreen();
}
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::COVER) {
return renderCoverSleepScreen();
}
renderDefaultSleepScreen();
}
void SleepActivity::renderPopup(const char* message) const {
const int textWidth = renderer.getTextWidth(READER_FONT_ID, message);
constexpr int margin = 20;
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 117;
const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2;
// renderer.clearScreen();
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
renderer.drawText(READER_FONT_ID, x + margin, y + margin, message);
renderer.drawRect(x + 5, y + 5, w - 10, h - 10);
renderer.displayBuffer();
}
void SleepActivity::renderCustomSleepScreen() const {
// Check if we have a /sleep directory // Check if we have a /sleep directory
auto dir = SD.open("/sleep"); auto dir = SD.open("/sleep");
if (dir && dir.isDirectory()) { if (dir && dir.isDirectory()) {
@ -28,31 +59,31 @@ void SleepActivity::onEnter() {
} }
if (filename.substr(filename.length() - 4) != ".bmp") { if (filename.substr(filename.length() - 4) != ".bmp") {
Serial.printf("[%lu] [Slp] Skipping non-.bmp file name: %s\n", millis(), file.name()); Serial.printf("[%lu] [SLP] Skipping non-.bmp file name: %s\n", millis(), file.name());
file.close(); file.close();
continue; continue;
} }
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() != BmpReaderError::Ok) { if (bitmap.parseHeaders() != BmpReaderError::Ok) {
Serial.printf("[%lu] [Slp] Skipping invalid BMP file: %s\n", millis(), file.name()); Serial.printf("[%lu] [SLP] Skipping invalid BMP file: %s\n", millis(), file.name());
file.close(); file.close();
continue; continue;
} }
files.emplace_back(filename); files.emplace_back(filename);
file.close(); file.close();
} }
int numFiles = files.size(); const auto numFiles = files.size();
if (numFiles > 0) { if (numFiles > 0) {
// Generate a random number between 1 and numFiles // Generate a random number between 1 and numFiles
int randomFileIndex = random(numFiles); const auto randomFileIndex = random(numFiles);
auto filename = "/sleep/" + files[randomFileIndex]; const auto filename = "/sleep/" + files[randomFileIndex];
auto file = SD.open(filename.c_str()); File file;
if (file) { if (FsHelpers::openFileForRead("SLP", filename, file)) {
Serial.printf("[%lu] [Slp] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str());
delay(100); delay(100);
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
renderCustomSleepScreen(bitmap); renderBitmapSleepScreen(bitmap);
dir.close(); dir.close();
return; return;
} }
@ -63,12 +94,12 @@ void SleepActivity::onEnter() {
// Look for sleep.bmp on the root of the sd card to determine if we should // Look for sleep.bmp on the root of the sd card to determine if we should
// render a custom sleep screen instead of the default. // render a custom sleep screen instead of the default.
auto file = SD.open("/sleep.bmp"); File file;
if (file) { if (FsHelpers::openFileForRead("SLP", "/sleep.bmp", file)) {
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[%lu] [Slp] Loading: /sleep.bmp\n", millis()); Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis());
renderCustomSleepScreen(bitmap); renderBitmapSleepScreen(bitmap);
return; return;
} }
} }
@ -76,41 +107,27 @@ void SleepActivity::onEnter() {
renderDefaultSleepScreen(); renderDefaultSleepScreen();
} }
void SleepActivity::renderPopup(const char* message) const {
const int textWidth = renderer.getTextWidth(READER_FONT_ID, message);
constexpr int margin = 20;
const int x = (GfxRenderer::getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 117;
const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2;
// renderer.clearScreen();
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
renderer.drawText(READER_FONT_ID, x + margin, y + margin, message);
renderer.drawRect(x + 5, y + 5, w - 10, h - 10);
renderer.displayBuffer();
}
void SleepActivity::renderDefaultSleepScreen() const { void SleepActivity::renderDefaultSleepScreen() const {
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
renderer.clearScreen(); renderer.clearScreen();
renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128); renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128);
renderer.drawCenteredText(UI_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, BOLD); renderer.drawCenteredText(UI_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING"); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING");
// Apply white screen if enabled in settings // Make sleep screen dark unless light is selected in settings
if (!SETTINGS.whiteSleepScreen) { if (SETTINGS.sleepScreen != CrossPointSettings::SLEEP_SCREEN_MODE::LIGHT) {
renderer.invertScreen(); renderer.invertScreen();
} }
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
} }
void SleepActivity::renderCustomSleepScreen(const Bitmap& bitmap) const { void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
int x, y; int x, y;
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) { if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) {
// image will scale, make sure placement is right // image will scale, make sure placement is right
@ -153,3 +170,31 @@ void SleepActivity::renderCustomSleepScreen(const Bitmap& bitmap) const {
renderer.setRenderMode(GfxRenderer::BW); renderer.setRenderMode(GfxRenderer::BW);
} }
} }
void SleepActivity::renderCoverSleepScreen() const {
if (APP_STATE.openEpubPath.empty()) {
return renderDefaultSleepScreen();
}
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastEpub.load()) {
Serial.println("[SLP] Failed to load last epub");
return renderDefaultSleepScreen();
}
if (!lastEpub.generateCoverBmp()) {
Serial.println("[SLP] Failed to generate cover bmp");
return renderDefaultSleepScreen();
}
File file;
if (FsHelpers::openFileForRead("SLP", lastEpub.getCoverBmpPath(), file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
renderBitmapSleepScreen(bitmap);
return;
}
}
renderDefaultSleepScreen();
}

View File

@ -5,11 +5,14 @@ class Bitmap;
class SleepActivity final : public Activity { class SleepActivity final : public Activity {
public: public:
explicit SleepActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity(renderer, inputManager) {} explicit SleepActivity(GfxRenderer& renderer, InputManager& inputManager)
: Activity("Sleep", renderer, inputManager) {}
void onEnter() override; void onEnter() override;
private: private:
void renderDefaultSleepScreen() const;
void renderCustomSleepScreen(const Bitmap& bitmap) const;
void renderPopup(const char* message) const; void renderPopup(const char* message) const;
void renderDefaultSleepScreen() const;
void renderCustomSleepScreen() const;
void renderCoverSleepScreen() const;
void renderBitmapSleepScreen(const Bitmap& bitmap) const;
}; };

View File

@ -1,22 +1,27 @@
#include "HomeActivity.h" #include "HomeActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h>
#include <SD.h> #include <SD.h>
#include "CrossPointState.h"
#include "config.h" #include "config.h"
namespace {
constexpr int menuItemCount = 3;
}
void HomeActivity::taskTrampoline(void* param) { void HomeActivity::taskTrampoline(void* param) {
auto* self = static_cast<HomeActivity*>(param); auto* self = static_cast<HomeActivity*>(param);
self->displayTaskLoop(); self->displayTaskLoop();
} }
int HomeActivity::getMenuItemCount() const { return hasContinueReading ? 4 : 3; }
void HomeActivity::onEnter() { void HomeActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
// Check if we have a book to continue reading
hasContinueReading = !APP_STATE.openEpubPath.empty() && SD.exists(APP_STATE.openEpubPath.c_str());
selectorIndex = 0; selectorIndex = 0;
// Trigger first update // Trigger first update
@ -31,6 +36,8 @@ void HomeActivity::onEnter() {
} }
void HomeActivity::onExit() { void HomeActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD // Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) { if (displayTaskHandle) {
@ -47,19 +54,35 @@ void HomeActivity::loop() {
const bool nextPressed = const bool nextPressed =
inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT); inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT);
const int menuCount = getMenuItemCount();
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (selectorIndex == 0) { if (hasContinueReading) {
onReaderOpen(); // Menu: Continue Reading, Browse, File transfer, Settings
} else if (selectorIndex == 1) { if (selectorIndex == 0) {
onFileTransferOpen(); onContinueReading();
} else if (selectorIndex == 2) { } else if (selectorIndex == 1) {
onSettingsOpen(); onReaderOpen();
} else if (selectorIndex == 2) {
onFileTransferOpen();
} else if (selectorIndex == 3) {
onSettingsOpen();
}
} else {
// Menu: Browse, File transfer, Settings
if (selectorIndex == 0) {
onReaderOpen();
} else if (selectorIndex == 1) {
onFileTransferOpen();
} else if (selectorIndex == 2) {
onSettingsOpen();
}
} }
} else if (prevPressed) { } else if (prevPressed) {
selectorIndex = (selectorIndex + menuItemCount - 1) % menuItemCount; selectorIndex = (selectorIndex + menuCount - 1) % menuCount;
updateRequired = true; updateRequired = true;
} else if (nextPressed) { } else if (nextPressed) {
selectorIndex = (selectorIndex + 1) % menuItemCount; selectorIndex = (selectorIndex + 1) % menuCount;
updateRequired = true; updateRequired = true;
} }
} }
@ -79,28 +102,48 @@ void HomeActivity::displayTaskLoop() {
void HomeActivity::render() const { void HomeActivity::render() const {
renderer.clearScreen(); renderer.clearScreen();
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD);
// Draw selection // Draw selection
renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30); renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30);
renderer.drawText(UI_FONT_ID, 20, 60, "Read", selectorIndex != 0);
renderer.drawText(UI_FONT_ID, 20, 90, "File transfer", selectorIndex != 1);
renderer.drawText(UI_FONT_ID, 20, 120, "Settings", selectorIndex != 2);
renderer.drawRect(25, pageHeight - 40, 106, 40); int menuY = 60;
renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Back")) / 2, pageHeight - 35, "Back"); int menuIndex = 0;
renderer.drawRect(130, pageHeight - 40, 106, 40); if (hasContinueReading) {
renderer.drawText(UI_FONT_ID, 130 + (105 - renderer.getTextWidth(UI_FONT_ID, "Confirm")) / 2, pageHeight - 35, // Extract filename from path for display
"Confirm"); std::string bookName = APP_STATE.openEpubPath;
const size_t lastSlash = bookName.find_last_of('/');
if (lastSlash != std::string::npos) {
bookName = bookName.substr(lastSlash + 1);
}
// Remove .epub extension
if (bookName.length() > 5 && bookName.substr(bookName.length() - 5) == ".epub") {
bookName.resize(bookName.length() - 5);
}
// Truncate if too long
if (bookName.length() > 25) {
bookName.resize(22);
bookName += "...";
}
std::string continueLabel = "Continue: " + bookName;
renderer.drawText(UI_FONT_ID, 20, menuY, continueLabel.c_str(), selectorIndex != menuIndex);
menuY += 30;
menuIndex++;
}
renderer.drawRect(245, pageHeight - 40, 106, 40); renderer.drawText(UI_FONT_ID, 20, menuY, "Browse", selectorIndex != menuIndex);
renderer.drawText(UI_FONT_ID, 245 + (105 - renderer.getTextWidth(UI_FONT_ID, "Left")) / 2, pageHeight - 35, "Left"); menuY += 30;
menuIndex++;
renderer.drawRect(350, pageHeight - 40, 106, 40); renderer.drawText(UI_FONT_ID, 20, menuY, "File transfer", selectorIndex != menuIndex);
renderer.drawText(UI_FONT_ID, 350 + (105 - renderer.getTextWidth(UI_FONT_ID, "Right")) / 2, pageHeight - 35, "Right"); menuY += 30;
menuIndex++;
renderer.drawText(UI_FONT_ID, 20, menuY, "Settings", selectorIndex != menuIndex);
renderer.drawButtonHints(UI_FONT_ID, "Back", "Confirm", "Left", "Right");
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -12,6 +12,8 @@ class HomeActivity final : public Activity {
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
int selectorIndex = 0; int selectorIndex = 0;
bool updateRequired = false; bool updateRequired = false;
bool hasContinueReading = false;
const std::function<void()> onContinueReading;
const std::function<void()> onReaderOpen; const std::function<void()> onReaderOpen;
const std::function<void()> onSettingsOpen; const std::function<void()> onSettingsOpen;
const std::function<void()> onFileTransferOpen; const std::function<void()> onFileTransferOpen;
@ -19,11 +21,14 @@ class HomeActivity final : public Activity {
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void render() const; void render() const;
int getMenuItemCount() const;
public: public:
explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onReaderOpen, explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void()>& onContinueReading, const std::function<void()>& onReaderOpen,
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen) const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen)
: Activity(renderer, inputManager), : Activity("Home", renderer, inputManager),
onContinueReading(onContinueReading),
onReaderOpen(onReaderOpen), onReaderOpen(onReaderOpen),
onSettingsOpen(onSettingsOpen), onSettingsOpen(onSettingsOpen),
onFileTransferOpen(onFileTransferOpen) {} onFileTransferOpen(onFileTransferOpen) {}

View File

@ -1,23 +1,47 @@
#include "CrossPointWebServerActivity.h" #include "CrossPointWebServerActivity.h"
#include <DNSServer.h>
#include <ESPmDNS.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h>
#include <WiFi.h> #include <WiFi.h>
#include <qrcode.h>
#include <cstddef>
#include "NetworkModeSelectionActivity.h"
#include "WifiSelectionActivity.h"
#include "config.h" #include "config.h"
namespace {
// AP Mode configuration
constexpr const char* AP_SSID = "CrossPoint-Reader";
constexpr const char* AP_PASSWORD = nullptr; // Open network for ease of use
constexpr const char* AP_HOSTNAME = "crosspoint";
constexpr uint8_t AP_CHANNEL = 1;
constexpr uint8_t AP_MAX_CONNECTIONS = 4;
// DNS server for captive portal (redirects all DNS queries to our IP)
DNSServer* dnsServer = nullptr;
constexpr uint16_t DNS_PORT = 53;
} // namespace
void CrossPointWebServerActivity::taskTrampoline(void* param) { void CrossPointWebServerActivity::taskTrampoline(void* param) {
auto* self = static_cast<CrossPointWebServerActivity*>(param); auto* self = static_cast<CrossPointWebServerActivity*>(param);
self->displayTaskLoop(); self->displayTaskLoop();
} }
void CrossPointWebServerActivity::onEnter() { void CrossPointWebServerActivity::onEnter() {
Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onEnter ==========\n", millis()); ActivityWithSubactivity::onEnter();
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onEnter: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onEnter: %d bytes\n", millis(), ESP.getFreeHeap());
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
// Reset state // Reset state
state = WebServerActivityState::WIFI_SELECTION; state = WebServerActivityState::MODE_SELECTION;
networkMode = NetworkMode::JOIN_NETWORK;
isApMode = false;
connectedIP.clear(); connectedIP.clear();
connectedSSID.clear(); connectedSSID.clear();
lastHandleClientTime = 0; lastHandleClientTime = 0;
@ -30,19 +54,17 @@ void CrossPointWebServerActivity::onEnter() {
&displayTaskHandle // Task handle &displayTaskHandle // Task handle
); );
// Turn on WiFi immediately // Launch network mode selection subactivity
Serial.printf("[%lu] [WEBACT] Turning on WiFi...\n", millis()); Serial.printf("[%lu] [WEBACT] Launching NetworkModeSelectionActivity...\n", millis());
WiFi.mode(WIFI_STA); enterNewActivity(new NetworkModeSelectionActivity(
renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); },
// Launch WiFi selection subactivity [this]() { onGoBack(); } // Cancel goes back to home
Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis()); ));
wifiSelection.reset(new WifiSelectionActivity(renderer, inputManager,
[this](bool connected) { onWifiSelectionComplete(connected); }));
wifiSelection->onEnter();
} }
void CrossPointWebServerActivity::onExit() { void CrossPointWebServerActivity::onExit() {
Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onExit START ==========\n", millis()); ActivityWithSubactivity::onExit();
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap());
state = WebServerActivityState::SHUTTING_DOWN; state = WebServerActivityState::SHUTTING_DOWN;
@ -50,12 +72,15 @@ void CrossPointWebServerActivity::onExit() {
// Stop the web server first (before disconnecting WiFi) // Stop the web server first (before disconnecting WiFi)
stopWebServer(); stopWebServer();
// Exit WiFi selection subactivity if still active // Stop mDNS
if (wifiSelection) { MDNS.end();
Serial.printf("[%lu] [WEBACT] Exiting WifiSelectionActivity...\n", millis());
wifiSelection->onExit(); // Stop DNS server if running (AP mode)
wifiSelection.reset(); if (dnsServer) {
Serial.printf("[%lu] [WEBACT] WifiSelectionActivity exited\n", millis()); Serial.printf("[%lu] [WEBACT] Stopping DNS server...\n", millis());
dnsServer->stop();
delete dnsServer;
dnsServer = nullptr;
} }
// CRITICAL: Wait for LWIP stack to flush any pending packets // CRITICAL: Wait for LWIP stack to flush any pending packets
@ -63,9 +88,14 @@ void CrossPointWebServerActivity::onExit() {
delay(500); delay(500);
// Disconnect WiFi gracefully // Disconnect WiFi gracefully
Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis()); if (isApMode) {
WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame Serial.printf("[%lu] [WEBACT] Stopping WiFi AP...\n", millis());
delay(100); // Allow disconnect frame to be sent WiFi.softAPdisconnect(true);
} else {
Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis());
WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame
}
delay(100); // Allow disconnect frame to be sent
Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis()); Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis());
WiFi.mode(WIFI_OFF); WiFi.mode(WIFI_OFF);
@ -92,29 +122,119 @@ void CrossPointWebServerActivity::onExit() {
Serial.printf("[%lu] [WEBACT] Mutex deleted\n", millis()); Serial.printf("[%lu] [WEBACT] Mutex deleted\n", millis());
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap());
Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onExit COMPLETE ==========\n", millis());
} }
void CrossPointWebServerActivity::onWifiSelectionComplete(bool connected) { void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) {
Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(),
mode == NetworkMode::JOIN_NETWORK ? "Join Network" : "Create Hotspot");
networkMode = mode;
isApMode = (mode == NetworkMode::CREATE_HOTSPOT);
// Exit mode selection subactivity
exitActivity();
if (mode == NetworkMode::JOIN_NETWORK) {
// STA mode - launch WiFi selection
Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis());
WiFi.mode(WIFI_STA);
state = WebServerActivityState::WIFI_SELECTION;
Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis());
enterNewActivity(new WifiSelectionActivity(renderer, inputManager,
[this](const bool connected) { onWifiSelectionComplete(connected); }));
} else {
// AP mode - start access point
state = WebServerActivityState::AP_STARTING;
updateRequired = true;
startAccessPoint();
}
}
void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) {
Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected); Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected);
if (connected) { if (connected) {
// Get connection info before exiting subactivity // Get connection info before exiting subactivity
connectedIP = wifiSelection->getConnectedIP(); connectedIP = static_cast<WifiSelectionActivity*>(subActivity.get())->getConnectedIP();
connectedSSID = WiFi.SSID().c_str(); connectedSSID = WiFi.SSID().c_str();
isApMode = false;
// Exit the wifi selection subactivity exitActivity();
wifiSelection->onExit();
wifiSelection.reset(); // Start mDNS for hostname resolution
if (MDNS.begin(AP_HOSTNAME)) {
Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME);
}
// Start the web server // Start the web server
startWebServer(); startWebServer();
} else { } else {
// User cancelled - go back // User cancelled - go back to mode selection
onGoBack(); exitActivity();
state = WebServerActivityState::MODE_SELECTION;
enterNewActivity(new NetworkModeSelectionActivity(
renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); },
[this]() { onGoBack(); }));
} }
} }
void CrossPointWebServerActivity::startAccessPoint() {
Serial.printf("[%lu] [WEBACT] Starting Access Point mode...\n", millis());
Serial.printf("[%lu] [WEBACT] [MEM] Free heap before AP start: %d bytes\n", millis(), ESP.getFreeHeap());
// Configure and start the AP
WiFi.mode(WIFI_AP);
delay(100);
// Start soft AP
bool apStarted;
if (AP_PASSWORD && strlen(AP_PASSWORD) >= 8) {
apStarted = WiFi.softAP(AP_SSID, AP_PASSWORD, AP_CHANNEL, false, AP_MAX_CONNECTIONS);
} else {
// Open network (no password)
apStarted = WiFi.softAP(AP_SSID, nullptr, AP_CHANNEL, false, AP_MAX_CONNECTIONS);
}
if (!apStarted) {
Serial.printf("[%lu] [WEBACT] ERROR: Failed to start Access Point!\n", millis());
onGoBack();
return;
}
delay(100); // Wait for AP to fully initialize
// Get AP IP address
const IPAddress apIP = WiFi.softAPIP();
char ipStr[16];
snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", apIP[0], apIP[1], apIP[2], apIP[3]);
connectedIP = ipStr;
connectedSSID = AP_SSID;
Serial.printf("[%lu] [WEBACT] Access Point started!\n", millis());
Serial.printf("[%lu] [WEBACT] SSID: %s\n", millis(), AP_SSID);
Serial.printf("[%lu] [WEBACT] IP: %s\n", millis(), connectedIP.c_str());
// Start mDNS for hostname resolution
if (MDNS.begin(AP_HOSTNAME)) {
Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME);
} else {
Serial.printf("[%lu] [WEBACT] WARNING: mDNS failed to start\n", millis());
}
// Start DNS server for captive portal behavior
// This redirects all DNS queries to our IP, making any domain typed resolve to us
dnsServer = new DNSServer();
dnsServer->setErrorReplyCode(DNSReplyCode::NoError);
dnsServer->start(DNS_PORT, "*", apIP);
Serial.printf("[%lu] [WEBACT] DNS server started for captive portal\n", millis());
Serial.printf("[%lu] [WEBACT] [MEM] Free heap after AP start: %d bytes\n", millis(), ESP.getFreeHeap());
// Start the web server
startWebServer();
}
void CrossPointWebServerActivity::startWebServer() { void CrossPointWebServerActivity::startWebServer() {
Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis()); Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis());
@ -150,47 +270,45 @@ void CrossPointWebServerActivity::stopWebServer() {
} }
void CrossPointWebServerActivity::loop() { void CrossPointWebServerActivity::loop() {
if (subActivity) {
// Forward loop to subactivity
subActivity->loop();
return;
}
// Handle different states // Handle different states
switch (state) { if (state == WebServerActivityState::SERVER_RUNNING) {
case WebServerActivityState::WIFI_SELECTION: // Handle DNS requests for captive portal (AP mode only)
// Forward loop to WiFi selection subactivity if (isApMode && dnsServer) {
if (wifiSelection) { dnsServer->processNextRequest();
wifiSelection->loop(); }
}
break;
case WebServerActivityState::SERVER_RUNNING: // Handle web server requests - call handleClient multiple times per loop
// Handle web server requests - call handleClient multiple times per loop // to improve responsiveness and upload throughput
// to improve responsiveness and upload throughput if (webServer && webServer->isRunning()) {
if (webServer && webServer->isRunning()) { const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime;
unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime;
// Log if there's a significant gap between handleClient calls (>100ms) // Log if there's a significant gap between handleClient calls (>100ms)
if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) { if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) {
Serial.printf("[%lu] [WEBACT] WARNING: %lu ms gap since last handleClient\n", millis(), Serial.printf("[%lu] [WEBACT] WARNING: %lu ms gap since last handleClient\n", millis(),
timeSinceLastHandleClient); timeSinceLastHandleClient);
}
// Call handleClient multiple times to process pending requests faster
// This is critical for upload performance - HTTP file uploads send data
// in chunks and each handleClient() call processes incoming data
constexpr int HANDLE_CLIENT_ITERATIONS = 10;
for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) {
webServer->handleClient();
}
lastHandleClientTime = millis();
} }
// Handle exit on Back button // Call handleClient multiple times to process pending requests faster
if (inputManager.wasPressed(InputManager::BTN_BACK)) { // This is critical for upload performance - HTTP file uploads send data
onGoBack(); // in chunks and each handleClient() call processes incoming data
return; constexpr int HANDLE_CLIENT_ITERATIONS = 10;
for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) {
webServer->handleClient();
} }
break; lastHandleClientTime = millis();
}
case WebServerActivityState::SHUTTING_DOWN: // Handle exit on Back button
// Do nothing - waiting for cleanup if (inputManager.wasPressed(InputManager::BTN_BACK)) {
break; onGoBack();
return;
}
} }
} }
@ -208,36 +326,107 @@ void CrossPointWebServerActivity::displayTaskLoop() {
void CrossPointWebServerActivity::render() const { void CrossPointWebServerActivity::render() const {
// Only render our own UI when server is running // Only render our own UI when server is running
// WiFi selection handles its own rendering // Subactivities handle their own rendering
if (state == WebServerActivityState::SERVER_RUNNING) { if (state == WebServerActivityState::SERVER_RUNNING) {
renderer.clearScreen(); renderer.clearScreen();
renderServerRunning(); renderServerRunning();
renderer.displayBuffer(); renderer.displayBuffer();
} else if (state == WebServerActivityState::AP_STARTING) {
renderer.clearScreen();
const auto pageHeight = renderer.getScreenHeight();
renderer.drawCenteredText(READER_FONT_ID, pageHeight / 2 - 20, "Starting Hotspot...", true, BOLD);
renderer.displayBuffer();
}
}
void drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std::string& data) {
// Implementation of QR code calculation
// The structure to manage the QR code
QRCode qrcode;
uint8_t qrcodeBytes[qrcode_getBufferSize(4)];
Serial.printf("[%lu] [WEBACT] QR Code (%lu): %s\n", millis(), data.length(), data.c_str());
qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str());
const uint8_t px = 6; // pixels per module
for (uint8_t cy = 0; cy < qrcode.size; cy++) {
for (uint8_t cx = 0; cx < qrcode.size; cx++) {
if (qrcode_getModule(&qrcode, cx, cy)) {
// Serial.print("**");
renderer.fillRect(x + px * cx, y + px * cy, px, px, true);
} else {
// Serial.print(" ");
}
}
// Serial.print("\n");
} }
} }
void CrossPointWebServerActivity::renderServerRunning() const { void CrossPointWebServerActivity::renderServerRunning() const {
const auto pageWidth = GfxRenderer::getScreenWidth(); // Use consistent line spacing
const auto pageHeight = GfxRenderer::getScreenHeight(); constexpr int LINE_SPACING = 28; // Space between lines
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 5) / 2;
renderer.drawCenteredText(READER_FONT_ID, top - 30, "File Transfer", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 15, "File Transfer", true, BOLD);
std::string ssidInfo = "Network: " + connectedSSID; if (isApMode) {
if (ssidInfo.length() > 28) { // AP mode display - center the content block
ssidInfo = ssidInfo.substr(0, 25) + "..."; int startY = 55;
renderer.drawCenteredText(UI_FONT_ID, startY, "Hotspot Mode", true, BOLD);
std::string ssidInfo = "Network: " + connectedSSID;
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ssidInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 2, "Connect your device to this WiFi network",
true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3,
"or scan QR code with your phone to connect to Wifi.", true, REGULAR);
// Show QR code for URL
std::string wifiConfig = std::string("WIFI:T:WPA;S:") + connectedSSID + ";P:" + "" + ";;";
drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 4, wifiConfig);
startY += 6 * 29 + 3 * LINE_SPACING;
// Show primary URL (hostname)
std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/";
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, BOLD);
// Show IP address as fallback
std::string ipUrl = "or http://" + connectedIP + "/";
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, ipUrl.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Open this URL in your browser", true, REGULAR);
// Show QR code for URL
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 6, "or scan QR code with your phone:", true,
REGULAR);
drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 7, hostnameUrl);
} else {
// STA mode display (original behavior)
const int startY = 65;
std::string ssidInfo = "Network: " + connectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
}
renderer.drawCenteredText(UI_FONT_ID, startY, ssidInfo.c_str(), true, REGULAR);
std::string ipInfo = "IP Address: " + connectedIP;
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ipInfo.c_str(), true, REGULAR);
// Show web server URL prominently
std::string webInfo = "http://" + connectedIP + "/";
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 2, webInfo.c_str(), true, BOLD);
// Also show hostname URL
std::string hostnameUrl = std::string("or http://") + AP_HOSTNAME + ".local/";
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser", true, REGULAR);
// Show QR code for URL
drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 6, webInfo);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "or scan QR code with your phone:", true,
REGULAR);
} }
renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR);
std::string ipInfo = "IP Address: " + connectedIP; renderer.drawButtonHints(UI_FONT_ID, "« Exit", "", "", "");
renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR);
// Show web server URL prominently
std::string webInfo = "http://" + connectedIP + "/";
renderer.drawCenteredText(UI_FONT_ID, top + 70, webInfo.c_str(), true, BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, top + 110, "Open this URL in your browser", true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press BACK to exit", true, REGULAR);
} }

View File

@ -7,13 +7,15 @@
#include <memory> #include <memory>
#include <string> #include <string>
#include "../Activity.h" #include "NetworkModeSelectionActivity.h"
#include "WifiSelectionActivity.h" #include "activities/ActivityWithSubactivity.h"
#include "server/CrossPointWebServer.h" #include "network/CrossPointWebServer.h"
// Web server activity states // Web server activity states
enum class WebServerActivityState { enum class WebServerActivityState {
WIFI_SELECTION, // WiFi selection subactivity is active MODE_SELECTION, // Choosing between Join Network and Create Hotspot
WIFI_SELECTION, // WiFi selection subactivity is active (for Join Network mode)
AP_STARTING, // Starting Access Point mode
SERVER_RUNNING, // Web server is running and handling requests SERVER_RUNNING, // Web server is running and handling requests
SHUTTING_DOWN // Shutting down server and WiFi SHUTTING_DOWN // Shutting down server and WiFi
}; };
@ -21,27 +23,30 @@ enum class WebServerActivityState {
/** /**
* CrossPointWebServerActivity is the entry point for file transfer functionality. * CrossPointWebServerActivity is the entry point for file transfer functionality.
* It: * It:
* - Immediately turns on WiFi and launches WifiSelectionActivity on enter * - First presents a choice between "Join a Network" (STA) and "Create Hotspot" (AP)
* - When WifiSelectionActivity completes successfully, starts the CrossPointWebServer * - For STA mode: Launches WifiSelectionActivity to connect to an existing network
* - For AP mode: Creates an Access Point that clients can connect to
* - Starts the CrossPointWebServer when connected
* - Handles client requests in its loop() function * - Handles client requests in its loop() function
* - Cleans up the server and shuts down WiFi on exit * - Cleans up the server and shuts down WiFi on exit
*/ */
class CrossPointWebServerActivity final : public Activity { class CrossPointWebServerActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false; bool updateRequired = false;
WebServerActivityState state = WebServerActivityState::WIFI_SELECTION; WebServerActivityState state = WebServerActivityState::MODE_SELECTION;
const std::function<void()> onGoBack; const std::function<void()> onGoBack;
// WiFi selection subactivity // Network mode
std::unique_ptr<WifiSelectionActivity> wifiSelection; NetworkMode networkMode = NetworkMode::JOIN_NETWORK;
bool isApMode = false;
// Web server - owned by this activity // Web server - owned by this activity
std::unique_ptr<CrossPointWebServer> webServer; std::unique_ptr<CrossPointWebServer> webServer;
// Server status // Server status
std::string connectedIP; std::string connectedIP;
std::string connectedSSID; std::string connectedSSID; // For STA mode: network name, For AP mode: AP name
// Performance monitoring // Performance monitoring
unsigned long lastHandleClientTime = 0; unsigned long lastHandleClientTime = 0;
@ -51,14 +56,16 @@ class CrossPointWebServerActivity final : public Activity {
void render() const; void render() const;
void renderServerRunning() const; void renderServerRunning() const;
void onNetworkModeSelected(NetworkMode mode);
void onWifiSelectionComplete(bool connected); void onWifiSelectionComplete(bool connected);
void startAccessPoint();
void startWebServer(); void startWebServer();
void stopWebServer(); void stopWebServer();
public: public:
explicit CrossPointWebServerActivity(GfxRenderer& renderer, InputManager& inputManager, explicit CrossPointWebServerActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void()>& onGoBack) const std::function<void()>& onGoBack)
: Activity(renderer, inputManager), onGoBack(onGoBack) {} : ActivityWithSubactivity("CrossPointWebServer", renderer, inputManager), onGoBack(onGoBack) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@ -0,0 +1,128 @@
#include "NetworkModeSelectionActivity.h"
#include <GfxRenderer.h>
#include <InputManager.h>
#include "config.h"
namespace {
constexpr int MENU_ITEM_COUNT = 2;
const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Create Hotspot"};
const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {"Connect to an existing WiFi network",
"Create a WiFi network others can join"};
} // namespace
void NetworkModeSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<NetworkModeSelectionActivity*>(param);
self->displayTaskLoop();
}
void NetworkModeSelectionActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
// Reset selection
selectedIndex = 0;
// Trigger first update
updateRequired = true;
xTaskCreate(&NetworkModeSelectionActivity::taskTrampoline, "NetworkModeTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void NetworkModeSelectionActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void NetworkModeSelectionActivity::loop() {
// Handle back button - cancel
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
onCancel();
return;
}
// Handle confirm button - select current option
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
const NetworkMode mode = (selectedIndex == 0) ? NetworkMode::JOIN_NETWORK : NetworkMode::CREATE_HOTSPOT;
onModeSelected(mode);
return;
}
// Handle navigation
const bool prevPressed =
inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT);
const bool nextPressed =
inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT);
if (prevPressed) {
selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT;
updateRequired = true;
} else if (nextPressed) {
selectedIndex = (selectedIndex + 1) % MENU_ITEM_COUNT;
updateRequired = true;
}
}
void NetworkModeSelectionActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void NetworkModeSelectionActivity::render() const {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Draw header
renderer.drawCenteredText(READER_FONT_ID, 10, "File Transfer", true, BOLD);
// Draw subtitle
renderer.drawCenteredText(UI_FONT_ID, 50, "How would you like to connect?", true, REGULAR);
// Draw menu items centered on screen
constexpr int itemHeight = 50; // Height for each menu item (including description)
const int startY = (pageHeight - (MENU_ITEM_COUNT * itemHeight)) / 2 + 10;
for (int i = 0; i < MENU_ITEM_COUNT; i++) {
const int itemY = startY + i * itemHeight;
const bool isSelected = (i == selectedIndex);
// Draw selection highlight (black fill) for selected item
if (isSelected) {
renderer.fillRect(20, itemY - 2, pageWidth - 40, itemHeight - 6);
}
// Draw text: black=false (white text) when selected (on black background)
// black=true (black text) when not selected (on white background)
renderer.drawText(UI_FONT_ID, 30, itemY, MENU_ITEMS[i], /*black=*/!isSelected);
renderer.drawText(SMALL_FONT_ID, 30, itemY + 22, MENU_DESCRIPTIONS[i], /*black=*/!isSelected);
}
// Draw help text at bottom
renderer.drawButtonHints(UI_FONT_ID, "« Back", "Select", "", "");
renderer.displayBuffer();
}

View File

@ -0,0 +1,41 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include "../Activity.h"
// Enum for network mode selection
enum class NetworkMode { JOIN_NETWORK, CREATE_HOTSPOT };
/**
* NetworkModeSelectionActivity presents the user with a choice:
* - "Join a Network" - Connect to an existing WiFi network (STA mode)
* - "Create Hotspot" - Create an Access Point that others can connect to (AP mode)
*
* The onModeSelected callback is called with the user's choice.
* The onCancel callback is called if the user presses back.
*/
class NetworkModeSelectionActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
int selectedIndex = 0;
bool updateRequired = false;
const std::function<void(NetworkMode)> onModeSelected;
const std::function<void()> onCancel;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
public:
explicit NetworkModeSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void(NetworkMode)>& onModeSelected,
const std::function<void()>& onCancel)
: Activity("NetworkModeSelection", renderer, inputManager), onModeSelected(onModeSelected), onCancel(onCancel) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -6,6 +6,7 @@
#include <map> #include <map>
#include "WifiCredentialStore.h" #include "WifiCredentialStore.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "config.h" #include "config.h"
void WifiSelectionActivity::taskTrampoline(void* param) { void WifiSelectionActivity::taskTrampoline(void* param) {
@ -14,10 +15,14 @@ void WifiSelectionActivity::taskTrampoline(void* param) {
} }
void WifiSelectionActivity::onEnter() { void WifiSelectionActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
// Load saved WiFi credentials // Load saved WiFi credentials - SD card operations need lock as we use SPI for both
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.loadFromFile(); WIFI_STORE.loadFromFile();
xSemaphoreGive(renderingMutex);
// Reset state // Reset state
selectedNetworkIndex = 0; selectedNetworkIndex = 0;
@ -30,7 +35,6 @@ void WifiSelectionActivity::onEnter() {
usedSavedPassword = false; usedSavedPassword = false;
savePromptSelection = 0; savePromptSelection = 0;
forgetPromptSelection = 0; forgetPromptSelection = 0;
keyboard.reset();
// Trigger first update to show scanning message // Trigger first update to show scanning message
updateRequired = true; updateRequired = true;
@ -47,7 +51,8 @@ void WifiSelectionActivity::onEnter() {
} }
void WifiSelectionActivity::onExit() { void WifiSelectionActivity::onExit() {
Serial.printf("[%lu] [WIFI] ========== WifiSelectionActivity onExit START ==========\n", millis()); Activity::onExit();
Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap());
// Stop any ongoing WiFi scan // Stop any ongoing WiFi scan
@ -78,7 +83,6 @@ void WifiSelectionActivity::onExit() {
Serial.printf("[%lu] [WIFI] Mutex deleted\n", millis()); Serial.printf("[%lu] [WIFI] Mutex deleted\n", millis());
Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap());
Serial.printf("[%lu] [WIFI] ========== WifiSelectionActivity onExit COMPLETE ==========\n", millis());
} }
void WifiSelectionActivity::startWifiScan() { void WifiSelectionActivity::startWifiScan() {
@ -96,7 +100,7 @@ void WifiSelectionActivity::startWifiScan() {
} }
void WifiSelectionActivity::processWifiScanResults() { void WifiSelectionActivity::processWifiScanResults() {
int16_t scanResult = WiFi.scanComplete(); const int16_t scanResult = WiFi.scanComplete();
if (scanResult == WIFI_SCAN_RUNNING) { if (scanResult == WIFI_SCAN_RUNNING) {
// Scan still in progress // Scan still in progress
@ -115,7 +119,7 @@ void WifiSelectionActivity::processWifiScanResults() {
for (int i = 0; i < scanResult; i++) { for (int i = 0; i < scanResult; i++) {
std::string ssid = WiFi.SSID(i).c_str(); std::string ssid = WiFi.SSID(i).c_str();
int32_t rssi = WiFi.RSSI(i); const int32_t rssi = WiFi.RSSI(i);
// Skip hidden networks (empty SSID) // Skip hidden networks (empty SSID)
if (ssid.empty()) { if (ssid.empty()) {
@ -138,6 +142,7 @@ void WifiSelectionActivity::processWifiScanResults() {
// Convert map to vector // Convert map to vector
networks.clear(); networks.clear();
for (const auto& pair : uniqueNetworks) { for (const auto& pair : uniqueNetworks) {
// cppcheck-suppress useStlAlgorithm
networks.push_back(pair.second); networks.push_back(pair.second);
} }
@ -145,13 +150,18 @@ void WifiSelectionActivity::processWifiScanResults() {
std::sort(networks.begin(), networks.end(), std::sort(networks.begin(), networks.end(),
[](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { return a.rssi > b.rssi; }); [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { return a.rssi > b.rssi; });
// Show networks with PW first
std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) {
return a.hasSavedPassword && !b.hasSavedPassword;
});
WiFi.scanDelete(); WiFi.scanDelete();
state = WifiSelectionState::NETWORK_LIST; state = WifiSelectionState::NETWORK_LIST;
selectedNetworkIndex = 0; selectedNetworkIndex = 0;
updateRequired = true; updateRequired = true;
} }
void WifiSelectionActivity::selectNetwork(int index) { void WifiSelectionActivity::selectNetwork(const int index) {
if (index < 0 || index >= static_cast<int>(networks.size())) { if (index < 0 || index >= static_cast<int>(networks.size())) {
return; return;
} }
@ -177,11 +187,21 @@ void WifiSelectionActivity::selectNetwork(int index) {
if (selectedRequiresPassword) { if (selectedRequiresPassword) {
// Show password entry // Show password entry
state = WifiSelectionState::PASSWORD_ENTRY; state = WifiSelectionState::PASSWORD_ENTRY;
keyboard.reset(new KeyboardEntryActivity(renderer, inputManager, "Enter WiFi Password", enterNewActivity(new KeyboardEntryActivity(
"", // No initial text renderer, inputManager, "Enter WiFi Password",
64, // Max password length "", // No initial text
false // Show password by default (hard keyboard to use) 50, // Y position
)); 64, // Max password length
false, // Show password by default (hard keyboard to use)
[this](const std::string& text) {
enteredPassword = text;
exitActivity();
},
[this] {
state = WifiSelectionState::NETWORK_LIST;
updateRequired = true;
exitActivity();
}));
updateRequired = true; updateRequired = true;
} else { } else {
// Connect directly for open networks // Connect directly for open networks
@ -198,11 +218,6 @@ void WifiSelectionActivity::attemptConnection() {
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
// Get password from keyboard if we just entered it
if (keyboard && !usedSavedPassword) {
enteredPassword = keyboard->getText();
}
if (selectedRequiresPassword && !enteredPassword.empty()) { if (selectedRequiresPassword && !enteredPassword.empty()) {
WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str()); WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str());
} else { } else {
@ -215,7 +230,7 @@ void WifiSelectionActivity::checkConnectionStatus() {
return; return;
} }
wl_status_t status = WiFi.status(); const wl_status_t status = WiFi.status();
if (status == WL_CONNECTED) { if (status == WL_CONNECTED) {
// Successfully connected // Successfully connected
@ -259,6 +274,11 @@ void WifiSelectionActivity::checkConnectionStatus() {
} }
void WifiSelectionActivity::loop() { void WifiSelectionActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
// Check scan progress // Check scan progress
if (state == WifiSelectionState::SCANNING) { if (state == WifiSelectionState::SCANNING) {
processWifiScanResults(); processWifiScanResults();
@ -271,23 +291,9 @@ void WifiSelectionActivity::loop() {
return; return;
} }
// Handle password entry state if (state == WifiSelectionState::PASSWORD_ENTRY) {
if (state == WifiSelectionState::PASSWORD_ENTRY && keyboard) { // Reach here once password entry finished in subactivity
keyboard->handleInput(); attemptConnection();
if (keyboard->isComplete()) {
attemptConnection();
return;
}
if (keyboard->isCancelled()) {
state = WifiSelectionState::NETWORK_LIST;
keyboard.reset();
updateRequired = true;
return;
}
updateRequired = true;
return; return;
} }
@ -306,7 +312,9 @@ void WifiSelectionActivity::loop() {
} else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { } else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (savePromptSelection == 0) { if (savePromptSelection == 0) {
// User chose "Yes" - save the password // User chose "Yes" - save the password
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.addCredential(selectedSSID, enteredPassword); WIFI_STORE.addCredential(selectedSSID, enteredPassword);
xSemaphoreGive(renderingMutex);
} }
// Complete - parent will start web server // Complete - parent will start web server
onComplete(true); onComplete(true);
@ -332,13 +340,14 @@ void WifiSelectionActivity::loop() {
} else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { } else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (forgetPromptSelection == 0) { if (forgetPromptSelection == 0) {
// User chose "Yes" - forget the network // User chose "Yes" - forget the network
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.removeCredential(selectedSSID); WIFI_STORE.removeCredential(selectedSSID);
xSemaphoreGive(renderingMutex);
// Update the network list to reflect the change // Update the network list to reflect the change
for (auto& network : networks) { const auto network = find_if(networks.begin(), networks.end(),
if (network.ssid == selectedSSID) { [this](const WifiNetworkInfo& net) { return net.ssid == selectedSSID; });
network.hasSavedPassword = false; if (network != networks.end()) {
break; network->hasSavedPassword = false;
}
} }
} }
// Go back to network list // Go back to network list
@ -408,15 +417,18 @@ void WifiSelectionActivity::loop() {
} }
} }
std::string WifiSelectionActivity::getSignalStrengthIndicator(int32_t rssi) const { std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi) const {
// Convert RSSI to signal bars representation // Convert RSSI to signal bars representation
if (rssi >= -50) { if (rssi >= -50) {
return "||||"; // Excellent return "||||"; // Excellent
} else if (rssi >= -60) { }
if (rssi >= -60) {
return "||| "; // Good return "||| "; // Good
} else if (rssi >= -70) { }
if (rssi >= -70) {
return "|| "; // Fair return "|| "; // Fair
} else if (rssi >= -80) { }
if (rssi >= -80) {
return "| "; // Weak return "| "; // Weak
} }
return " "; // Very weak return " "; // Very weak
@ -424,6 +436,10 @@ std::string WifiSelectionActivity::getSignalStrengthIndicator(int32_t rssi) cons
void WifiSelectionActivity::displayTaskLoop() { void WifiSelectionActivity::displayTaskLoop() {
while (true) { while (true) {
if (subActivity) {
return;
}
if (updateRequired) { if (updateRequired) {
updateRequired = false; updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
@ -444,9 +460,6 @@ void WifiSelectionActivity::render() const {
case WifiSelectionState::NETWORK_LIST: case WifiSelectionState::NETWORK_LIST:
renderNetworkList(); renderNetworkList();
break; break;
case WifiSelectionState::PASSWORD_ENTRY:
renderPasswordEntry();
break;
case WifiSelectionState::CONNECTING: case WifiSelectionState::CONNECTING:
renderConnecting(); renderConnecting();
break; break;
@ -468,8 +481,8 @@ void WifiSelectionActivity::render() const {
} }
void WifiSelectionActivity::renderNetworkList() const { void WifiSelectionActivity::renderNetworkList() const {
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
// Draw header // Draw header
renderer.drawCenteredText(READER_FONT_ID, 10, "WiFi Networks", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 10, "WiFi Networks", true, BOLD);
@ -482,8 +495,8 @@ void WifiSelectionActivity::renderNetworkList() const {
renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, "Press OK to scan again", true, REGULAR); renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, "Press OK to scan again", true, REGULAR);
} else { } else {
// Calculate how many networks we can display // Calculate how many networks we can display
const int startY = 60; constexpr int startY = 60;
const int lineHeight = 25; constexpr int lineHeight = 25;
const int maxVisibleNetworks = (pageHeight - startY - 40) / lineHeight; const int maxVisibleNetworks = (pageHeight - startY - 40) / lineHeight;
// Calculate scroll offset to keep selected item visible // Calculate scroll offset to keep selected item visible
@ -506,7 +519,7 @@ void WifiSelectionActivity::renderNetworkList() const {
// Draw network name (truncate if too long) // Draw network name (truncate if too long)
std::string displayName = network.ssid; std::string displayName = network.ssid;
if (displayName.length() > 16) { if (displayName.length() > 16) {
displayName = displayName.substr(0, 13) + "..."; displayName.replace(13, displayName.length() - 13, "...");
} }
renderer.drawText(UI_FONT_ID, 20, networkY, displayName.c_str()); renderer.drawText(UI_FONT_ID, 20, networkY, displayName.c_str());
@ -536,53 +549,34 @@ void WifiSelectionActivity::renderNetworkList() const {
// Show network count // Show network count
char countStr[32]; char countStr[32];
snprintf(countStr, sizeof(countStr), "%zu networks found", networks.size()); snprintf(countStr, sizeof(countStr), "%zu networks found", networks.size());
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 45, countStr); renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 90, countStr);
} }
// Draw help text // Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "OK: Connect | * = Encrypted | + = Saved"); renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved");
} renderer.drawButtonHints(UI_FONT_ID, "« Back", "Connect", "", "");
void WifiSelectionActivity::renderPasswordEntry() const {
const auto pageHeight = GfxRenderer::getScreenHeight();
// Draw header
renderer.drawCenteredText(READER_FONT_ID, 5, "WiFi Password", true, BOLD);
// Draw network name with good spacing from header
std::string networkInfo = "Network: " + selectedSSID;
if (networkInfo.length() > 30) {
networkInfo = networkInfo.substr(0, 27) + "...";
}
renderer.drawCenteredText(UI_FONT_ID, 38, networkInfo.c_str(), true, REGULAR);
// Draw keyboard
if (keyboard) {
keyboard->render(58);
}
} }
void WifiSelectionActivity::renderConnecting() const { void WifiSelectionActivity::renderConnecting() const {
const auto pageHeight = GfxRenderer::getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID); const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height) / 2; const auto top = (pageHeight - height) / 2;
if (state == WifiSelectionState::SCANNING) { if (state == WifiSelectionState::SCANNING) {
renderer.drawCenteredText(UI_FONT_ID, top, "Scanning...", true, REGULAR); renderer.drawCenteredText(UI_FONT_ID, top, "Scanning...", true, REGULAR);
} else { } else {
renderer.drawCenteredText(READER_FONT_ID, top - 30, "Connecting...", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, top - 40, "Connecting...", true, BOLD);
std::string ssidInfo = "to " + selectedSSID; std::string ssidInfo = "to " + selectedSSID;
if (ssidInfo.length() > 25) { if (ssidInfo.length() > 25) {
ssidInfo = ssidInfo.substr(0, 22) + "..."; ssidInfo.replace(22, ssidInfo.length() - 22, "...");
} }
renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR); renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR);
} }
} }
void WifiSelectionActivity::renderConnected() const { void WifiSelectionActivity::renderConnected() const {
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageHeight = renderer.getScreenHeight();
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID); const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 4) / 2; const auto top = (pageHeight - height * 4) / 2;
@ -590,19 +584,19 @@ void WifiSelectionActivity::renderConnected() const {
std::string ssidInfo = "Network: " + selectedSSID; std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) { if (ssidInfo.length() > 28) {
ssidInfo = ssidInfo.substr(0, 25) + "..."; ssidInfo.replace(25, ssidInfo.length() - 25, "...");
} }
renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR); renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR);
std::string ipInfo = "IP Address: " + connectedIP; const std::string ipInfo = "IP Address: " + connectedIP;
renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR); renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue", true, REGULAR); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue", true, REGULAR);
} }
void WifiSelectionActivity::renderSavePrompt() const { void WifiSelectionActivity::renderSavePrompt() const {
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID); const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 3) / 2; const auto top = (pageHeight - height * 3) / 2;
@ -610,7 +604,7 @@ void WifiSelectionActivity::renderSavePrompt() const {
std::string ssidInfo = "Network: " + selectedSSID; std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) { if (ssidInfo.length() > 28) {
ssidInfo = ssidInfo.substr(0, 25) + "..."; ssidInfo.replace(25, ssidInfo.length() - 25, "...");
} }
renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR); renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR);
@ -618,9 +612,9 @@ void WifiSelectionActivity::renderSavePrompt() const {
// Draw Yes/No buttons // Draw Yes/No buttons
const int buttonY = top + 80; const int buttonY = top + 80;
const int buttonWidth = 60; constexpr int buttonWidth = 60;
const int buttonSpacing = 30; constexpr int buttonSpacing = 30;
const int totalWidth = buttonWidth * 2 + buttonSpacing; constexpr int totalWidth = buttonWidth * 2 + buttonSpacing;
const int startX = (pageWidth - totalWidth) / 2; const int startX = (pageWidth - totalWidth) / 2;
// Draw "Yes" button // Draw "Yes" button
@ -641,7 +635,7 @@ void WifiSelectionActivity::renderSavePrompt() const {
} }
void WifiSelectionActivity::renderConnectionFailed() const { void WifiSelectionActivity::renderConnectionFailed() const {
const auto pageHeight = GfxRenderer::getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID); const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 2) / 2; const auto top = (pageHeight - height * 2) / 2;
@ -651,8 +645,8 @@ void WifiSelectionActivity::renderConnectionFailed() const {
} }
void WifiSelectionActivity::renderForgetPrompt() const { void WifiSelectionActivity::renderForgetPrompt() const {
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID); const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 3) / 2; const auto top = (pageHeight - height * 3) / 2;
@ -660,7 +654,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
std::string ssidInfo = "Network: " + selectedSSID; std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) { if (ssidInfo.length() > 28) {
ssidInfo = ssidInfo.substr(0, 25) + "..."; ssidInfo.replace(25, ssidInfo.length() - 25, "...");
} }
renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR); renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR);
@ -668,9 +662,9 @@ void WifiSelectionActivity::renderForgetPrompt() const {
// Draw Yes/No buttons // Draw Yes/No buttons
const int buttonY = top + 80; const int buttonY = top + 80;
const int buttonWidth = 60; constexpr int buttonWidth = 60;
const int buttonSpacing = 30; constexpr int buttonSpacing = 30;
const int totalWidth = buttonWidth * 2 + buttonSpacing; constexpr int totalWidth = buttonWidth * 2 + buttonSpacing;
const int startX = (pageWidth - totalWidth) / 2; const int startX = (pageWidth - totalWidth) / 2;
// Draw "Yes" button // Draw "Yes" button

View File

@ -9,8 +9,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "../Activity.h" #include "activities/ActivityWithSubactivity.h"
#include "../util/KeyboardEntryActivity.h"
// Structure to hold WiFi network information // Structure to hold WiFi network information
struct WifiNetworkInfo { struct WifiNetworkInfo {
@ -43,7 +42,7 @@ enum class WifiSelectionState {
* *
* The onComplete callback receives true if connected successfully, false if cancelled. * The onComplete callback receives true if connected successfully, false if cancelled.
*/ */
class WifiSelectionActivity final : public Activity { class WifiSelectionActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false; bool updateRequired = false;
@ -56,9 +55,6 @@ class WifiSelectionActivity final : public Activity {
std::string selectedSSID; std::string selectedSSID;
bool selectedRequiresPassword = false; bool selectedRequiresPassword = false;
// On-screen keyboard for password entry
std::unique_ptr<KeyboardEntryActivity> keyboard;
// Connection result // Connection result
std::string connectedIP; std::string connectedIP;
std::string connectionError; std::string connectionError;
@ -98,7 +94,7 @@ class WifiSelectionActivity final : public Activity {
public: public:
explicit WifiSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, explicit WifiSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void(bool connected)>& onComplete) const std::function<void(bool connected)>& onComplete)
: Activity(renderer, inputManager), onComplete(onComplete) {} : ActivityWithSubactivity("WifiSelection", renderer, inputManager), onComplete(onComplete) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@ -1,17 +1,20 @@
#include "EpubReaderActivity.h" #include "EpubReaderActivity.h"
#include <Epub/Page.h> #include <Epub/Page.h>
#include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SD.h> #include <InputManager.h>
#include "Battery.h" #include "Battery.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderChapterSelectionActivity.h"
#include "config.h" #include "config.h"
namespace { namespace {
constexpr int pagesPerRefresh = 15; constexpr int pagesPerRefresh = 15;
constexpr unsigned long skipChapterMs = 700; constexpr unsigned long skipChapterMs = 700;
constexpr unsigned long goHomeMs = 1000;
constexpr float lineCompression = 0.95f; constexpr float lineCompression = 0.95f;
constexpr int marginTop = 8; constexpr int marginTop = 8;
constexpr int marginRight = 10; constexpr int marginRight = 10;
@ -25,6 +28,8 @@ void EpubReaderActivity::taskTrampoline(void* param) {
} }
void EpubReaderActivity::onEnter() { void EpubReaderActivity::onEnter() {
ActivityWithSubactivity::onEnter();
if (!epub) { if (!epub) {
return; return;
} }
@ -44,8 +49,8 @@ void EpubReaderActivity::onEnter() {
epub->setupCacheDir(); epub->setupCacheDir();
File f = SD.open((epub->getCachePath() + "/progress.bin").c_str()); File f;
if (f) { if (FsHelpers::openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[4]; uint8_t data[4];
if (f.read(data, 4) == 4) { if (f.read(data, 4) == 4) {
currentSpineIndex = data[0] + (data[1] << 8); currentSpineIndex = data[0] + (data[1] << 8);
@ -55,6 +60,10 @@ void EpubReaderActivity::onEnter() {
f.close(); f.close();
} }
// Save current epub as last opened epub
APP_STATE.openEpubPath = epub->getPath();
APP_STATE.saveToFile();
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
@ -67,6 +76,8 @@ void EpubReaderActivity::onEnter() {
} }
void EpubReaderActivity::onExit() { void EpubReaderActivity::onExit() {
ActivityWithSubactivity::onExit();
// Reset orientation back to portrait for the rest of the UI // Reset orientation back to portrait for the rest of the UI
GfxRenderer::setOrientation(GfxRenderer::Orientation::Portrait); GfxRenderer::setOrientation(GfxRenderer::Orientation::Portrait);
@ -84,8 +95,8 @@ void EpubReaderActivity::onExit() {
void EpubReaderActivity::loop() { void EpubReaderActivity::loop() {
// Pass input responsibility to sub activity if exists // Pass input responsibility to sub activity if exists
if (subAcitivity) { if (subActivity) {
subAcitivity->loop(); subActivity->loop();
return; return;
} }
@ -93,11 +104,11 @@ void EpubReaderActivity::loop() {
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
// Don't start activity transition while rendering // Don't start activity transition while rendering
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
subAcitivity.reset(new EpubReaderChapterSelectionActivity( exitActivity();
enterNewActivity(new EpubReaderChapterSelectionActivity(
this->renderer, this->inputManager, epub, currentSpineIndex, this->renderer, this->inputManager, epub, currentSpineIndex,
[this] { [this] {
subAcitivity->onExit(); exitActivity();
subAcitivity.reset();
updateRequired = true; updateRequired = true;
}, },
[this](const int newSpineIndex) { [this](const int newSpineIndex) {
@ -106,15 +117,20 @@ void EpubReaderActivity::loop() {
nextPageNumber = 0; nextPageNumber = 0;
section.reset(); section.reset();
} }
subAcitivity->onExit(); exitActivity();
subAcitivity.reset();
updateRequired = true; updateRequired = true;
})); }));
subAcitivity->onEnter();
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
} }
if (inputManager.wasPressed(InputManager::BTN_BACK)) { // Long press BACK (1s+) goes directly to home
if (inputManager.isPressed(InputManager::BTN_BACK) && inputManager.getHeldTime() >= goHomeMs) {
onGoHome();
return;
}
// Short press BACK goes to file selection
if (inputManager.wasReleased(InputManager::BTN_BACK) && inputManager.getHeldTime() < goHomeMs) {
onGoBack(); onGoBack();
return; return;
} }
@ -218,7 +234,7 @@ void EpubReaderActivity::renderScreen() {
} }
if (!section) { if (!section) {
const auto filepath = epub->getSpineItem(currentSpineIndex); const auto filepath = epub->getSpineItem(currentSpineIndex).href;
Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex); Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer)); section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, marginLeft, if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, marginLeft,
@ -226,24 +242,52 @@ void EpubReaderActivity::renderScreen() {
GfxRenderer::getScreenHeight())) { GfxRenderer::getScreenHeight())) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
// Progress bar dimensions
constexpr int barWidth = 200;
constexpr int barHeight = 10;
constexpr int boxMargin = 20;
const int textWidth = renderer.getTextWidth(READER_FONT_ID, "Indexing...");
const int boxWidthWithBar = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2;
const int boxWidthNoBar = textWidth + boxMargin * 2;
const int boxHeightWithBar = renderer.getLineHeight(READER_FONT_ID) + barHeight + boxMargin * 3;
const int boxHeightNoBar = renderer.getLineHeight(READER_FONT_ID) + boxMargin * 2;
const int boxXWithBar = (GfxRenderer::getScreenWidth() - boxWidthWithBar) / 2;
const int boxXNoBar = (GfxRenderer::getScreenWidth() - boxWidthNoBar) / 2;
constexpr int boxY = 50;
const int barX = boxXWithBar + (boxWidthWithBar - barWidth) / 2;
const int barY = boxY + renderer.getLineHeight(READER_FONT_ID) + boxMargin * 2;
// Always show "Indexing..." text first
{ {
const int textWidth = renderer.getTextWidth(READER_FONT_ID, "Indexing..."); renderer.fillRect(boxXNoBar, boxY, boxWidthNoBar, boxHeightNoBar, false);
constexpr int margin = 20; renderer.drawText(READER_FONT_ID, boxXNoBar + boxMargin, boxY + boxMargin, "Indexing...");
const int x = (GfxRenderer::getScreenWidth() - textWidth - margin * 2) / 2; renderer.drawRect(boxXNoBar + 5, boxY + 5, boxWidthNoBar - 10, boxHeightNoBar - 10);
constexpr int y = 50;
const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2;
renderer.fillRect(x, y, w, h, false);
renderer.drawText(READER_FONT_ID, x + margin, y + margin, "Indexing...");
renderer.drawRect(x + 5, y + 5, w - 10, h - 10);
renderer.displayBuffer(); renderer.displayBuffer();
pagesUntilFullRefresh = 0; pagesUntilFullRefresh = 0;
} }
section->setupCacheDir(); section->setupCacheDir();
// Setup callback - only called for chapters >= 50KB, redraws with progress bar
auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, boxMargin, barX, barY, barWidth,
barHeight]() {
renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false);
renderer.drawText(READER_FONT_ID, boxXWithBar + boxMargin, boxY + boxMargin, "Indexing...");
renderer.drawRect(boxXWithBar + 5, boxY + 5, boxWidthWithBar - 10, boxHeightWithBar - 10);
renderer.drawRect(barX, barY, barWidth, barHeight);
renderer.displayBuffer();
};
// Progress callback to update progress bar
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
const int fillWidth = (barWidth - 2) * progress / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
};
if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom,
marginLeft, SETTINGS.extraParagraphSpacing, GfxRenderer::getScreenWidth(), marginLeft, SETTINGS.extraParagraphSpacing, GfxRenderer::getScreenWidth(),
GfxRenderer::getScreenHeight())) { GfxRenderer::getScreenHeight(), progressSetup, progressCallback)) {
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
section.reset(); section.reset();
return; return;
@ -290,14 +334,16 @@ void EpubReaderActivity::renderScreen() {
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
} }
File f = SD.open((epub->getCachePath() + "/progress.bin").c_str(), FILE_WRITE); File f;
uint8_t data[4]; if (FsHelpers::openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
data[0] = currentSpineIndex & 0xFF; uint8_t data[4];
data[1] = (currentSpineIndex >> 8) & 0xFF; data[0] = currentSpineIndex & 0xFF;
data[2] = section->currentPage & 0xFF; data[1] = (currentSpineIndex >> 8) & 0xFF;
data[3] = (section->currentPage >> 8) & 0xFF; data[2] = section->currentPage & 0xFF;
f.write(data, 4); data[3] = (section->currentPage >> 8) & 0xFF;
f.close(); f.write(data, 4);
f.close();
}
} }
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) { void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) {
@ -338,73 +384,88 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) {
} }
void EpubReaderActivity::renderStatusBar() const { void EpubReaderActivity::renderStatusBar() const {
// determine visible status bar elements
const bool showProgress = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
const bool showChapterTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
// Position status bar near the bottom of the logical screen, regardless of orientation // Position status bar near the bottom of the logical screen, regardless of orientation
const auto screenHeight = GfxRenderer::getScreenHeight(); const auto screenHeight = GfxRenderer::getScreenHeight();
const auto textY = screenHeight - 24; const auto textY = screenHeight - 24;
int percentageTextWidth = 0;
int progressTextWidth = 0;
// Calculate progress in book if (showProgress) {
float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount; // Calculate progress in book
uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg); const float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount;
const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg);
// Right aligned text for progress counter // Right aligned text for progress counter
const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) + const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) +
" " + std::to_string(bookProgress) + "%"; " " + std::to_string(bookProgress) + "%";
const auto progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str());
renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY, renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY,
progress.c_str()); progress.c_str());
// Left aligned battery icon and percentage
const uint16_t percentage = battery.readPercentage();
const auto percentageText = std::to_string(percentage) + "%";
const auto percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageText.c_str());
// 1 column on left, 2 columns on right, 5 columns of battery body
constexpr int batteryWidth = 15;
constexpr int batteryHeight = 10;
constexpr int x = marginLeft;
const int y = screenHeight - 17;
// Top line
renderer.drawLine(x, y, x + batteryWidth - 4, y);
// Bottom line
renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1);
// Left line
renderer.drawLine(x, y, x, y + batteryHeight - 1);
// Battery end
renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1);
renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2);
renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3);
renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3);
// The +1 is to round up, so that we always fill at least one pixel
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
if (filledWidth > batteryWidth - 5) {
filledWidth = batteryWidth - 5; // Ensure we don't overflow
} }
renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2);
// Centered chatper title text if (showBattery) {
// Page width minus existing content with 30px padding on each side // Left aligned battery icon and percentage
const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft; const uint16_t percentage = battery.readPercentage();
const int titleMarginRight = progressTextWidth + 30 + marginRight; const auto percentageText = std::to_string(percentage) + "%";
const int availableTextWidth = GfxRenderer::getScreenWidth() - titleMarginLeft - titleMarginRight; percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageText.c_str());
std::string title; // 1 column on left, 2 columns on right, 5 columns of battery body
int titleWidth; constexpr int batteryWidth = 15;
if (tocIndex == -1) { constexpr int batteryHeight = 10;
title = "Unnamed"; constexpr int x = marginLeft;
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, "Unnamed"); const int y = screenHeight - 17;
} else {
const auto tocItem = epub->getTocItem(tocIndex); // Top line
title = tocItem.title; renderer.drawLine(x, y, x + batteryWidth - 4, y);
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); // Bottom line
while (titleWidth > availableTextWidth && title.length() > 11) { renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1);
title = title.substr(0, title.length() - 8) + "..."; // Left line
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); renderer.drawLine(x, y, x, y + batteryHeight - 1);
// Battery end
renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1);
renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2);
renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3);
renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3);
// The +1 is to round up, so that we always fill at least one pixel
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
if (filledWidth > batteryWidth - 5) {
filledWidth = batteryWidth - 5; // Ensure we don't overflow
} }
renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2);
} }
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str()); if (showChapterTitle) {
// Centered chatper title text
// Page width minus existing content with 30px padding on each side
const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft;
const int titleMarginRight = progressTextWidth + 30 + marginRight;
const int availableTextWidth = GfxRenderer::getScreenWidth() - titleMarginLeft - titleMarginRight;
const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
std::string title;
int titleWidth;
if (tocIndex == -1) {
title = "Unnamed";
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, "Unnamed");
} else {
const auto tocItem = epub->getTocItem(tocIndex);
title = tocItem.title;
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
while (titleWidth > availableTextWidth && title.length() > 11) {
title.replace(title.length() - 8, 8, "...");
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
}
}
renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str());
}
} }

View File

@ -5,19 +5,19 @@
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <freertos/task.h> #include <freertos/task.h>
#include "../Activity.h" #include "activities/ActivityWithSubactivity.h"
class EpubReaderActivity final : public Activity { class EpubReaderActivity final : public ActivityWithSubactivity {
std::shared_ptr<Epub> epub; std::shared_ptr<Epub> epub;
std::unique_ptr<Section> section = nullptr; std::unique_ptr<Section> section = nullptr;
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
std::unique_ptr<Activity> subAcitivity = nullptr;
int currentSpineIndex = 0; int currentSpineIndex = 0;
int nextPageNumber = 0; int nextPageNumber = 0;
int pagesUntilFullRefresh = 0; int pagesUntilFullRefresh = 0;
bool updateRequired = false; bool updateRequired = false;
const std::function<void()> onGoBack; const std::function<void()> onGoBack;
const std::function<void()> onGoHome;
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
@ -27,8 +27,11 @@ class EpubReaderActivity final : public Activity {
public: public:
explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub, explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoBack) const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
: Activity(renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {} : ActivityWithSubactivity("EpubReader", renderer, inputManager),
epub(std::move(epub)),
onGoBack(onGoBack),
onGoHome(onGoHome) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@ -1,12 +1,15 @@
#include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderChapterSelectionActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h>
#include <SD.h> #include <SD.h>
#include "config.h" #include "config.h"
namespace {
// Time threshold for treating a long press as a page-up/page-down // Time threshold for treating a long press as a page-up/page-down
constexpr int SKIP_PAGE_MS = 700; constexpr int SKIP_PAGE_MS = 700;
} // namespace
int EpubReaderChapterSelectionActivity::getPageItems() const { int EpubReaderChapterSelectionActivity::getPageItems() const {
// Layout constants used in renderScreen // Layout constants used in renderScreen
@ -30,6 +33,8 @@ void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) {
} }
void EpubReaderChapterSelectionActivity::onEnter() { void EpubReaderChapterSelectionActivity::onEnter() {
Activity::onEnter();
if (!epub) { if (!epub) {
return; return;
} }
@ -40,7 +45,7 @@ void EpubReaderChapterSelectionActivity::onEnter() {
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
xTaskCreate(&EpubReaderChapterSelectionActivity::taskTrampoline, "EpubReaderChapterSelectionActivityTask", xTaskCreate(&EpubReaderChapterSelectionActivity::taskTrampoline, "EpubReaderChapterSelectionActivityTask",
2048, // Stack size 4096, // Stack size
this, // Parameters this, // Parameters
1, // Priority 1, // Priority
&displayTaskHandle // Task handle &displayTaskHandle // Task handle
@ -48,6 +53,8 @@ void EpubReaderChapterSelectionActivity::onEnter() {
} }
void EpubReaderChapterSelectionActivity::onExit() { void EpubReaderChapterSelectionActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD // Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) { if (displayTaskHandle) {
@ -67,9 +74,9 @@ void EpubReaderChapterSelectionActivity::loop() {
const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS; const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems(); const int pageItems = getPageItems();
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) {
onSelectSpineIndex(selectorIndex); onSelectSpineIndex(selectorIndex);
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) { } else if (inputManager.wasReleased(InputManager::BTN_BACK)) {
onGoBack(); onGoBack();
} else if (prevReleased) { } else if (prevReleased) {
if (skipPage) { if (skipPage) {

View File

@ -31,7 +31,7 @@ class EpubReaderChapterSelectionActivity final : public Activity {
const std::shared_ptr<Epub>& epub, const int currentSpineIndex, const std::shared_ptr<Epub>& epub, const int currentSpineIndex,
const std::function<void()>& onGoBack, const std::function<void()>& onGoBack,
const std::function<void(int newSpineIndex)>& onSelectSpineIndex) const std::function<void(int newSpineIndex)>& onSelectSpineIndex)
: Activity(renderer, inputManager), : Activity("EpubReaderChapterSelection", renderer, inputManager),
epub(epub), epub(epub),
currentSpineIndex(currentSpineIndex), currentSpineIndex(currentSpineIndex),
onGoBack(onGoBack), onGoBack(onGoBack),

View File

@ -1,10 +1,17 @@
#include "FileSelectionActivity.h" #include "FileSelectionActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h>
#include <SD.h> #include <SD.h>
#include "config.h" #include "config.h"
namespace {
constexpr int PAGE_ITEMS = 23;
constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000;
} // namespace
void sortFileList(std::vector<std::string>& strs) { void sortFileList(std::vector<std::string>& strs) {
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
if (str1.back() == '/' && str2.back() != '/') return true; if (str1.back() == '/' && str2.back() != '/') return true;
@ -43,9 +50,11 @@ void FileSelectionActivity::loadFiles() {
} }
void FileSelectionActivity::onEnter() { void FileSelectionActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
basepath = "/"; // basepath is set via constructor parameter (defaults to "/" if not specified)
loadFiles(); loadFiles();
selectorIndex = 0; selectorIndex = 0;
@ -61,6 +70,8 @@ void FileSelectionActivity::onEnter() {
} }
void FileSelectionActivity::onExit() { void FileSelectionActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD // Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) { if (displayTaskHandle) {
@ -73,10 +84,22 @@ void FileSelectionActivity::onExit() {
} }
void FileSelectionActivity::loop() { void FileSelectionActivity::loop() {
const bool prevPressed = // Long press BACK (1s+) goes to root folder
inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT); if (inputManager.isPressed(InputManager::BTN_BACK) && inputManager.getHeldTime() >= GO_HOME_MS) {
const bool nextPressed = if (basepath != "/") {
inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT); basepath = "/";
loadFiles();
updateRequired = true;
}
return;
}
const bool prevReleased =
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT);
const bool nextReleased =
inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT);
const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS;
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (files.empty()) { if (files.empty()) {
@ -91,21 +114,31 @@ void FileSelectionActivity::loop() {
} else { } else {
onSelect(basepath + files[selectorIndex]); onSelect(basepath + files[selectorIndex]);
} }
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) { } else if (inputManager.wasReleased(InputManager::BTN_BACK)) {
if (basepath != "/") { // Short press: go up one directory, or go home if at root
basepath = basepath.substr(0, basepath.rfind('/')); if (inputManager.getHeldTime() < GO_HOME_MS) {
if (basepath.empty()) basepath = "/"; if (basepath != "/") {
loadFiles(); basepath.replace(basepath.find_last_of('/'), std::string::npos, "");
updateRequired = true; if (basepath.empty()) basepath = "/";
} else { loadFiles();
// At root level, go back home updateRequired = true;
onGoHome(); } else {
onGoHome();
}
}
} else if (prevReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + files.size()) % files.size();
} else {
selectorIndex = (selectorIndex + files.size() - 1) % files.size();
} }
} else if (prevPressed) {
selectorIndex = (selectorIndex + files.size() - 1) % files.size();
updateRequired = true; updateRequired = true;
} else if (nextPressed) { } else if (nextReleased) {
selectorIndex = (selectorIndex + 1) % files.size(); if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % files.size();
} else {
selectorIndex = (selectorIndex + 1) % files.size();
}
updateRequired = true; updateRequired = true;
} }
} }
@ -126,21 +159,27 @@ void FileSelectionActivity::render() const {
renderer.clearScreen(); renderer.clearScreen();
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = GfxRenderer::getScreenWidth();
renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD);
// Help text // Help text
renderer.drawText(SMALL_FONT_ID, 20, GfxRenderer::getScreenHeight() - 30, "Press BACK for Home"); renderer.drawButtonHints(UI_FONT_ID, "« Home", "Open", "", "");
if (files.empty()) { if (files.empty()) {
renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found"); renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found");
} else { renderer.displayBuffer();
// Draw selection return;
renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30); }
for (size_t i = 0; i < files.size(); i++) { const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
const auto file = files[i]; renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 + 2, pageWidth - 1, 30);
renderer.drawText(UI_FONT_ID, 20, 60 + i * 30, file.c_str(), i != selectorIndex); for (int i = pageStartIndex; i < files.size() && i < pageStartIndex + PAGE_ITEMS; i++) {
auto item = files[i];
int itemWidth = renderer.getTextWidth(UI_FONT_ID, item.c_str());
while (itemWidth > renderer.getScreenWidth() - 40 && item.length() > 8) {
item.replace(item.length() - 5, 5, "...");
itemWidth = renderer.getTextWidth(UI_FONT_ID, item.c_str());
} }
renderer.drawText(UI_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != selectorIndex);
} }
renderer.displayBuffer(); renderer.displayBuffer();

View File

@ -27,8 +27,11 @@ class FileSelectionActivity final : public Activity {
public: public:
explicit FileSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, explicit FileSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void(const std::string&)>& onSelect, const std::function<void(const std::string&)>& onSelect,
const std::function<void()>& onGoHome) const std::function<void()>& onGoHome, std::string initialPath = "/")
: Activity(renderer, inputManager), onSelect(onSelect), onGoHome(onGoHome) {} : Activity("FileSelection", renderer, inputManager),
basepath(initialPath.empty() ? "/" : std::move(initialPath)),
onSelect(onSelect),
onGoHome(onGoHome) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@ -2,12 +2,19 @@
#include <SD.h> #include <SD.h>
#include "CrossPointState.h"
#include "Epub.h" #include "Epub.h"
#include "EpubReaderActivity.h" #include "EpubReaderActivity.h"
#include "FileSelectionActivity.h" #include "FileSelectionActivity.h"
#include "activities/util/FullScreenMessageActivity.h" #include "activities/util/FullScreenMessageActivity.h"
std::string ReaderActivity::extractFolderPath(const std::string& filePath) {
const auto lastSlash = filePath.find_last_of('/');
if (lastSlash == std::string::npos || lastSlash == 0) {
return "/";
}
return filePath.substr(0, lastSlash);
}
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) { std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
if (!SD.exists(path.c_str())) { if (!SD.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
@ -24,13 +31,12 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
} }
void ReaderActivity::onSelectEpubFile(const std::string& path) { void ReaderActivity::onSelectEpubFile(const std::string& path) {
currentEpubPath = path; // Track current book path
exitActivity(); exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading...")); enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading..."));
auto epub = loadEpub(path); auto epub = loadEpub(path);
if (epub) { if (epub) {
APP_STATE.openEpubPath = path;
APP_STATE.saveToFile();
onGoToEpubReader(std::move(epub)); onGoToEpubReader(std::move(epub));
} else { } else {
exitActivity(); exitActivity();
@ -41,23 +47,32 @@ void ReaderActivity::onSelectEpubFile(const std::string& path) {
} }
} }
void ReaderActivity::onGoToFileSelection() { void ReaderActivity::onGoToFileSelection(const std::string& fromEpubPath) {
exitActivity(); exitActivity();
// If coming from a book, start in that book's folder; otherwise start from root
const auto initialPath = fromEpubPath.empty() ? "/" : extractFolderPath(fromEpubPath);
enterNewActivity(new FileSelectionActivity( enterNewActivity(new FileSelectionActivity(
renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack)); renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack, initialPath));
} }
void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) { void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
const auto epubPath = epub->getPath();
currentEpubPath = epubPath;
exitActivity(); exitActivity();
enterNewActivity(new EpubReaderActivity(renderer, inputManager, std::move(epub), [this] { onGoToFileSelection(); })); enterNewActivity(new EpubReaderActivity(
renderer, inputManager, std::move(epub), [this, epubPath] { onGoToFileSelection(epubPath); },
[this] { onGoBack(); }));
} }
void ReaderActivity::onEnter() { void ReaderActivity::onEnter() {
ActivityWithSubactivity::onEnter();
if (initialEpubPath.empty()) { if (initialEpubPath.empty()) {
onGoToFileSelection(); onGoToFileSelection(); // Start from root when entering via Browse
return; return;
} }
currentEpubPath = initialEpubPath;
auto epub = loadEpub(initialEpubPath); auto epub = loadEpub(initialEpubPath);
if (!epub) { if (!epub) {
onGoBack(); onGoBack();

View File

@ -7,17 +7,19 @@ class Epub;
class ReaderActivity final : public ActivityWithSubactivity { class ReaderActivity final : public ActivityWithSubactivity {
std::string initialEpubPath; std::string initialEpubPath;
std::string currentEpubPath; // Track current book path for navigation
const std::function<void()> onGoBack; const std::function<void()> onGoBack;
static std::unique_ptr<Epub> loadEpub(const std::string& path); static std::unique_ptr<Epub> loadEpub(const std::string& path);
static std::string extractFolderPath(const std::string& filePath);
void onSelectEpubFile(const std::string& path); void onSelectEpubFile(const std::string& path);
void onGoToFileSelection(); void onGoToFileSelection(const std::string& fromEpubPath = "");
void onGoToEpubReader(std::unique_ptr<Epub> epub); void onGoToEpubReader(std::unique_ptr<Epub> epub);
public: public:
explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialEpubPath, explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialEpubPath,
const std::function<void()>& onGoBack) const std::function<void()>& onGoBack)
: ActivityWithSubactivity(renderer, inputManager), : ActivityWithSubactivity("Reader", renderer, inputManager),
initialEpubPath(std::move(initialEpubPath)), initialEpubPath(std::move(initialEpubPath)),
onGoBack(onGoBack) {} onGoBack(onGoBack) {}
void onEnter() override; void onEnter() override;

View File

@ -0,0 +1,242 @@
#include "OtaUpdateActivity.h"
#include <GfxRenderer.h>
#include <InputManager.h>
#include <WiFi.h>
#include "activities/network/WifiSelectionActivity.h"
#include "config.h"
#include "network/OtaUpdater.h"
void OtaUpdateActivity::taskTrampoline(void* param) {
auto* self = static_cast<OtaUpdateActivity*>(param);
self->displayTaskLoop();
}
void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
exitActivity();
if (!success) {
Serial.printf("[%lu] [OTA] WiFi connection failed, exiting\n", millis());
goBack();
return;
}
Serial.printf("[%lu] [OTA] WiFi connected, checking for update\n", millis());
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = CHECKING_FOR_UPDATE;
xSemaphoreGive(renderingMutex);
updateRequired = true;
vTaskDelay(10 / portTICK_PERIOD_MS);
const auto res = updater.checkForUpdate();
if (res != OtaUpdater::OK) {
Serial.printf("[%lu] [OTA] Update check failed: %d\n", millis(), res);
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = FAILED;
xSemaphoreGive(renderingMutex);
updateRequired = true;
return;
}
if (!updater.isUpdateNewer()) {
Serial.printf("[%lu] [OTA] No new update available\n", millis());
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = NO_UPDATE;
xSemaphoreGive(renderingMutex);
updateRequired = true;
return;
}
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = WAITING_CONFIRMATION;
xSemaphoreGive(renderingMutex);
updateRequired = true;
}
void OtaUpdateActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
xTaskCreate(&OtaUpdateActivity::taskTrampoline, "OtaUpdateActivityTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
// Turn on WiFi immediately
Serial.printf("[%lu] [OTA] Turning on WiFi...\n", millis());
WiFi.mode(WIFI_STA);
// Launch WiFi selection subactivity
Serial.printf("[%lu] [OTA] Launching WifiSelectionActivity...\n", millis());
enterNewActivity(new WifiSelectionActivity(renderer, inputManager,
[this](const bool connected) { onWifiSelectionComplete(connected); }));
}
void OtaUpdateActivity::onExit() {
ActivityWithSubactivity::onExit();
// Turn off wifi
WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame
delay(100); // Allow disconnect frame to be sent
WiFi.mode(WIFI_OFF);
delay(100); // Allow WiFi hardware to fully power down
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void OtaUpdateActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void OtaUpdateActivity::render() {
if (subActivity) {
// Subactivity handles its own rendering
return;
}
float updaterProgress = 0;
if (state == UPDATE_IN_PROGRESS) {
Serial.printf("[%lu] [OTA] Update progress: %d / %d\n", millis(), updater.processedSize, updater.totalSize);
updaterProgress = static_cast<float>(updater.processedSize) / static_cast<float>(updater.totalSize);
// Only update every 2% at the most
if (static_cast<int>(updaterProgress * 50) == lastUpdaterPercentage / 2) {
return;
}
lastUpdaterPercentage = static_cast<int>(updaterProgress * 100);
}
const auto pageHeight = renderer.getScreenHeight();
const auto pageWidth = renderer.getScreenWidth();
renderer.clearScreen();
renderer.drawCenteredText(READER_FONT_ID, 10, "Update", true, BOLD);
if (state == CHECKING_FOR_UPDATE) {
renderer.drawCenteredText(UI_FONT_ID, 300, "Checking for update...", true, BOLD);
renderer.displayBuffer();
return;
}
if (state == WAITING_CONFIRMATION) {
renderer.drawCenteredText(UI_FONT_ID, 200, "New update available!", true, BOLD);
renderer.drawText(UI_FONT_ID, 20, 250, "Current Version: " CROSSPOINT_VERSION);
renderer.drawText(UI_FONT_ID, 20, 270, ("New Version: " + updater.getLatestVersion()).c_str());
renderer.drawRect(25, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Cancel")) / 2, pageHeight - 35,
"Cancel");
renderer.drawRect(130, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 130 + (105 - renderer.getTextWidth(UI_FONT_ID, "Update")) / 2, pageHeight - 35,
"Update");
renderer.displayBuffer();
return;
}
if (state == UPDATE_IN_PROGRESS) {
renderer.drawCenteredText(UI_FONT_ID, 310, "Updating...", true, BOLD);
renderer.drawRect(20, 350, pageWidth - 40, 50);
renderer.fillRect(24, 354, static_cast<int>(updaterProgress * static_cast<float>(pageWidth - 44)), 42);
renderer.drawCenteredText(UI_FONT_ID, 420, (std::to_string(static_cast<int>(updaterProgress * 100)) + "%").c_str());
renderer.drawCenteredText(
UI_FONT_ID, 440, (std::to_string(updater.processedSize) + " / " + std::to_string(updater.totalSize)).c_str());
renderer.displayBuffer();
return;
}
if (state == NO_UPDATE) {
renderer.drawCenteredText(UI_FONT_ID, 300, "No update available", true, BOLD);
renderer.displayBuffer();
return;
}
if (state == FAILED) {
renderer.drawCenteredText(UI_FONT_ID, 300, "Update failed", true, BOLD);
renderer.displayBuffer();
return;
}
if (state == FINISHED) {
renderer.drawCenteredText(UI_FONT_ID, 300, "Update complete", true, BOLD);
renderer.drawCenteredText(UI_FONT_ID, 350, "Press and hold power button to turn back on");
renderer.displayBuffer();
state = SHUTTING_DOWN;
return;
}
}
void OtaUpdateActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
if (state == WAITING_CONFIRMATION) {
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
Serial.printf("[%lu] [OTA] New update available, starting download...\n", millis());
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = UPDATE_IN_PROGRESS;
xSemaphoreGive(renderingMutex);
updateRequired = true;
vTaskDelay(10 / portTICK_PERIOD_MS);
const auto res = updater.installUpdate([this](const size_t, const size_t) { updateRequired = true; });
if (res != OtaUpdater::OK) {
Serial.printf("[%lu] [OTA] Update failed: %d\n", millis(), res);
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = FAILED;
xSemaphoreGive(renderingMutex);
updateRequired = true;
return;
}
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = FINISHED;
xSemaphoreGive(renderingMutex);
updateRequired = true;
}
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
goBack();
}
return;
}
if (state == FAILED) {
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
goBack();
}
return;
}
if (state == NO_UPDATE) {
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
goBack();
}
return;
}
if (state == SHUTTING_DOWN) {
ESP.restart();
}
}

View File

@ -0,0 +1,43 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include "activities/ActivityWithSubactivity.h"
#include "network/OtaUpdater.h"
class OtaUpdateActivity : public ActivityWithSubactivity {
enum State {
WIFI_SELECTION,
CHECKING_FOR_UPDATE,
WAITING_CONFIRMATION,
UPDATE_IN_PROGRESS,
NO_UPDATE,
FAILED,
FINISHED,
SHUTTING_DOWN
};
// Can't initialize this to 0 or the first render doesn't happen
static constexpr unsigned int UNINITIALIZED_PERCENTAGE = 111;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
const std::function<void()> goBack;
State state = WIFI_SELECTION;
unsigned int lastUpdaterPercentage = UNINITIALIZED_PERCENTAGE;
OtaUpdater updater;
void onWifiSelectionComplete(bool success);
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render();
public:
explicit OtaUpdateActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& goBack)
: ActivityWithSubactivity("OtaUpdate", renderer, inputManager), goBack(goBack), updater() {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -1,18 +1,26 @@
#include "SettingsActivity.h" #include "SettingsActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "OtaUpdateActivity.h"
#include "config.h" #include "config.h"
// Define the static settings list // Define the static settings list
namespace {
const SettingInfo SettingsActivity::settingsList[settingsCount] = { constexpr int settingsCount = 7;
{"White Sleep Screen", SettingType::TOGGLE, &CrossPointSettings::whiteSleepScreen}, const SettingInfo settingsList[settingsCount] = {
{"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing}, // Should match with SLEEP_SCREEN_MODE
{"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn}, {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
{"Status Bar", SettingType::ENUM, &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}},
{"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}},
{"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}},
{"Landscape Reading", SettingType::TOGGLE, &CrossPointSettings::landscapeReading}, {"Landscape Reading", SettingType::TOGGLE, &CrossPointSettings::landscapeReading},
{"Flip Landscape (swap top/bottom)", SettingType::TOGGLE, &CrossPointSettings::landscapeFlipped}}; {"Flip Landscape (swap top/bottom)", SettingType::TOGGLE, &CrossPointSettings::landscapeFlipped},
{"Check for updates", SettingType::ACTION, nullptr, {}},
};
} // namespace
void SettingsActivity::taskTrampoline(void* param) { void SettingsActivity::taskTrampoline(void* param) {
auto* self = static_cast<SettingsActivity*>(param); auto* self = static_cast<SettingsActivity*>(param);
@ -20,6 +28,8 @@ void SettingsActivity::taskTrampoline(void* param) {
} }
void SettingsActivity::onEnter() { void SettingsActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
// Reset selection to first item // Reset selection to first item
@ -37,6 +47,8 @@ void SettingsActivity::onEnter() {
} }
void SettingsActivity::onExit() { void SettingsActivity::onExit() {
ActivityWithSubactivity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD // Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) { if (displayTaskHandle) {
@ -48,6 +60,11 @@ void SettingsActivity::onExit() {
} }
void SettingsActivity::loop() { void SettingsActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
// Handle actions with early return // Handle actions with early return
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
toggleCurrentSetting(); toggleCurrentSetting();
@ -83,22 +100,35 @@ void SettingsActivity::toggleCurrentSetting() {
const auto& setting = settingsList[selectedSettingIndex]; const auto& setting = settingsList[selectedSettingIndex];
// Only toggle if it's a toggle type and has a value pointer if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {
if (setting.type != SettingType::TOGGLE || setting.valuePtr == nullptr) { // Toggle the boolean value using the member pointer
const bool currentValue = SETTINGS.*(setting.valuePtr);
SETTINGS.*(setting.valuePtr) = !currentValue;
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
} else if (setting.type == SettingType::ACTION) {
if (std::string(setting.name) == "Check for updates") {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new OtaUpdateActivity(renderer, inputManager, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
}
} else {
// Only toggle if it's a toggle type and has a value pointer
return; return;
} }
// Toggle the boolean value using the member pointer
bool currentValue = SETTINGS.*(setting.valuePtr);
SETTINGS.*(setting.valuePtr) = !currentValue;
// Save settings when they change // Save settings when they change
SETTINGS.saveToFile(); SETTINGS.saveToFile();
} }
void SettingsActivity::displayTaskLoop() { void SettingsActivity::displayTaskLoop() {
while (true) { while (true) {
if (updateRequired) { if (updateRequired && !subActivity) {
updateRequired = false; updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
render(); render();
@ -131,13 +161,20 @@ void SettingsActivity::render() const {
// Draw value based on setting type // Draw value based on setting type
if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) {
bool value = SETTINGS.*(settingsList[i].valuePtr); const bool value = SETTINGS.*(settingsList[i].valuePtr);
renderer.drawText(UI_FONT_ID, pageWidth - 80, settingY, value ? "ON" : "OFF"); renderer.drawText(UI_FONT_ID, pageWidth - 80, settingY, value ? "ON" : "OFF");
} else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(settingsList[i].valuePtr);
auto valueText = settingsList[i].enumValues[value];
const auto width = renderer.getTextWidth(UI_FONT_ID, valueText.c_str());
renderer.drawText(UI_FONT_ID, pageWidth - 50 - width, settingY, valueText.c_str());
} }
} }
// Draw help text // Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "Press OK to toggle, BACK to save & exit"); renderer.drawButtonHints(UI_FONT_ID, "« Save", "Toggle", "", "");
renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION),
pageHeight - 30, CROSSPOINT_VERSION);
// Always use standard refresh for settings screen // Always use standard refresh for settings screen
renderer.displayBuffer(); renderer.displayBuffer();

View File

@ -3,35 +3,31 @@
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <freertos/task.h> #include <freertos/task.h>
#include <cstdint>
#include <functional> #include <functional>
#include <string> #include <string>
#include <vector> #include <vector>
#include "../Activity.h" #include "activities/ActivityWithSubactivity.h"
class CrossPointSettings; class CrossPointSettings;
enum class SettingType { TOGGLE }; enum class SettingType { TOGGLE, ENUM, ACTION };
// Structure to hold setting information // Structure to hold setting information
struct SettingInfo { struct SettingInfo {
const char* name; // Display name of the setting const char* name; // Display name of the setting
SettingType type; // Type of setting SettingType type; // Type of setting
uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE) uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE/ENUM)
std::vector<std::string> enumValues;
}; };
class SettingsActivity final : public Activity { class SettingsActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false; bool updateRequired = false;
int selectedSettingIndex = 0; // Currently selected setting int selectedSettingIndex = 0; // Currently selected setting
const std::function<void()> onGoHome; const std::function<void()> onGoHome;
// Static settings list
static constexpr int settingsCount = 5; // Number of settings
static const SettingInfo settingsList[settingsCount];
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void render() const; void render() const;
@ -39,7 +35,7 @@ class SettingsActivity final : public Activity {
public: public:
explicit SettingsActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onGoHome) explicit SettingsActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onGoHome)
: Activity(renderer, inputManager), onGoHome(onGoHome) {} : ActivityWithSubactivity("Settings", renderer, inputManager), onGoHome(onGoHome) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@ -5,6 +5,8 @@
#include "config.h" #include "config.h"
void FullScreenMessageActivity::onEnter() { void FullScreenMessageActivity::onEnter() {
Activity::onEnter();
const auto height = renderer.getLineHeight(UI_FONT_ID); const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (GfxRenderer::getScreenHeight() - height) / 2; const auto top = (GfxRenderer::getScreenHeight() - height) / 2;

View File

@ -16,6 +16,9 @@ class FullScreenMessageActivity final : public Activity {
explicit FullScreenMessageActivity(GfxRenderer& renderer, InputManager& inputManager, std::string text, explicit FullScreenMessageActivity(GfxRenderer& renderer, InputManager& inputManager, std::string text,
const EpdFontStyle style = REGULAR, const EpdFontStyle style = REGULAR,
const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH)
: Activity(renderer, inputManager), text(std::move(text)), style(style), refreshMode(refreshMode) {} : Activity("FullScreenMessage", renderer, inputManager),
text(std::move(text)),
style(style),
refreshMode(refreshMode) {}
void onEnter() override; void onEnter() override;
}; };

View File

@ -10,48 +10,55 @@ const char* const KeyboardEntryActivity::keyboard[NUM_ROWS] = {
// Keyboard layouts - uppercase/symbols // Keyboard layouts - uppercase/symbols
const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"", const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"",
"ZXCVBNM<>?", "^ _____<OK"}; "ZXCVBNM<>?", "SPECIAL ROW"};
KeyboardEntryActivity::KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, void KeyboardEntryActivity::taskTrampoline(void* param) {
const std::string& title, const std::string& initialText, size_t maxLength, auto* self = static_cast<KeyboardEntryActivity*>(param);
bool isPassword) self->displayTaskLoop();
: Activity(renderer, inputManager), title(title), text(initialText), maxLength(maxLength), isPassword(isPassword) {}
void KeyboardEntryActivity::setText(const std::string& newText) {
text = newText;
if (maxLength > 0 && text.length() > maxLength) {
text = text.substr(0, maxLength);
}
} }
void KeyboardEntryActivity::reset(const std::string& newTitle, const std::string& newInitialText) { void KeyboardEntryActivity::displayTaskLoop() {
if (!newTitle.empty()) { while (true) {
title = newTitle; if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
} }
text = newInitialText;
selectedRow = 0;
selectedCol = 0;
shiftActive = false;
complete = false;
cancelled = false;
} }
void KeyboardEntryActivity::onEnter() { void KeyboardEntryActivity::onEnter() {
// Reset state when entering the activity Activity::onEnter();
complete = false;
cancelled = false; renderingMutex = xSemaphoreCreateMutex();
// Trigger first update
updateRequired = true;
xTaskCreate(&KeyboardEntryActivity::taskTrampoline, "KeyboardEntryActivity",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
} }
void KeyboardEntryActivity::onExit() { void KeyboardEntryActivity::onExit() {
// Clean up if needed Activity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
} }
void KeyboardEntryActivity::loop() { int KeyboardEntryActivity::getRowLength(const int row) const {
handleInput();
render(10);
}
int KeyboardEntryActivity::getRowLength(int row) const {
if (row < 0 || row >= NUM_ROWS) return 0; if (row < 0 || row >= NUM_ROWS) return 0;
// Return actual length of each row based on keyboard layout // Return actual length of each row based on keyboard layout
@ -65,7 +72,7 @@ int KeyboardEntryActivity::getRowLength(int row) const {
case 3: case 3:
return 10; // zxcvbnm,./ return 10; // zxcvbnm,./
case 4: case 4:
return 10; // ^, space (5 wide), backspace, OK (2 wide) return 10; // caps (2 wide), space (5 wide), backspace (2 wide), OK
default: default:
return 0; return 0;
} }
@ -82,8 +89,8 @@ char KeyboardEntryActivity::getSelectedChar() const {
void KeyboardEntryActivity::handleKeyPress() { void KeyboardEntryActivity::handleKeyPress() {
// Handle special row (bottom row with shift, space, backspace, done) // Handle special row (bottom row with shift, space, backspace, done)
if (selectedRow == SHIFT_ROW) { if (selectedRow == SPECIAL_ROW) {
if (selectedCol == SHIFT_COL) { if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// Shift toggle // Shift toggle
shiftActive = !shiftActive; shiftActive = !shiftActive;
return; return;
@ -97,7 +104,7 @@ void KeyboardEntryActivity::handleKeyPress() {
return; return;
} }
if (selectedCol == BACKSPACE_COL) { if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) {
// Backspace // Backspace
if (!text.empty()) { if (!text.empty()) {
text.pop_back(); text.pop_back();
@ -107,7 +114,6 @@ void KeyboardEntryActivity::handleKeyPress() {
if (selectedCol >= DONE_COL) { if (selectedCol >= DONE_COL) {
// Done button // Done button
complete = true;
if (onComplete) { if (onComplete) {
onComplete(text); onComplete(text);
} }
@ -116,42 +122,61 @@ void KeyboardEntryActivity::handleKeyPress() {
} }
// Regular character // Regular character
char c = getSelectedChar(); const char c = getSelectedChar();
if (c != '\0' && c != '^' && c != '_' && c != '<') { if (c == '\0') {
if (maxLength == 0 || text.length() < maxLength) { return;
text += c; }
// Auto-disable shift after typing a letter
if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) { if (maxLength == 0 || text.length() < maxLength) {
shiftActive = false; text += c;
} // Auto-disable shift after typing a letter
if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) {
shiftActive = false;
} }
} }
} }
bool KeyboardEntryActivity::handleInput() { void KeyboardEntryActivity::loop() {
if (complete || cancelled) {
return false;
}
bool handled = false;
// Navigation // Navigation
if (inputManager.wasPressed(InputManager::BTN_UP)) { if (inputManager.wasPressed(InputManager::BTN_UP)) {
if (selectedRow > 0) { if (selectedRow > 0) {
selectedRow--; selectedRow--;
// Clamp column to valid range for new row // Clamp column to valid range for new row
int maxCol = getRowLength(selectedRow) - 1; const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol; if (selectedCol > maxCol) selectedCol = maxCol;
} }
handled = true; updateRequired = true;
} else if (inputManager.wasPressed(InputManager::BTN_DOWN)) { }
if (inputManager.wasPressed(InputManager::BTN_DOWN)) {
if (selectedRow < NUM_ROWS - 1) { if (selectedRow < NUM_ROWS - 1) {
selectedRow++; selectedRow++;
int maxCol = getRowLength(selectedRow) - 1; const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol; if (selectedCol > maxCol) selectedCol = maxCol;
} }
handled = true; updateRequired = true;
} else if (inputManager.wasPressed(InputManager::BTN_LEFT)) { }
if (inputManager.wasPressed(InputManager::BTN_LEFT)) {
// Special bottom row case
if (selectedRow == SPECIAL_ROW) {
// Bottom row has special key widths
if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// In shift key, do nothing
} else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) {
// In space bar, move to shift
selectedCol = SHIFT_COL;
} else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) {
// In backspace, move to space
selectedCol = SPACE_COL;
} else if (selectedCol >= DONE_COL) {
// At done button, move to backspace
selectedCol = BACKSPACE_COL;
}
updateRequired = true;
return;
}
if (selectedCol > 0) { if (selectedCol > 0) {
selectedCol--; selectedCol--;
} else if (selectedRow > 0) { } else if (selectedRow > 0) {
@ -159,9 +184,31 @@ bool KeyboardEntryActivity::handleInput() {
selectedRow--; selectedRow--;
selectedCol = getRowLength(selectedRow) - 1; selectedCol = getRowLength(selectedRow) - 1;
} }
handled = true; updateRequired = true;
} else if (inputManager.wasPressed(InputManager::BTN_RIGHT)) { }
int maxCol = getRowLength(selectedRow) - 1;
if (inputManager.wasPressed(InputManager::BTN_RIGHT)) {
const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case
if (selectedRow == SPECIAL_ROW) {
// Bottom row has special key widths
if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// In shift key, move to space
selectedCol = SPACE_COL;
} else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) {
// In space bar, move to backspace
selectedCol = BACKSPACE_COL;
} else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) {
// In backspace, move to done
selectedCol = DONE_COL;
} else if (selectedCol >= DONE_COL) {
// At done button, do nothing
}
updateRequired = true;
return;
}
if (selectedCol < maxCol) { if (selectedCol < maxCol) {
selectedCol++; selectedCol++;
} else if (selectedRow < NUM_ROWS - 1) { } else if (selectedRow < NUM_ROWS - 1) {
@ -169,35 +216,34 @@ bool KeyboardEntryActivity::handleInput() {
selectedRow++; selectedRow++;
selectedCol = 0; selectedCol = 0;
} }
handled = true; updateRequired = true;
} }
// Selection // Selection
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
handleKeyPress(); handleKeyPress();
handled = true; updateRequired = true;
} }
// Cancel // Cancel
if (inputManager.wasPressed(InputManager::BTN_BACK)) { if (inputManager.wasPressed(InputManager::BTN_BACK)) {
cancelled = true;
if (onCancel) { if (onCancel) {
onCancel(); onCancel();
} }
handled = true; updateRequired = true;
} }
return handled;
} }
void KeyboardEntryActivity::render(int startY) const { void KeyboardEntryActivity::render() const {
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = GfxRenderer::getScreenWidth();
renderer.clearScreen();
// Draw title // Draw title
renderer.drawCenteredText(UI_FONT_ID, startY, title.c_str(), true, REGULAR); renderer.drawCenteredText(UI_FONT_ID, startY, title.c_str(), true, REGULAR);
// Draw input field // Draw input field
int inputY = startY + 22; const int inputY = startY + 22;
renderer.drawText(UI_FONT_ID, 10, inputY, "["); renderer.drawText(UI_FONT_ID, 10, inputY, "[");
std::string displayText; std::string displayText;
@ -211,9 +257,9 @@ void KeyboardEntryActivity::render(int startY) const {
displayText += "_"; displayText += "_";
// Truncate if too long for display - use actual character width from font // Truncate if too long for display - use actual character width from font
int charWidth = renderer.getSpaceWidth(UI_FONT_ID); int approxCharWidth = renderer.getSpaceWidth(UI_FONT_ID);
if (charWidth < 1) charWidth = 8; // Fallback to approximate width if (approxCharWidth < 1) approxCharWidth = 8; // Fallback to approximate width
int maxDisplayLen = (pageWidth - 40) / charWidth; const int maxDisplayLen = (pageWidth - 40) / approxCharWidth;
if (displayText.length() > static_cast<size_t>(maxDisplayLen)) { if (displayText.length() > static_cast<size_t>(maxDisplayLen)) {
displayText = "..." + displayText.substr(displayText.length() - maxDisplayLen + 3); displayText = "..." + displayText.substr(displayText.length() - maxDisplayLen + 3);
} }
@ -222,22 +268,22 @@ void KeyboardEntryActivity::render(int startY) const {
renderer.drawText(UI_FONT_ID, pageWidth - 15, inputY, "]"); renderer.drawText(UI_FONT_ID, pageWidth - 15, inputY, "]");
// Draw keyboard - use compact spacing to fit 5 rows on screen // Draw keyboard - use compact spacing to fit 5 rows on screen
int keyboardStartY = inputY + 25; const int keyboardStartY = inputY + 25;
const int keyWidth = 18; constexpr int keyWidth = 18;
const int keyHeight = 18; constexpr int keyHeight = 18;
const int keySpacing = 3; constexpr int keySpacing = 3;
const char* const* layout = shiftActive ? keyboardShift : keyboard; const char* const* layout = shiftActive ? keyboardShift : keyboard;
// Calculate left margin to center the longest row (13 keys) // Calculate left margin to center the longest row (13 keys)
int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing); constexpr int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing);
int leftMargin = (pageWidth - maxRowWidth) / 2; const int leftMargin = (pageWidth - maxRowWidth) / 2;
for (int row = 0; row < NUM_ROWS; row++) { for (int row = 0; row < NUM_ROWS; row++) {
int rowY = keyboardStartY + row * (keyHeight + keySpacing); const int rowY = keyboardStartY + row * (keyHeight + keySpacing);
// Left-align all rows for consistent navigation // Left-align all rows for consistent navigation
int startX = leftMargin; const int startX = leftMargin;
// Handle bottom row (row 4) specially with proper multi-column keys // Handle bottom row (row 4) specially with proper multi-column keys
if (row == 4) { if (row == 4) {
@ -247,64 +293,37 @@ void KeyboardEntryActivity::render(int startY) const {
int currentX = startX; int currentX = startX;
// CAPS key (logical col 0, spans 2 key widths) // CAPS key (logical col 0, spans 2 key widths)
int capsWidth = 2 * keyWidth + keySpacing; const bool capsSelected = (selectedRow == 4 && selectedCol >= SHIFT_COL && selectedCol < SPACE_COL);
bool capsSelected = (selectedRow == 4 && selectedCol == SHIFT_COL); renderItemWithSelector(currentX + 2, rowY, shiftActive ? "CAPS" : "caps", capsSelected);
if (capsSelected) { currentX += 2 * (keyWidth + keySpacing);
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + capsWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 2, rowY, shiftActive ? "CAPS" : "caps");
currentX += capsWidth + keySpacing;
// Space bar (logical cols 2-6, spans 5 key widths) // Space bar (logical cols 2-6, spans 5 key widths)
int spaceWidth = 5 * keyWidth + 4 * keySpacing; const bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL);
bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL); const int spaceTextWidth = renderer.getTextWidth(UI_FONT_ID, "_____");
if (spaceSelected) { const int spaceXWidth = 5 * (keyWidth + keySpacing);
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "["); const int spaceXPos = currentX + (spaceXWidth - spaceTextWidth) / 2;
renderer.drawText(UI_FONT_ID, currentX + spaceWidth - 4, rowY, "]"); renderItemWithSelector(spaceXPos, rowY, "_____", spaceSelected);
} currentX += spaceXWidth;
// Draw centered underscores for space bar
int spaceTextX = currentX + (spaceWidth / 2) - 12;
renderer.drawText(UI_FONT_ID, spaceTextX, rowY, "_____");
currentX += spaceWidth + keySpacing;
// Backspace key (logical col 7, spans 2 key widths) // Backspace key (logical col 7, spans 2 key widths)
int bsWidth = 2 * keyWidth + keySpacing; const bool bsSelected = (selectedRow == 4 && selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL);
bool bsSelected = (selectedRow == 4 && selectedCol == BACKSPACE_COL); renderItemWithSelector(currentX + 2, rowY, "<-", bsSelected);
if (bsSelected) { currentX += 2 * (keyWidth + keySpacing);
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + bsWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 6, rowY, "<-");
currentX += bsWidth + keySpacing;
// OK button (logical col 9, spans 2 key widths) // OK button (logical col 9, spans 2 key widths)
int okWidth = 2 * keyWidth + keySpacing; const bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL);
bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL); renderItemWithSelector(currentX + 2, rowY, "OK", okSelected);
if (okSelected) {
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + okWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 8, rowY, "OK");
} else { } else {
// Regular rows: render each key individually // Regular rows: render each key individually
for (int col = 0; col < getRowLength(row); col++) { for (int col = 0; col < getRowLength(row); col++) {
int keyX = startX + col * (keyWidth + keySpacing);
// Get the character to display // Get the character to display
char c = layout[row][col]; const char c = layout[row][col];
std::string keyLabel(1, c); std::string keyLabel(1, c);
const int charWidth = renderer.getTextWidth(UI_FONT_ID, keyLabel.c_str());
// Draw selection highlight const int keyX = startX + col * (keyWidth + keySpacing) + (keyWidth - charWidth) / 2;
bool isSelected = (row == selectedRow && col == selectedCol); const bool isSelected = row == selectedRow && col == selectedCol;
renderItemWithSelector(keyX, rowY, keyLabel.c_str(), isSelected);
if (isSelected) {
renderer.drawText(UI_FONT_ID, keyX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, keyX + keyWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, keyX + 2, rowY, keyLabel.c_str());
} }
} }
} }
@ -312,4 +331,15 @@ void KeyboardEntryActivity::render(int startY) const {
// Draw help text at absolute bottom of screen (consistent with other screens) // Draw help text at absolute bottom of screen (consistent with other screens)
const auto pageHeight = GfxRenderer::getScreenHeight(); const auto pageHeight = GfxRenderer::getScreenHeight();
renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK"); renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK");
renderer.displayBuffer();
}
void KeyboardEntryActivity::renderItemWithSelector(const int x, const int y, const char* item,
const bool isSelected) const {
if (isSelected) {
const int itemWidth = renderer.getTextWidth(UI_FONT_ID, item);
renderer.drawText(UI_FONT_ID, x - 6, y, "[");
renderer.drawText(UI_FONT_ID, x + itemWidth, y, "]");
}
renderer.drawText(UI_FONT_ID, x, y, item);
} }

View File

@ -1,9 +1,13 @@
#pragma once #pragma once
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h> #include <InputManager.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional> #include <functional>
#include <string> #include <string>
#include <utility>
#include "../Activity.h" #include "../Activity.h"
@ -30,58 +34,24 @@ class KeyboardEntryActivity : public Activity {
* @param inputManager Reference to InputManager for handling input * @param inputManager Reference to InputManager for handling input
* @param title Title to display above the keyboard * @param title Title to display above the keyboard
* @param initialText Initial text to show in the input field * @param initialText Initial text to show in the input field
* @param startY Y position to start rendering the keyboard
* @param maxLength Maximum length of input text (0 for unlimited) * @param maxLength Maximum length of input text (0 for unlimited)
* @param isPassword If true, display asterisks instead of actual characters * @param isPassword If true, display asterisks instead of actual characters
* @param onComplete Callback invoked when input is complete
* @param onCancel Callback invoked when input is cancelled
*/ */
KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, const std::string& title = "Enter Text", explicit KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, std::string title = "Enter Text",
const std::string& initialText = "", size_t maxLength = 0, bool isPassword = false); std::string initialText = "", const int startY = 10, const size_t maxLength = 0,
const bool isPassword = false, OnCompleteCallback onComplete = nullptr,
/** OnCancelCallback onCancel = nullptr)
* Handle button input. Call this in your main loop. : Activity("KeyboardEntry", renderer, inputManager),
* @return true if input was handled, false otherwise title(std::move(title)),
*/ text(std::move(initialText)),
bool handleInput(); startY(startY),
maxLength(maxLength),
/** isPassword(isPassword),
* Render the keyboard at the specified Y position. onComplete(std::move(onComplete)),
* @param startY Y-coordinate where keyboard rendering starts (default 10) onCancel(std::move(onCancel)) {}
*/
void render(int startY = 10) const;
/**
* Get the current text entered by the user.
*/
const std::string& getText() const { return text; }
/**
* Set the current text.
*/
void setText(const std::string& newText);
/**
* Check if the user has completed text entry (pressed OK on Done).
*/
bool isComplete() const { return complete; }
/**
* Check if the user has cancelled text entry.
*/
bool isCancelled() const { return cancelled; }
/**
* Reset the keyboard state for reuse.
*/
void reset(const std::string& newTitle = "", const std::string& newInitialText = "");
/**
* Set callback for when input is complete.
*/
void setOnComplete(OnCompleteCallback callback) { onComplete = callback; }
/**
* Set callback for when input is cancelled.
*/
void setOnCancel(OnCancelCallback callback) { onCancel = callback; }
// Activity overrides // Activity overrides
void onEnter() override; void onEnter() override;
@ -90,16 +60,18 @@ class KeyboardEntryActivity : public Activity {
private: private:
std::string title; std::string title;
int startY;
std::string text; std::string text;
size_t maxLength; size_t maxLength;
bool isPassword; bool isPassword;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
// Keyboard state // Keyboard state
int selectedRow = 0; int selectedRow = 0;
int selectedCol = 0; int selectedCol = 0;
bool shiftActive = false; bool shiftActive = false;
bool complete = false;
bool cancelled = false;
// Callbacks // Callbacks
OnCompleteCallback onComplete; OnCompleteCallback onComplete;
@ -112,16 +84,17 @@ class KeyboardEntryActivity : public Activity {
static const char* const keyboardShift[NUM_ROWS]; static const char* const keyboardShift[NUM_ROWS];
// Special key positions (bottom row) // Special key positions (bottom row)
static constexpr int SHIFT_ROW = 4; static constexpr int SPECIAL_ROW = 4;
static constexpr int SHIFT_COL = 0; static constexpr int SHIFT_COL = 0;
static constexpr int SPACE_ROW = 4;
static constexpr int SPACE_COL = 2; static constexpr int SPACE_COL = 2;
static constexpr int BACKSPACE_ROW = 4;
static constexpr int BACKSPACE_COL = 7; static constexpr int BACKSPACE_COL = 7;
static constexpr int DONE_ROW = 4;
static constexpr int DONE_COL = 9; static constexpr int DONE_COL = 9;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
char getSelectedChar() const; char getSelectedChar() const;
void handleKeyPress(); void handleKeyPress();
int getRowLength(int row) const; int getRowLength(int row) const;
void render() const;
void renderItemWithSelector(int x, int y, const char* item, bool isSelected) const;
}; };

View File

@ -26,4 +26,4 @@
* "./lib/EpdFont/builtinFonts/pixelarial14.h", * "./lib/EpdFont/builtinFonts/pixelarial14.h",
* ].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)' * ].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
*/ */
#define SMALL_FONT_ID (-139796914) #define SMALL_FONT_ID 1482513144

View File

@ -1,251 +0,0 @@
<div class="card">
<p style="text-align: center; color: #95a5a6; margin: 0;">
CrossPoint E-Reader • Open Source
</p>
</div>
<!-- Upload Modal -->
<div class="modal-overlay" id="uploadModal">
<div class="modal">
<button class="modal-close" onclick="closeUploadModal()">&times;</button>
<h3>📤 Upload eBook</h3>
<div class="upload-form">
<p class="file-info">Select an .epub file to upload to <strong id="uploadPathDisplay"></strong></p>
<input type="file" id="fileInput" accept=".epub" onchange="validateFile()">
<button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button>
<div id="progress-container">
<div id="progress-bar"><div id="progress-fill"></div></div>
<div id="progress-text"></div>
</div>
</div>
</div>
</div>
<!-- New Folder Modal -->
<div class="modal-overlay" id="folderModal">
<div class="modal">
<button class="modal-close" onclick="closeFolderModal()">&times;</button>
<h3>📁 New Folder</h3>
<div class="folder-form">
<p class="file-info">Create a new folder in <strong id="folderPathDisplay"></strong></p>
<input type="text" id="folderName" class="folder-input" placeholder="Folder name...">
<button class="folder-btn" onclick="createFolder()">Create Folder</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal-overlay" id="deleteModal">
<div class="modal">
<button class="modal-close" onclick="closeDeleteModal()">&times;</button>
<h3>🗑️ Delete Item</h3>
<div class="folder-form">
<p class="delete-warning">⚠️ This action cannot be undone!</p>
<p class="file-info">Are you sure you want to delete:</p>
<p class="delete-item-name" id="deleteItemName"></p>
<input type="hidden" id="deleteItemPath">
<input type="hidden" id="deleteItemType">
<button class="delete-btn-confirm" onclick="confirmDelete()">Delete</button>
<button class="delete-btn-cancel" onclick="closeDeleteModal()">Cancel</button>
</div>
</div>
</div>
<script>
// Modal functions
function openUploadModal() {
const currentPath = document.getElementById('currentPath').value;
document.getElementById('uploadPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
document.getElementById('uploadModal').classList.add('open');
}
function closeUploadModal() {
document.getElementById('uploadModal').classList.remove('open');
document.getElementById('fileInput').value = '';
document.getElementById('uploadBtn').disabled = true;
document.getElementById('progress-container').style.display = 'none';
document.getElementById('progress-fill').style.width = '0%';
document.getElementById('progress-fill').style.backgroundColor = '#27ae60';
}
function openFolderModal() {
const currentPath = document.getElementById('currentPath').value;
document.getElementById('folderPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
document.getElementById('folderModal').classList.add('open');
document.getElementById('folderName').value = '';
}
function closeFolderModal() {
document.getElementById('folderModal').classList.remove('open');
}
// Close modals when clicking overlay
document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
overlay.classList.remove('open');
}
});
});
function validateFile() {
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const file = fileInput.files[0];
if (file) {
const fileName = file.name.toLowerCase();
if (!fileName.endsWith('.epub')) {
alert('Only .epub files are allowed!');
fileInput.value = '';
uploadBtn.disabled = true;
return;
}
uploadBtn.disabled = false;
} else {
uploadBtn.disabled = true;
}
}
function uploadFile() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
const currentPath = document.getElementById('currentPath').value;
if (!file) {
alert('Please select a file first!');
return;
}
const fileName = file.name.toLowerCase();
if (!fileName.endsWith('.epub')) {
alert('Only .epub files are allowed!');
return;
}
const formData = new FormData();
formData.append('file', file);
const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const uploadBtn = document.getElementById('uploadBtn');
progressContainer.style.display = 'block';
uploadBtn.disabled = true;
const xhr = new XMLHttpRequest();
// Include path as query parameter since multipart form data doesn't make
// form fields available until after file upload completes
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = percent + '%';
progressText.textContent = 'Uploading: ' + percent + '%';
}
};
xhr.onload = function() {
if (xhr.status === 200) {
progressText.textContent = 'Upload complete!';
setTimeout(function() {
window.location.reload();
}, 1000);
} else {
progressText.textContent = 'Upload failed: ' + xhr.responseText;
progressFill.style.backgroundColor = '#e74c3c';
uploadBtn.disabled = false;
}
};
xhr.onerror = function() {
progressText.textContent = 'Upload failed - network error';
progressFill.style.backgroundColor = '#e74c3c';
uploadBtn.disabled = false;
};
xhr.send(formData);
}
function createFolder() {
const folderName = document.getElementById('folderName').value.trim();
const currentPath = document.getElementById('currentPath').value;
if (!folderName) {
alert('Please enter a folder name!');
return;
}
// Validate folder name (no special characters except underscore and hyphen)
const validName = /^[a-zA-Z0-9_\-]+$/.test(folderName);
if (!validName) {
alert('Folder name can only contain letters, numbers, underscores, and hyphens.');
return;
}
const formData = new FormData();
formData.append('name', folderName);
formData.append('path', currentPath);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/mkdir', true);
xhr.onload = function() {
if (xhr.status === 200) {
window.location.reload();
} else {
alert('Failed to create folder: ' + xhr.responseText);
}
};
xhr.onerror = function() {
alert('Failed to create folder - network error');
};
xhr.send(formData);
}
// Delete functions
function openDeleteModal(name, path, isFolder) {
document.getElementById('deleteItemName').textContent = (isFolder ? '📁 ' : '📄 ') + name;
document.getElementById('deleteItemPath').value = path;
document.getElementById('deleteItemType').value = isFolder ? 'folder' : 'file';
document.getElementById('deleteModal').classList.add('open');
}
function closeDeleteModal() {
document.getElementById('deleteModal').classList.remove('open');
}
function confirmDelete() {
const path = document.getElementById('deleteItemPath').value;
const itemType = document.getElementById('deleteItemType').value;
const formData = new FormData();
formData.append('path', path);
formData.append('type', itemType);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/delete', true);
xhr.onload = function() {
if (xhr.status === 200) {
window.location.reload();
} else {
alert('Failed to delete: ' + xhr.responseText);
closeDeleteModal();
}
};
xhr.onerror = function() {
alert('Failed to delete - network error');
closeDeleteModal();
};
xhr.send(formData);
}
</script>
</body>
</html>

View File

@ -1,472 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CrossPoint Reader - Files</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
h1 {
color: #2c3e50;
margin-bottom: 5px;
}
h2 {
color: #34495e;
margin-top: 0;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #3498db;
}
.page-header-left {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.breadcrumb-inline {
color: #7f8c8d;
font-size: 1.1em;
}
.breadcrumb-inline a {
color: #3498db;
text-decoration: none;
}
.breadcrumb-inline a:hover {
text-decoration: underline;
}
.breadcrumb-inline .sep {
margin: 0 6px;
color: #bdc3c7;
}
.breadcrumb-inline .current {
color: #2c3e50;
font-weight: 500;
}
.nav-links {
margin: 20px 0;
}
.nav-links a {
display: inline-block;
padding: 10px 20px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
margin-right: 10px;
}
.nav-links a:hover {
background-color: #2980b9;
}
/* Action buttons */
.action-buttons {
display: flex;
gap: 10px;
}
.action-btn {
color: white;
padding: 10px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.95em;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.upload-action-btn {
background-color: #27ae60;
}
.upload-action-btn:hover {
background-color: #219a52;
}
.folder-action-btn {
background-color: #f39c12;
}
.folder-action-btn:hover {
background-color: #d68910;
}
/* Upload modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
justify-content: center;
align-items: center;
}
.modal-overlay.open {
display: flex;
}
.modal {
background: white;
border-radius: 8px;
padding: 25px;
max-width: 450px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal h3 {
margin: 0 0 15px 0;
color: #2c3e50;
}
.modal-close {
float: right;
background: none;
border: none;
font-size: 1.5em;
cursor: pointer;
color: #7f8c8d;
line-height: 1;
}
.modal-close:hover {
color: #2c3e50;
}
.file-table {
width: 100%;
border-collapse: collapse;
}
.file-table th,
.file-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
.file-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #7f8c8d;
}
.file-table tr:hover {
background-color: #f8f9fa;
}
.epub-file {
background-color: #e8f6e9 !important;
}
.epub-file:hover {
background-color: #d4edda !important;
}
.folder-row {
background-color: #fff9e6 !important;
}
.folder-row:hover {
background-color: #fff3cd !important;
}
.epub-badge {
display: inline-block;
padding: 2px 8px;
background-color: #27ae60;
color: white;
border-radius: 10px;
font-size: 0.75em;
margin-left: 8px;
}
.folder-badge {
display: inline-block;
padding: 2px 8px;
background-color: #f39c12;
color: white;
border-radius: 10px;
font-size: 0.75em;
margin-left: 8px;
}
.file-icon {
margin-right: 8px;
}
.folder-link {
color: #2c3e50;
text-decoration: none;
cursor: pointer;
}
.folder-link:hover {
color: #3498db;
text-decoration: underline;
}
.upload-form {
margin-top: 10px;
}
.upload-form input[type="file"] {
margin: 10px 0;
width: 100%;
}
.upload-btn {
background-color: #27ae60;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
}
.upload-btn:hover {
background-color: #219a52;
}
.upload-btn:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
.file-info {
color: #7f8c8d;
font-size: 0.85em;
margin: 8px 0;
}
.no-files {
text-align: center;
color: #95a5a6;
padding: 40px;
font-style: italic;
}
.message {
padding: 15px;
border-radius: 4px;
margin: 15px 0;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.contents-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.contents-title {
font-size: 1.1em;
font-weight: 600;
color: #34495e;
margin: 0;
}
.summary-inline {
color: #7f8c8d;
font-size: 0.9em;
}
#progress-container {
display: none;
margin-top: 10px;
}
#progress-bar {
width: 100%;
height: 20px;
background-color: #e0e0e0;
border-radius: 10px;
overflow: hidden;
}
#progress-fill {
height: 100%;
background-color: #27ae60;
width: 0%;
transition: width 0.3s;
}
#progress-text {
text-align: center;
margin-top: 5px;
font-size: 0.9em;
color: #7f8c8d;
}
.folder-form {
margin-top: 10px;
}
.folder-input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1em;
margin-bottom: 10px;
box-sizing: border-box;
}
.folder-btn {
background-color: #f39c12;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
}
.folder-btn:hover {
background-color: #d68910;
}
/* Delete button styles */
.delete-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.1em;
padding: 4px 8px;
border-radius: 4px;
color: #95a5a6;
transition: all 0.15s;
}
.delete-btn:hover {
background-color: #fee;
color: #e74c3c;
}
.actions-col {
width: 60px;
text-align: center;
}
/* Delete modal */
.delete-warning {
color: #e74c3c;
font-weight: 600;
margin: 10px 0;
}
.delete-item-name {
font-weight: 600;
color: #2c3e50;
word-break: break-all;
}
.delete-btn-confirm {
background-color: #e74c3c;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
}
.delete-btn-confirm:hover {
background-color: #c0392b;
}
.delete-btn-cancel {
background-color: #95a5a6;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
margin-top: 10px;
}
.delete-btn-cancel:hover {
background-color: #7f8c8d;
}
/* Mobile responsive styles */
@media (max-width: 600px) {
body {
padding: 10px;
font-size: 14px;
}
.card {
padding: 12px;
margin: 10px 0;
}
.page-header {
gap: 10px;
margin-bottom: 12px;
padding-bottom: 10px;
}
.page-header-left {
gap: 8px;
}
h1 {
font-size: 1.3em;
}
.breadcrumb-inline {
font-size: 0.95em;
}
.nav-links a {
padding: 8px 12px;
margin-right: 6px;
font-size: 0.9em;
}
.action-buttons {
gap: 6px;
}
.action-btn {
padding: 8px 10px;
font-size: 0.85em;
}
.file-table th,
.file-table td {
padding: 8px 6px;
font-size: 0.9em;
}
.file-table th {
font-size: 0.85em;
}
.file-icon {
margin-right: 4px;
}
.epub-badge,
.folder-badge {
padding: 2px 5px;
font-size: 0.65em;
margin-left: 4px;
}
.contents-header {
margin-bottom: 8px;
flex-wrap: wrap;
gap: 4px;
}
.contents-title {
font-size: 1em;
}
.summary-inline {
font-size: 0.8em;
}
.modal {
padding: 15px;
}
.modal h3 {
font-size: 1.1em;
}
.actions-col {
width: 40px;
}
.delete-btn {
font-size: 1em;
padding: 2px 4px;
}
.no-files {
padding: 20px;
font-size: 0.9em;
}
}
</style>
</head>
<body>
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">File Manager</a>
</div>
</body>
</html>

View File

@ -5,7 +5,6 @@
#include <InputManager.h> #include <InputManager.h>
#include <SD.h> #include <SD.h>
#include <SPI.h> #include <SPI.h>
#include <WiFi.h>
#include <builtinFonts/bookerly_2b.h> #include <builtinFonts/bookerly_2b.h>
#include <builtinFonts/bookerly_bold_2b.h> #include <builtinFonts/bookerly_bold_2b.h>
#include <builtinFonts/bookerly_bold_italic_2b.h> #include <builtinFonts/bookerly_bold_italic_2b.h>
@ -61,6 +60,9 @@ EpdFontFamily ubuntuFontFamily(&ubuntu10Font, &ubuntuBold10Font);
// Auto-sleep timeout (10 minutes of inactivity) // Auto-sleep timeout (10 minutes of inactivity)
constexpr unsigned long AUTO_SLEEP_TIMEOUT_MS = 10 * 60 * 1000; constexpr unsigned long AUTO_SLEEP_TIMEOUT_MS = 10 * 60 * 1000;
// measurement of power button press duration calibration value
unsigned long t1 = 0;
unsigned long t2 = 0;
void exitActivity() { void exitActivity() {
if (currentActivity) { if (currentActivity) {
@ -80,6 +82,10 @@ void verifyWakeupLongPress() {
// Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration() // Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration()
const auto start = millis(); const auto start = millis();
bool abort = false; bool abort = false;
// It takes us some time to wake up from deep sleep, so we need to subtract that from the duration
uint16_t calibration = 25;
uint16_t calibratedPressDuration =
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
inputManager.update(); inputManager.update();
// Verify the user has actually pressed // Verify the user has actually pressed
@ -88,13 +94,13 @@ void verifyWakeupLongPress() {
inputManager.update(); inputManager.update();
} }
t2 = millis();
if (inputManager.isPressed(InputManager::BTN_POWER)) { if (inputManager.isPressed(InputManager::BTN_POWER)) {
do { do {
delay(10); delay(10);
inputManager.update(); inputManager.update();
} while (inputManager.isPressed(InputManager::BTN_POWER) && } while (inputManager.isPressed(InputManager::BTN_POWER) && inputManager.getHeldTime() < calibratedPressDuration);
inputManager.getHeldTime() < SETTINGS.getPowerButtonDuration()); abort = inputManager.getHeldTime() < calibratedPressDuration;
abort = inputManager.getHeldTime() < SETTINGS.getPowerButtonDuration();
} else { } else {
abort = true; abort = true;
} }
@ -120,14 +126,12 @@ void enterDeepSleep() {
exitActivity(); exitActivity();
enterNewActivity(new SleepActivity(renderer, inputManager)); enterNewActivity(new SleepActivity(renderer, inputManager));
Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis());
delay(1000); // Allow Serial buffer to empty and display to update
// Enable Wakeup on LOW (button press)
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
einkDisplay.deepSleep(); einkDisplay.deepSleep();
Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1);
Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis());
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
waitForPowerRelease();
// Enter Deep Sleep // Enter Deep Sleep
esp_deep_sleep_start(); esp_deep_sleep_start();
} }
@ -138,6 +142,7 @@ void onGoToReader(const std::string& initialEpubPath) {
enterNewActivity(new ReaderActivity(renderer, inputManager, initialEpubPath, onGoHome)); enterNewActivity(new ReaderActivity(renderer, inputManager, initialEpubPath, onGoHome));
} }
void onGoToReaderHome() { onGoToReader(std::string()); } void onGoToReaderHome() { onGoToReader(std::string()); }
void onContinueReading() { onGoToReader(APP_STATE.openEpubPath); }
void onGoToFileTransfer() { void onGoToFileTransfer() {
exitActivity(); exitActivity();
@ -151,11 +156,27 @@ void onGoToSettings() {
void onGoHome() { void onGoHome() {
exitActivity(); exitActivity();
enterNewActivity(new HomeActivity(renderer, inputManager, onGoToReaderHome, onGoToSettings, onGoToFileTransfer)); enterNewActivity(new HomeActivity(renderer, inputManager, onContinueReading, onGoToReaderHome, onGoToSettings,
onGoToFileTransfer));
}
void setupDisplayAndFonts() {
einkDisplay.begin();
Serial.printf("[%lu] [ ] Display initialized\n", millis());
renderer.insertFont(READER_FONT_ID, bookerlyFontFamily);
renderer.insertFont(UI_FONT_ID, ubuntuFontFamily);
renderer.insertFont(SMALL_FONT_ID, smallFontFamily);
Serial.printf("[%lu] [ ] Fonts setup\n", millis());
} }
void setup() { void setup() {
Serial.begin(115200); t1 = millis();
// Only start serial if USB connected
pinMode(UART0_RXD, INPUT);
if (digitalRead(UART0_RXD) == HIGH) {
Serial.begin(115200);
}
Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis());
@ -167,8 +188,10 @@ void setup() {
SPI.begin(EPD_SCLK, SD_SPI_MISO, EPD_MOSI, EPD_CS); SPI.begin(EPD_SCLK, SD_SPI_MISO, EPD_MOSI, EPD_CS);
// SD Card Initialization // SD Card Initialization
if (!SD.begin(SD_SPI_CS, SPI, SPI_FQ)) { // We need 6 open files concurrently when parsing a new chapter
if (!SD.begin(SD_SPI_CS, SPI, SPI_FQ, "/sd", 6)) {
Serial.printf("[%lu] [ ] SD card initialization failed\n", millis()); Serial.printf("[%lu] [ ] SD card initialization failed\n", millis());
setupDisplayAndFonts();
exitActivity(); exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "SD card error", BOLD)); enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "SD card error", BOLD));
return; return;
@ -179,14 +202,7 @@ void setup() {
// verify power button press duration after we've read settings. // verify power button press duration after we've read settings.
verifyWakeupLongPress(); verifyWakeupLongPress();
// Initialize display setupDisplayAndFonts();
einkDisplay.begin();
Serial.printf("[%lu] [ ] Display initialized\n", millis());
renderer.insertFont(READER_FONT_ID, bookerlyFontFamily);
renderer.insertFont(UI_FONT_ID, ubuntuFontFamily);
renderer.insertFont(SMALL_FONT_ID, smallFontFamily);
Serial.printf("[%lu] [ ] Fonts setup\n", millis());
exitActivity(); exitActivity();
enterNewActivity(new BootActivity(renderer, inputManager)); enterNewActivity(new BootActivity(renderer, inputManager));
@ -195,7 +211,11 @@ void setup() {
if (APP_STATE.openEpubPath.empty()) { if (APP_STATE.openEpubPath.empty()) {
onGoHome(); onGoHome();
} else { } else {
onGoToReader(APP_STATE.openEpubPath); // Clear app state to avoid getting into a boot loop if the epub doesn't load
const auto path = APP_STATE.openEpubPath;
APP_STATE.openEpubPath = "";
APP_STATE.saveToFile();
onGoToReader(path);
} }
// Ensure we're not still holding the power button before leaving setup // Ensure we're not still holding the power button before leaving setup
@ -203,20 +223,18 @@ void setup() {
} }
void loop() { void loop() {
static unsigned long lastLoopTime = 0;
static unsigned long maxLoopDuration = 0; static unsigned long maxLoopDuration = 0;
const unsigned long loopStartTime = millis();
unsigned long loopStartTime = millis();
static unsigned long lastMemPrint = 0; static unsigned long lastMemPrint = 0;
inputManager.update();
if (Serial && millis() - lastMemPrint >= 10000) { if (Serial && millis() - lastMemPrint >= 10000) {
Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(), Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(),
ESP.getHeapSize(), ESP.getMinFreeHeap()); ESP.getHeapSize(), ESP.getMinFreeHeap());
lastMemPrint = millis(); lastMemPrint = millis();
} }
inputManager.update();
// Check for any user activity (button press or release) // Check for any user activity (button press or release)
static unsigned long lastActivityTime = millis(); static unsigned long lastActivityTime = millis();
if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased()) { if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased()) {
@ -230,20 +248,20 @@ void loop() {
return; return;
} }
if (inputManager.wasReleased(InputManager::BTN_POWER) && if (inputManager.isPressed(InputManager::BTN_POWER) &&
inputManager.getHeldTime() > SETTINGS.getPowerButtonDuration()) { inputManager.getHeldTime() > SETTINGS.getPowerButtonDuration()) {
enterDeepSleep(); enterDeepSleep();
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
return; return;
} }
unsigned long activityStartTime = millis(); const unsigned long activityStartTime = millis();
if (currentActivity) { if (currentActivity) {
currentActivity->loop(); currentActivity->loop();
} }
unsigned long activityDuration = millis() - activityStartTime; const unsigned long activityDuration = millis() - activityStartTime;
unsigned long loopDuration = millis() - loopStartTime; const unsigned long loopDuration = millis() - loopStartTime;
if (loopDuration > maxLoopDuration) { if (loopDuration > maxLoopDuration) {
maxLoopDuration = loopDuration; maxLoopDuration = loopDuration;
if (maxLoopDuration > 50) { if (maxLoopDuration > 50) {
@ -252,8 +270,6 @@ void loop() {
} }
} }
lastLoopTime = loopStartTime;
// Add delay at the end of the loop to prevent tight spinning // Add delay at the end of the loop to prevent tight spinning
// When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response // When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response
// Otherwise, use longer delay to save power // Otherwise, use longer delay to save power

View File

@ -1,53 +1,20 @@
#include "CrossPointWebServer.h" #include "CrossPointWebServer.h"
#include <ArduinoJson.h>
#include <FsHelpers.h>
#include <SD.h> #include <SD.h>
#include <WiFi.h> #include <WiFi.h>
#include <algorithm> #include <algorithm>
#include "config.h" #include "html/FilesPageHtml.generated.h"
#include "html/FilesPageFooterHtml.generated.h"
#include "html/FilesPageHeaderHtml.generated.h"
#include "html/HomePageHtml.generated.h" #include "html/HomePageHtml.generated.h"
namespace { namespace {
// Folders/files to hide from the web interface file browser // Folders/files to hide from the web interface file browser
// Note: Items starting with "." are automatically hidden // Note: Items starting with "." are automatically hidden
const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"};
const size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]);
// Helper function to escape HTML special characters to prevent XSS
String escapeHtml(const String& input) {
String output;
output.reserve(input.length() * 1.1); // Pre-allocate with some extra space
for (size_t i = 0; i < input.length(); i++) {
char c = input.charAt(i);
switch (c) {
case '&':
output += "&amp;";
break;
case '<':
output += "&lt;";
break;
case '>':
output += "&gt;";
break;
case '"':
output += "&quot;";
break;
case '\'':
output += "&#39;";
break;
default:
output += c;
break;
}
}
return output;
}
} // namespace } // namespace
// File listing page template - now using generated headers: // File listing page template - now using generated headers:
@ -64,15 +31,25 @@ void CrossPointWebServer::begin() {
return; return;
} }
if (WiFi.status() != WL_CONNECTED) { // Check if we have a valid network connection (either STA connected or AP mode)
Serial.printf("[%lu] [WEB] Cannot start webserver - WiFi not connected\n", millis()); const wifi_mode_t wifiMode = WiFi.getMode();
const bool isStaConnected = (wifiMode & WIFI_MODE_STA) && (WiFi.status() == WL_CONNECTED);
const bool isInApMode = (wifiMode & WIFI_MODE_AP) && (WiFi.softAPgetStationNum() >= 0); // AP is running
if (!isStaConnected && !isInApMode) {
Serial.printf("[%lu] [WEB] Cannot start webserver - no valid network (mode=%d, status=%d)\n", millis(), wifiMode,
WiFi.status());
return; return;
} }
// Store AP mode flag for later use (e.g., in handleStatus)
apMode = isInApMode;
Serial.printf("[%lu] [WEB] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WEB] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap());
Serial.printf("[%lu] [WEB] Network mode: %s\n", millis(), apMode ? "AP" : "STA");
Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port); Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port);
server = new WebServer(port); server.reset(new WebServer(port));
Serial.printf("[%lu] [WEB] [MEM] Free heap after WebServer allocation: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WEB] [MEM] Free heap after WebServer allocation: %d bytes\n", millis(), ESP.getFreeHeap());
if (!server) { if (!server) {
@ -82,33 +59,38 @@ void CrossPointWebServer::begin() {
// Setup routes // Setup routes
Serial.printf("[%lu] [WEB] Setting up routes...\n", millis()); Serial.printf("[%lu] [WEB] Setting up routes...\n", millis());
server->on("/", HTTP_GET, [this]() { handleRoot(); }); server->on("/", HTTP_GET, [this] { handleRoot(); });
server->on("/status", HTTP_GET, [this]() { handleStatus(); }); server->on("/files", HTTP_GET, [this] { handleFileList(); });
server->on("/files", HTTP_GET, [this]() { handleFileList(); });
server->on("/api/status", HTTP_GET, [this] { handleStatus(); });
server->on("/api/files", HTTP_GET, [this] { handleFileListData(); });
// Upload endpoint with special handling for multipart form data // Upload endpoint with special handling for multipart form data
server->on("/upload", HTTP_POST, [this]() { handleUploadPost(); }, [this]() { handleUpload(); }); server->on("/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); });
// Create folder endpoint // Create folder endpoint
server->on("/mkdir", HTTP_POST, [this]() { handleCreateFolder(); }); server->on("/mkdir", HTTP_POST, [this] { handleCreateFolder(); });
// Delete file/folder endpoint // Delete file/folder endpoint
server->on("/delete", HTTP_POST, [this]() { handleDelete(); }); server->on("/delete", HTTP_POST, [this] { handleDelete(); });
server->onNotFound([this]() { handleNotFound(); }); server->onNotFound([this] { handleNotFound(); });
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
server->begin(); server->begin();
running = true; running = true;
Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port);
Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), WiFi.localIP().toString().c_str()); // Show the correct IP based on network mode
const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString();
Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str());
Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap());
} }
void CrossPointWebServer::stop() { void CrossPointWebServer::stop() {
if (!running || !server) { if (!running || !server) {
Serial.printf("[%lu] [WEB] stop() called but already stopped (running=%d, server=%p)\n", millis(), running, server); Serial.printf("[%lu] [WEB] stop() called but already stopped (running=%d, server=%p)\n", millis(), running,
server.get());
return; return;
} }
@ -128,9 +110,7 @@ void CrossPointWebServer::stop() {
delay(50); delay(50);
Serial.printf("[%lu] [WEB] Waited 50ms before deleting server\n", millis()); Serial.printf("[%lu] [WEB] Waited 50ms before deleting server\n", millis());
delete server; server.reset();
server = nullptr;
Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis()); Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis());
Serial.printf("[%lu] [WEB] [MEM] Free heap after delete server: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WEB] [MEM] Free heap after delete server: %d bytes\n", millis(), ESP.getFreeHeap());
@ -139,7 +119,7 @@ void CrossPointWebServer::stop() {
Serial.printf("[%lu] [WEB] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WEB] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap());
} }
void CrossPointWebServer::handleClient() { void CrossPointWebServer::handleClient() const {
static unsigned long lastDebugPrint = 0; static unsigned long lastDebugPrint = 0;
// Check running flag FIRST before accessing server // Check running flag FIRST before accessing server
@ -162,29 +142,26 @@ void CrossPointWebServer::handleClient() {
server->handleClient(); server->handleClient();
} }
void CrossPointWebServer::handleRoot() { void CrossPointWebServer::handleRoot() const {
String html = HomePageHtml; server->send(200, "text/html", HomePageHtml);
// Replace placeholders with actual values
html.replace("%VERSION%", CROSSPOINT_VERSION);
html.replace("%IP_ADDRESS%", WiFi.localIP().toString());
html.replace("%FREE_HEAP%", String(ESP.getFreeHeap()));
server->send(200, "text/html", html);
Serial.printf("[%lu] [WEB] Served root page\n", millis()); Serial.printf("[%lu] [WEB] Served root page\n", millis());
} }
void CrossPointWebServer::handleNotFound() { void CrossPointWebServer::handleNotFound() const {
String message = "404 Not Found\n\n"; String message = "404 Not Found\n\n";
message += "URI: " + server->uri() + "\n"; message += "URI: " + server->uri() + "\n";
server->send(404, "text/plain", message); server->send(404, "text/plain", message);
} }
void CrossPointWebServer::handleStatus() { void CrossPointWebServer::handleStatus() const {
// Get correct IP based on AP vs STA mode
const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString();
String json = "{"; String json = "{";
json += "\"version\":\"" + String(CROSSPOINT_VERSION) + "\","; json += "\"version\":\"" + String(CROSSPOINT_VERSION) + "\",";
json += "\"ip\":\"" + WiFi.localIP().toString() + "\","; json += "\"ip\":\"" + ipAddr + "\",";
json += "\"rssi\":" + String(WiFi.RSSI()) + ","; json += "\"mode\":\"" + String(apMode ? "AP" : "STA") + "\",";
json += "\"rssi\":" + String(apMode ? 0 : WiFi.RSSI()) + ","; // RSSI not applicable in AP mode
json += "\"freeHeap\":" + String(ESP.getFreeHeap()) + ","; json += "\"freeHeap\":" + String(ESP.getFreeHeap()) + ",";
json += "\"uptime\":" + String(millis() / 1000); json += "\"uptime\":" + String(millis() / 1000);
json += "}"; json += "}";
@ -192,26 +169,24 @@ void CrossPointWebServer::handleStatus() {
server->send(200, "application/json", json); server->send(200, "application/json", json);
} }
std::vector<FileInfo> CrossPointWebServer::scanFiles(const char* path) { void CrossPointWebServer::scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const {
std::vector<FileInfo> files;
File root = SD.open(path); File root = SD.open(path);
if (!root) { if (!root) {
Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path); Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path);
return files; return;
} }
if (!root.isDirectory()) { if (!root.isDirectory()) {
Serial.printf("[%lu] [WEB] Not a directory: %s\n", millis(), path); Serial.printf("[%lu] [WEB] Not a directory: %s\n", millis(), path);
root.close(); root.close();
return files; return;
} }
Serial.printf("[%lu] [WEB] Scanning files in: %s\n", millis(), path); Serial.printf("[%lu] [WEB] Scanning files in: %s\n", millis(), path);
File file = root.openNextFile(); File file = root.openNextFile();
while (file) { while (file) {
String fileName = String(file.name()); auto fileName = String(file.name());
// Skip hidden items (starting with ".") // Skip hidden items (starting with ".")
bool shouldHide = fileName.startsWith("."); bool shouldHide = fileName.startsWith(".");
@ -239,37 +214,24 @@ std::vector<FileInfo> CrossPointWebServer::scanFiles(const char* path) {
info.isEpub = isEpubFile(info.name); info.isEpub = isEpubFile(info.name);
} }
files.push_back(info); callback(info);
} }
file.close(); file.close();
file = root.openNextFile(); file = root.openNextFile();
} }
root.close(); root.close();
Serial.printf("[%lu] [WEB] Found %d items (files and folders)\n", millis(), files.size());
return files;
} }
String CrossPointWebServer::formatFileSize(size_t bytes) { bool CrossPointWebServer::isEpubFile(const String& filename) const {
if (bytes < 1024) {
return String(bytes) + " B";
} else if (bytes < 1024 * 1024) {
return String(bytes / 1024.0, 1) + " KB";
} else {
return String(bytes / (1024.0 * 1024.0), 1) + " MB";
}
}
bool CrossPointWebServer::isEpubFile(const String& filename) {
String lower = filename; String lower = filename;
lower.toLowerCase(); lower.toLowerCase();
return lower.endsWith(".epub"); return lower.endsWith(".epub");
} }
void CrossPointWebServer::handleFileList() { void CrossPointWebServer::handleFileList() const { server->send(200, "text/html", FilesPageHtml); }
String html = FilesPageHeaderHtml;
void CrossPointWebServer::handleFileListData() const {
// Get current path from query string (default to root) // Get current path from query string (default to root)
String currentPath = "/"; String currentPath = "/";
if (server->hasArg("path")) { if (server->hasArg("path")) {
@ -284,182 +246,35 @@ void CrossPointWebServer::handleFileList() {
} }
} }
// Get message from query string if present server->setContentLength(CONTENT_LENGTH_UNKNOWN);
if (server->hasArg("msg")) { server->send(200, "application/json", "");
String msg = escapeHtml(server->arg("msg")); server->sendContent("[");
String msgType = server->hasArg("type") ? escapeHtml(server->arg("type")) : "success"; char output[512];
html += "<div class=\"message " + msgType + "\">" + msg + "</div>"; constexpr size_t outputSize = sizeof(output);
} bool seenFirst = false;
scanFiles(currentPath.c_str(), [this, &output, seenFirst](const FileInfo& info) mutable {
JsonDocument doc;
doc["name"] = info.name;
doc["size"] = info.size;
doc["isDirectory"] = info.isDirectory;
doc["isEpub"] = info.isEpub;
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) {
// JSON output truncated; skip this entry to avoid sending malformed JSON
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(), info.name.c_str());
return;
}
// Hidden input to store current path for JavaScript if (seenFirst) {
html += "<input type=\"hidden\" id=\"currentPath\" value=\"" + currentPath + "\">"; server->sendContent(",");
// Scan files in current path first (we need counts for the header)
std::vector<FileInfo> files = scanFiles(currentPath.c_str());
// Count items
int epubCount = 0;
int folderCount = 0;
size_t totalSize = 0;
for (const auto& file : files) {
if (file.isDirectory) {
folderCount++;
} else { } else {
if (file.isEpub) epubCount++; seenFirst = true;
totalSize += file.size;
} }
} server->sendContent(output);
});
// Page header with inline breadcrumb and action buttons server->sendContent("]");
html += "<div class=\"page-header\">"; // End of streamed response, empty chunk to signal client
html += "<div class=\"page-header-left\">"; server->sendContent("");
html += "<h1>📁 File Manager</h1>";
// Inline breadcrumb
html += "<div class=\"breadcrumb-inline\">";
html += "<span class=\"sep\">/</span>";
if (currentPath == "/") {
html += "<span class=\"current\">🏠</span>";
} else {
html += "<a href=\"/files\">🏠</a>";
String pathParts = currentPath.substring(1); // Remove leading /
String buildPath = "";
int start = 0;
int end = pathParts.indexOf('/');
while (start < (int)pathParts.length()) {
String part;
if (end == -1) {
part = pathParts.substring(start);
buildPath += "/" + part;
html += "<span class=\"sep\">/</span><span class=\"current\">" + escapeHtml(part) + "</span>";
break;
} else {
part = pathParts.substring(start, end);
buildPath += "/" + part;
html += "<span class=\"sep\">/</span><a href=\"/files?path=" + buildPath + "\">" + escapeHtml(part) + "</a>";
start = end + 1;
end = pathParts.indexOf('/', start);
}
}
}
html += "</div>";
html += "</div>";
// Action buttons
html += "<div class=\"action-buttons\">";
html += "<button class=\"action-btn upload-action-btn\" onclick=\"openUploadModal()\">";
html += "📤 Upload";
html += "</button>";
html += "<button class=\"action-btn folder-action-btn\" onclick=\"openFolderModal()\">";
html += "📁 New Folder";
html += "</button>";
html += "</div>";
html += "</div>"; // end page-header
// Contents card with inline summary
html += "<div class=\"card\">";
// Contents header with inline stats
html += "<div class=\"contents-header\">";
html += "<h2 class=\"contents-title\">Contents</h2>";
html += "<span class=\"summary-inline\">";
html += String(folderCount) + " folder" + (folderCount != 1 ? "s" : "") + ", ";
html += String(files.size() - folderCount) + " file" + ((files.size() - folderCount) != 1 ? "s" : "") + ", ";
html += formatFileSize(totalSize);
html += "</span>";
html += "</div>";
if (files.empty()) {
html += "<div class=\"no-files\">This folder is empty</div>";
} else {
html += "<table class=\"file-table\">";
html += "<tr><th>Name</th><th>Type</th><th>Size</th><th class=\"actions-col\">Actions</th></tr>";
// Sort files: folders first, then epub files, then other files, alphabetically within each group
std::sort(files.begin(), files.end(), [](const FileInfo& a, const FileInfo& b) {
// Folders come first
if (a.isDirectory != b.isDirectory) return a.isDirectory > b.isDirectory;
// Then sort by epub status (epubs first among files)
if (!a.isDirectory && !b.isDirectory) {
if (a.isEpub != b.isEpub) return a.isEpub > b.isEpub;
}
// Then alphabetically
return a.name < b.name;
});
for (const auto& file : files) {
String rowClass;
String icon;
String badge;
String typeStr;
String sizeStr;
if (file.isDirectory) {
rowClass = "folder-row";
icon = "📁";
badge = "<span class=\"folder-badge\">FOLDER</span>";
typeStr = "Folder";
sizeStr = "-";
// Build the path to this folder
String folderPath = currentPath;
if (!folderPath.endsWith("/")) folderPath += "/";
folderPath += file.name;
html += "<tr class=\"" + rowClass + "\">";
html += "<td><span class=\"file-icon\">" + icon + "</span>";
html += "<a href=\"/files?path=" + folderPath + "\" class=\"folder-link\">" + escapeHtml(file.name) + "</a>" +
badge + "</td>";
html += "<td>" + typeStr + "</td>";
html += "<td>" + sizeStr + "</td>";
// Escape quotes for JavaScript string
String escapedName = file.name;
escapedName.replace("'", "\\'");
String escapedPath = folderPath;
escapedPath.replace("'", "\\'");
html += "<td class=\"actions-col\"><button class=\"delete-btn\" onclick=\"openDeleteModal('" + escapedName +
"', '" + escapedPath + "', true)\" title=\"Delete folder\">🗑️</button></td>";
html += "</tr>";
} else {
rowClass = file.isEpub ? "epub-file" : "";
icon = file.isEpub ? "📗" : "📄";
badge = file.isEpub ? "<span class=\"epub-badge\">EPUB</span>" : "";
String ext = file.name.substring(file.name.lastIndexOf('.') + 1);
ext.toUpperCase();
typeStr = ext;
sizeStr = formatFileSize(file.size);
// Build file path for delete
String filePath = currentPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += file.name;
html += "<tr class=\"" + rowClass + "\">";
html += "<td><span class=\"file-icon\">" + icon + "</span>" + escapeHtml(file.name) + badge + "</td>";
html += "<td>" + typeStr + "</td>";
html += "<td>" + sizeStr + "</td>";
// Escape quotes for JavaScript string
String escapedName = file.name;
escapedName.replace("'", "\\'");
String escapedPath = filePath;
escapedPath.replace("'", "\\'");
html += "<td class=\"actions-col\"><button class=\"delete-btn\" onclick=\"openDeleteModal('" + escapedName +
"', '" + escapedPath + "', false)\" title=\"Delete file\">🗑️</button></td>";
html += "</tr>";
}
}
html += "</table>";
}
html += "</div>";
html += FilesPageFooterHtml;
server->send(200, "text/html", html);
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str()); Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
} }
@ -471,7 +286,7 @@ static size_t uploadSize = 0;
static bool uploadSuccess = false; static bool uploadSuccess = false;
static String uploadError = ""; static String uploadError = "";
void CrossPointWebServer::handleUpload() { void CrossPointWebServer::handleUpload() const {
static unsigned long lastWriteTime = 0; static unsigned long lastWriteTime = 0;
static unsigned long uploadStartTime = 0; static unsigned long uploadStartTime = 0;
static size_t lastLoggedSize = 0; static size_t lastLoggedSize = 0;
@ -482,7 +297,7 @@ void CrossPointWebServer::handleUpload() {
return; return;
} }
HTTPUpload& upload = server->upload(); const HTTPUpload& upload = server->upload();
if (upload.status == UPLOAD_FILE_START) { if (upload.status == UPLOAD_FILE_START) {
uploadFileName = upload.filename; uploadFileName = upload.filename;
@ -513,13 +328,6 @@ void CrossPointWebServer::handleUpload() {
Serial.printf("[%lu] [WEB] [UPLOAD] START: %s to path: %s\n", millis(), uploadFileName.c_str(), uploadPath.c_str()); Serial.printf("[%lu] [WEB] [UPLOAD] START: %s to path: %s\n", millis(), uploadFileName.c_str(), uploadPath.c_str());
Serial.printf("[%lu] [WEB] [UPLOAD] Free heap: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WEB] [UPLOAD] Free heap: %d bytes\n", millis(), ESP.getFreeHeap());
// Validate file extension
if (!isEpubFile(uploadFileName)) {
uploadError = "Only .epub files are allowed";
Serial.printf("[%lu] [WEB] [UPLOAD] REJECTED - not an epub file\n", millis());
return;
}
// Create file path // Create file path
String filePath = uploadPath; String filePath = uploadPath;
if (!filePath.endsWith("/")) filePath += "/"; if (!filePath.endsWith("/")) filePath += "/";
@ -532,8 +340,7 @@ void CrossPointWebServer::handleUpload() {
} }
// Open file for writing // Open file for writing
uploadFile = SD.open(filePath.c_str(), FILE_WRITE); if (!FsHelpers::openFileForWrite("WEB", filePath, uploadFile)) {
if (!uploadFile) {
uploadError = "Failed to create file on SD card"; uploadError = "Failed to create file on SD card";
Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str()); Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str());
return; return;
@ -542,10 +349,10 @@ void CrossPointWebServer::handleUpload() {
Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str()); Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str());
} else if (upload.status == UPLOAD_FILE_WRITE) { } else if (upload.status == UPLOAD_FILE_WRITE) {
if (uploadFile && uploadError.isEmpty()) { if (uploadFile && uploadError.isEmpty()) {
unsigned long writeStartTime = millis(); const unsigned long writeStartTime = millis();
size_t written = uploadFile.write(upload.buf, upload.currentSize); const size_t written = uploadFile.write(upload.buf, upload.currentSize);
unsigned long writeEndTime = millis(); const unsigned long writeEndTime = millis();
unsigned long writeDuration = writeEndTime - writeStartTime; const unsigned long writeDuration = writeEndTime - writeStartTime;
if (written != upload.currentSize) { if (written != upload.currentSize) {
uploadError = "Failed to write to SD card - disk may be full"; uploadError = "Failed to write to SD card - disk may be full";
@ -557,9 +364,9 @@ void CrossPointWebServer::handleUpload() {
// Log progress every 50KB or if write took >100ms // Log progress every 50KB or if write took >100ms
if (uploadSize - lastLoggedSize >= 51200 || writeDuration > 100) { if (uploadSize - lastLoggedSize >= 51200 || writeDuration > 100) {
unsigned long timeSinceStart = millis() - uploadStartTime; const unsigned long timeSinceStart = millis() - uploadStartTime;
unsigned long timeSinceLastWrite = millis() - lastWriteTime; const unsigned long timeSinceLastWrite = millis() - lastWriteTime;
float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0); const float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0);
Serial.printf( Serial.printf(
"[%lu] [WEB] [UPLOAD] Progress: %d bytes (%.1f KB), %.1f KB/s, write took %lu ms, gap since last: %lu " "[%lu] [WEB] [UPLOAD] Progress: %d bytes (%.1f KB), %.1f KB/s, write took %lu ms, gap since last: %lu "
@ -593,23 +400,23 @@ void CrossPointWebServer::handleUpload() {
} }
} }
void CrossPointWebServer::handleUploadPost() { void CrossPointWebServer::handleUploadPost() const {
if (uploadSuccess) { if (uploadSuccess) {
server->send(200, "text/plain", "File uploaded successfully: " + uploadFileName); server->send(200, "text/plain", "File uploaded successfully: " + uploadFileName);
} else { } else {
String error = uploadError.isEmpty() ? "Unknown error during upload" : uploadError; const String error = uploadError.isEmpty() ? "Unknown error during upload" : uploadError;
server->send(400, "text/plain", error); server->send(400, "text/plain", error);
} }
} }
void CrossPointWebServer::handleCreateFolder() { void CrossPointWebServer::handleCreateFolder() const {
// Get folder name from form data // Get folder name from form data
if (!server->hasArg("name")) { if (!server->hasArg("name")) {
server->send(400, "text/plain", "Missing folder name"); server->send(400, "text/plain", "Missing folder name");
return; return;
} }
String folderName = server->arg("name"); const String folderName = server->arg("name");
// Validate folder name // Validate folder name
if (folderName.isEmpty()) { if (folderName.isEmpty()) {
@ -652,7 +459,7 @@ void CrossPointWebServer::handleCreateFolder() {
} }
} }
void CrossPointWebServer::handleDelete() { void CrossPointWebServer::handleDelete() const {
// Get path from form data // Get path from form data
if (!server->hasArg("path")) { if (!server->hasArg("path")) {
server->send(400, "text/plain", "Missing path"); server->send(400, "text/plain", "Missing path");
@ -660,7 +467,7 @@ void CrossPointWebServer::handleDelete() {
} }
String itemPath = server->arg("path"); String itemPath = server->arg("path");
String itemType = server->hasArg("type") ? server->arg("type") : "file"; const String itemType = server->hasArg("type") ? server->arg("type") : "file";
// Validate path // Validate path
if (itemPath.isEmpty() || itemPath == "/") { if (itemPath.isEmpty() || itemPath == "/") {
@ -674,7 +481,7 @@ void CrossPointWebServer::handleDelete() {
} }
// Security check: prevent deletion of protected items // Security check: prevent deletion of protected items
String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1);
// Check if item starts with a dot (hidden/system file) // Check if item starts with a dot (hidden/system file)
if (itemName.startsWith(".")) { if (itemName.startsWith(".")) {

View File

@ -2,8 +2,6 @@
#include <WebServer.h> #include <WebServer.h>
#include <functional>
#include <string>
#include <vector> #include <vector>
// Structure to hold file information // Structure to hold file information
@ -26,7 +24,7 @@ class CrossPointWebServer {
void stop(); void stop();
// Call this periodically to handle client requests // Call this periodically to handle client requests
void handleClient(); void handleClient() const;
// Check if server is running // Check if server is running
bool isRunning() const { return running; } bool isRunning() const { return running; }
@ -35,22 +33,24 @@ class CrossPointWebServer {
uint16_t getPort() const { return port; } uint16_t getPort() const { return port; }
private: private:
WebServer* server = nullptr; std::unique_ptr<WebServer> server = nullptr;
bool running = false; bool running = false;
bool apMode = false; // true when running in AP mode, false for STA mode
uint16_t port = 80; uint16_t port = 80;
// File scanning // File scanning
std::vector<FileInfo> scanFiles(const char* path = "/"); void scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const;
String formatFileSize(size_t bytes); String formatFileSize(size_t bytes) const;
bool isEpubFile(const String& filename); bool isEpubFile(const String& filename) const;
// Request handlers // Request handlers
void handleRoot(); void handleRoot() const;
void handleNotFound(); void handleNotFound() const;
void handleStatus(); void handleStatus() const;
void handleFileList(); void handleFileList() const;
void handleUpload(); void handleFileListData() const;
void handleUploadPost(); void handleUpload() const;
void handleCreateFolder(); void handleUploadPost() const;
void handleDelete(); void handleCreateFolder() const;
void handleDelete() const;
}; };

169
src/network/OtaUpdater.cpp Normal file
View File

@ -0,0 +1,169 @@
#include "OtaUpdater.h"
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <Update.h>
#include <WiFiClientSecure.h>
namespace {
constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest";
}
OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
const std::unique_ptr<WiFiClientSecure> client(new WiFiClientSecure);
client->setInsecure();
HTTPClient http;
Serial.printf("[%lu] [OTA] Fetching: %s\n", millis(), latestReleaseUrl);
http.begin(*client, latestReleaseUrl);
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
const int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) {
Serial.printf("[%lu] [OTA] HTTP error: %d\n", millis(), httpCode);
http.end();
return HTTP_ERROR;
}
JsonDocument doc;
const DeserializationError error = deserializeJson(doc, *client);
http.end();
if (error) {
Serial.printf("[%lu] [OTA] JSON parse failed: %s\n", millis(), error.c_str());
return JSON_PARSE_ERROR;
}
if (!doc["tag_name"].is<std::string>()) {
Serial.printf("[%lu] [OTA] No tag_name found\n", millis());
return JSON_PARSE_ERROR;
}
if (!doc["assets"].is<JsonArray>()) {
Serial.printf("[%lu] [OTA] No assets found\n", millis());
return JSON_PARSE_ERROR;
}
latestVersion = doc["tag_name"].as<std::string>();
for (int i = 0; i < doc["assets"].size(); i++) {
if (doc["assets"][i]["name"] == "firmware.bin") {
otaUrl = doc["assets"][i]["browser_download_url"].as<std::string>();
otaSize = doc["assets"][i]["size"].as<size_t>();
totalSize = otaSize;
updateAvailable = true;
break;
}
}
if (!updateAvailable) {
Serial.printf("[%lu] [OTA] No firmware.bin asset found\n", millis());
return NO_UPDATE;
}
Serial.printf("[%lu] [OTA] Found update: %s\n", millis(), latestVersion.c_str());
return OK;
}
bool OtaUpdater::isUpdateNewer() {
if (!updateAvailable || latestVersion.empty() || latestVersion == CROSSPOINT_VERSION) {
return false;
}
// semantic version check (only match on 3 segments)
const auto updateMajor = stoi(latestVersion.substr(0, latestVersion.find('.')));
const auto updateMinor = stoi(
latestVersion.substr(latestVersion.find('.') + 1, latestVersion.find_last_of('.') - latestVersion.find('.') - 1));
const auto updatePatch = stoi(latestVersion.substr(latestVersion.find_last_of('.') + 1));
std::string currentVersion = CROSSPOINT_VERSION;
const auto currentMajor = stoi(currentVersion.substr(0, currentVersion.find('.')));
const auto currentMinor = stoi(currentVersion.substr(
currentVersion.find('.') + 1, currentVersion.find_last_of('.') - currentVersion.find('.') - 1));
const auto currentPatch = stoi(currentVersion.substr(currentVersion.find_last_of('.') + 1));
if (updateMajor > currentMajor) {
return true;
}
if (updateMajor < currentMajor) {
return false;
}
if (updateMinor > currentMinor) {
return true;
}
if (updateMinor < currentMinor) {
return false;
}
if (updatePatch > currentPatch) {
return true;
}
return false;
}
const std::string& OtaUpdater::getLatestVersion() { return latestVersion; }
OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(const std::function<void(size_t, size_t)>& onProgress) {
if (!isUpdateNewer()) {
return UPDATE_OLDER_ERROR;
}
const std::unique_ptr<WiFiClientSecure> client(new WiFiClientSecure);
client->setInsecure();
HTTPClient http;
Serial.printf("[%lu] [OTA] Fetching: %s\n", millis(), otaUrl.c_str());
http.begin(*client, otaUrl.c_str());
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
const int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) {
Serial.printf("[%lu] [OTA] Download failed: %d\n", millis(), httpCode);
http.end();
return HTTP_ERROR;
}
// 2. Get length and stream
const size_t contentLength = http.getSize();
if (contentLength != otaSize) {
Serial.printf("[%lu] [OTA] Invalid content length\n", millis());
http.end();
return HTTP_ERROR;
}
// 3. Begin the ESP-IDF Update process
if (!Update.begin(otaSize)) {
Serial.printf("[%lu] [OTA] Not enough space. Error: %s\n", millis(), Update.errorString());
http.end();
return INTERNAL_UPDATE_ERROR;
}
this->totalSize = otaSize;
Serial.printf("[%lu] [OTA] Update started\n", millis());
Update.onProgress([this, onProgress](const size_t progress, const size_t total) {
this->processedSize = progress;
this->totalSize = total;
onProgress(progress, total);
});
const size_t written = Update.writeStream(*client);
http.end();
if (written == otaSize) {
Serial.printf("[%lu] [OTA] Successfully written %u bytes\n", millis(), written);
} else {
Serial.printf("[%lu] [OTA] Written only %u/%u bytes. Error: %s\n", millis(), written, otaSize,
Update.errorString());
return INTERNAL_UPDATE_ERROR;
}
if (Update.end() && Update.isFinished()) {
Serial.printf("[%lu] [OTA] Update complete\n", millis());
return OK;
} else {
Serial.printf("[%lu] [OTA] Error Occurred: %s\n", millis(), Update.errorString());
return INTERNAL_UPDATE_ERROR;
}
}

30
src/network/OtaUpdater.h Normal file
View File

@ -0,0 +1,30 @@
#pragma once
#include <functional>
#include <string>
class OtaUpdater {
bool updateAvailable = false;
std::string latestVersion;
std::string otaUrl;
size_t otaSize = 0;
public:
enum OtaUpdaterError {
OK = 0,
NO_UPDATE,
HTTP_ERROR,
JSON_PARSE_ERROR,
UPDATE_OLDER_ERROR,
INTERNAL_UPDATE_ERROR,
OOM_ERROR,
};
size_t processedSize = 0;
size_t totalSize = 0;
OtaUpdater() = default;
bool isUpdateNewer();
const std::string& getLatestVersion();
OtaUpdaterError checkForUpdate();
OtaUpdaterError installUpdate(const std::function<void(size_t, size_t)>& onProgress);
};

View File

@ -0,0 +1,859 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CrossPoint Reader - Files</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
h1 {
color: #2c3e50;
margin-bottom: 5px;
}
h2 {
color: #34495e;
margin-top: 0;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #3498db;
}
.page-header-left {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.breadcrumb-inline {
color: #7f8c8d;
font-size: 1.1em;
}
.breadcrumb-inline a {
color: #3498db;
text-decoration: none;
}
.breadcrumb-inline a:hover {
text-decoration: underline;
}
.breadcrumb-inline .sep {
margin: 0 6px;
color: #bdc3c7;
}
.breadcrumb-inline .current {
color: #2c3e50;
font-weight: 500;
}
.nav-links {
margin: 20px 0;
}
.nav-links a {
display: inline-block;
padding: 10px 20px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
margin-right: 10px;
}
.nav-links a:hover {
background-color: #2980b9;
}
/* Action buttons */
.action-buttons {
display: flex;
gap: 10px;
}
.action-btn {
color: white;
padding: 10px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.95em;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.upload-action-btn {
background-color: #27ae60;
}
.upload-action-btn:hover {
background-color: #219a52;
}
.folder-action-btn {
background-color: #f39c12;
}
.folder-action-btn:hover {
background-color: #d68910;
}
/* Upload modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
justify-content: center;
align-items: center;
}
.modal-overlay.open {
display: flex;
}
.modal {
background: white;
border-radius: 8px;
padding: 25px;
max-width: 450px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal h3 {
margin: 0 0 15px 0;
color: #2c3e50;
}
.modal-close {
float: right;
background: none;
border: none;
font-size: 1.5em;
cursor: pointer;
color: #7f8c8d;
line-height: 1;
}
.modal-close:hover {
color: #2c3e50;
}
.file-table {
width: 100%;
border-collapse: collapse;
}
.file-table th,
.file-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
.file-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #7f8c8d;
}
.file-table tr:hover {
background-color: #f8f9fa;
}
.epub-file {
background-color: #e8f6e9 !important;
}
.epub-file:hover {
background-color: #d4edda !important;
}
.folder-row {
background-color: #fff9e6 !important;
}
.folder-row:hover {
background-color: #fff3cd !important;
}
.epub-badge {
display: inline-block;
padding: 2px 8px;
background-color: #27ae60;
color: white;
border-radius: 10px;
font-size: 0.75em;
margin-left: 8px;
}
.folder-badge {
display: inline-block;
padding: 2px 8px;
background-color: #f39c12;
color: white;
border-radius: 10px;
font-size: 0.75em;
margin-left: 8px;
}
.file-icon {
margin-right: 8px;
}
.folder-link {
color: #2c3e50;
text-decoration: none;
cursor: pointer;
}
.folder-link:hover {
color: #3498db;
text-decoration: underline;
}
.upload-form {
margin-top: 10px;
}
.upload-form input[type="file"] {
margin: 10px 0;
width: 100%;
}
.upload-btn {
background-color: #27ae60;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
}
.upload-btn:hover {
background-color: #219a52;
}
.upload-btn:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
.file-info {
color: #7f8c8d;
font-size: 0.85em;
margin: 8px 0;
}
.no-files {
text-align: center;
color: #95a5a6;
padding: 40px;
font-style: italic;
}
.message {
padding: 15px;
border-radius: 4px;
margin: 15px 0;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.contents-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.contents-title {
font-size: 1.1em;
font-weight: 600;
color: #34495e;
margin: 0;
}
.summary-inline {
color: #7f8c8d;
font-size: 0.9em;
}
#progress-container {
display: none;
margin-top: 10px;
}
#progress-bar {
width: 100%;
height: 20px;
background-color: #e0e0e0;
border-radius: 10px;
overflow: hidden;
}
#progress-fill {
height: 100%;
background-color: #27ae60;
width: 0%;
transition: width 0.3s;
}
#progress-text {
text-align: center;
margin-top: 5px;
font-size: 0.9em;
color: #7f8c8d;
}
.folder-form {
margin-top: 10px;
}
.folder-input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1em;
margin-bottom: 10px;
box-sizing: border-box;
}
.folder-btn {
background-color: #f39c12;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
}
.folder-btn:hover {
background-color: #d68910;
}
/* Delete button styles */
.delete-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.1em;
padding: 4px 8px;
border-radius: 4px;
color: #95a5a6;
transition: all 0.15s;
}
.delete-btn:hover {
background-color: #fee;
color: #e74c3c;
}
.actions-col {
width: 60px;
text-align: center;
}
/* Delete modal */
.delete-warning {
color: #e74c3c;
font-weight: 600;
margin: 10px 0;
}
.delete-item-name {
font-weight: 600;
color: #2c3e50;
word-break: break-all;
}
.delete-btn-confirm {
background-color: #e74c3c;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
}
.delete-btn-confirm:hover {
background-color: #c0392b;
}
.delete-btn-cancel {
background-color: #95a5a6;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
margin-top: 10px;
}
.delete-btn-cancel:hover {
background-color: #7f8c8d;
}
.loader-container {
display: flex;
justify-content: center;
align-items: center;
margin: 20px 0;
}
.loader {
width: 48px;
height: 48px;
border: 5px solid #AAA;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Mobile responsive styles */
@media (max-width: 600px) {
body {
padding: 10px;
font-size: 14px;
}
.card {
padding: 12px;
margin: 10px 0;
}
.page-header {
gap: 10px;
margin-bottom: 12px;
padding-bottom: 10px;
}
.page-header-left {
gap: 8px;
}
h1 {
font-size: 1.3em;
}
.breadcrumb-inline {
font-size: 0.95em;
}
.nav-links a {
padding: 8px 12px;
margin-right: 6px;
font-size: 0.9em;
}
.action-buttons {
gap: 6px;
}
.action-btn {
padding: 8px 10px;
font-size: 0.85em;
}
.file-table th,
.file-table td {
padding: 8px 6px;
font-size: 0.9em;
}
.file-table th {
font-size: 0.85em;
}
.file-icon {
margin-right: 4px;
}
.epub-badge,
.folder-badge {
padding: 2px 5px;
font-size: 0.65em;
margin-left: 4px;
}
.contents-header {
margin-bottom: 8px;
flex-wrap: wrap;
gap: 4px;
}
.contents-title {
font-size: 1em;
}
.summary-inline {
font-size: 0.8em;
}
.modal {
padding: 15px;
}
.modal h3 {
font-size: 1.1em;
}
.actions-col {
width: 40px;
}
.delete-btn {
font-size: 1em;
padding: 2px 4px;
}
.no-files {
padding: 20px;
font-size: 0.9em;
}
}
</style>
</head>
<body>
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">File Manager</a>
</div>
<div class="page-header">
<div class="page-header-left">
<h1>📁 File Manager</h1>
<div class="breadcrumb-inline" id="directory-breadcrumbs"></div>
</div>
<div class="action-buttons">
<button class="action-btn upload-action-btn" onclick="openUploadModal()">📤 Upload</button>
<button class="action-btn folder-action-btn" onclick="openFolderModal()">📁 New Folder</button>
</div>
</div>
<div class="card">
<div class="contents-header">
<h2 class="contents-title">Contents</h2>
<span class="summary-inline" id="folder-summary"></span>
</div>
<div id="file-table">
<div class="loader-container">
<span class="loader"></span>
</div>
</div>
</div>
<div class="card">
<p style="text-align: center; color: #95a5a6; margin: 0;">
CrossPoint E-Reader • Open Source
</p>
</div>
<!-- Upload Modal -->
<div class="modal-overlay" id="uploadModal">
<div class="modal">
<button class="modal-close" onclick="closeUploadModal()">&times;</button>
<h3>📤 Upload file</h3>
<div class="upload-form">
<p class="file-info">Select a file to upload to <strong id="uploadPathDisplay"></strong></p>
<input type="file" id="fileInput" onchange="validateFile()">
<button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button>
<div id="progress-container">
<div id="progress-bar"><div id="progress-fill"></div></div>
<div id="progress-text"></div>
</div>
</div>
</div>
</div>
<!-- New Folder Modal -->
<div class="modal-overlay" id="folderModal">
<div class="modal">
<button class="modal-close" onclick="closeFolderModal()">&times;</button>
<h3>📁 New Folder</h3>
<div class="folder-form">
<p class="file-info">Create a new folder in <strong id="folderPathDisplay"></strong></p>
<input type="text" id="folderName" class="folder-input" placeholder="Folder name...">
<button class="folder-btn" onclick="createFolder()">Create Folder</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal-overlay" id="deleteModal">
<div class="modal">
<button class="modal-close" onclick="closeDeleteModal()">&times;</button>
<h3>🗑️ Delete Item</h3>
<div class="folder-form">
<p class="delete-warning">⚠️ This action cannot be undone!</p>
<p class="file-info">Are you sure you want to delete:</p>
<p class="delete-item-name" id="deleteItemName"></p>
<input type="hidden" id="deleteItemPath">
<input type="hidden" id="deleteItemType">
<button class="delete-btn-confirm" onclick="confirmDelete()">Delete</button>
<button class="delete-btn-cancel" onclick="closeDeleteModal()">Cancel</button>
</div>
</div>
</div>
<script>
// get current path from query parameter
const currentPath = decodeURIComponent(new URLSearchParams(window.location.search).get('path') || '/');
function escapeHtml(unsafe) {
return unsafe
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)).toLocaleString() + ' ' + sizes[i];
}
async function hydrate() {
// Close modals when clicking overlay
document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
overlay.classList.remove('open');
}
});
});
const breadcrumbs = document.getElementById('directory-breadcrumbs');
const fileTable = document.getElementById('file-table');
let breadcrumbContent = '<span class="sep">/</span>';
if (currentPath === '/') {
breadcrumbContent += '<span class="current">🏠</span>';
} else {
breadcrumbContent += '<a href="/files">🏠</a>';
const pathSegments = currentPath.split('/');
pathSegments.slice(1, pathSegments.length - 1).forEach(function(segment, index) {
breadcrumbContent += '<span class="sep">/</span><a href="/files?path=' + encodeURIComponent(pathSegments.slice(0, index + 2).join('/')) + '">' + escapeHtml(segment) + '</a>';
});
breadcrumbContent += '<span class="sep">/</span>';
breadcrumbContent += '<span class="current">' + escapeHtml(pathSegments[pathSegments.length - 1]) + '</span>';
}
breadcrumbs.innerHTML = breadcrumbContent;
let files = [];
try {
const response = await fetch('/api/files?path=' + encodeURIComponent(currentPath));
if (!response.ok) {
throw new Error('Failed to load files: ' + response.status + ' ' + response.statusText);
}
files = await response.json();
} catch (e) {
console.error(e);
fileTable.innerHTML = '<div class="no-files">An error occurred while loading the files</div>';
return;
}
let folderCount = 0;
let totalSize = 0;
files.forEach(file => {
if (file.isDirectory) folderCount++;
totalSize += file.size;
});
document.getElementById('folder-summary').innerHTML = `${folderCount} folders, ${files.length - folderCount} files, ${formatFileSize(totalSize)}`;
if (files.length === 0) {
fileTable.innerHTML = '<div class="no-files">This folder is empty</div>';
} else {
let fileTableContent = '<table class="file-table">';
fileTableContent += '<tr><th>Name</th><th>Type</th><th>Size</th><th class="actions-col">Actions</th></tr>';
const sortedFiles = files.sort((a, b) => {
// Directories first, then epub files, then other files, alphabetically within each group
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
if (a.isEpub && !b.isEpub) return -1;
if (!a.isEpub && b.isEpub) return 1;
return a.name.localeCompare(b.name);
});
sortedFiles.forEach(file => {
if (file.isDirectory) {
let folderPath = currentPath;
if (!folderPath.endsWith("/")) folderPath += "/";
folderPath += file.name;
fileTableContent += '<tr class="folder-row">';
fileTableContent += `<td><span class="file-icon">📁</span><a href="/files?path=${encodeURIComponent(folderPath)}" class="folder-link">${escapeHtml(file.name)}</a><span class="folder-badge">FOLDER</span></td>`;
fileTableContent += '<td>Folder</td>';
fileTableContent += '<td>-</td>';
fileTableContent += `<td class="actions-col"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${folderPath.replaceAll("'", "\\'")}', true)" title="Delete folder">🗑️</button></td>`;
fileTableContent += '</tr>';
} else {
let filePath = currentPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += file.name;
fileTableContent += `<tr class="${file.isEpub ? 'epub-file' : ''}">`;
fileTableContent += `<td><span class="file-icon">${file.isEpub ? '📗' : '📄'}</span>${escapeHtml(file.name)}`;
if (file.isEpub) fileTableContent += '<span class="epub-badge">EPUB</span>';
fileTableContent += '</td>';
fileTableContent += `<td>${file.name.split('.').pop().toUpperCase()}</td>`;
fileTableContent += `<td>${formatFileSize(file.size)}</td>`;
fileTableContent += `<td class="actions-col"><button class="delete-btn" onclick="openDeleteModal('${file.name.replaceAll("'", "\\'")}', '${filePath.replaceAll("'", "\\'")}', false)" title="Delete file">🗑️</button></td>`;
fileTableContent += '</tr>';
}
});
fileTableContent += '</table>';
fileTable.innerHTML = fileTableContent;
}
}
// Modal functions
function openUploadModal() {
document.getElementById('uploadPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
document.getElementById('uploadModal').classList.add('open');
}
function closeUploadModal() {
document.getElementById('uploadModal').classList.remove('open');
document.getElementById('fileInput').value = '';
document.getElementById('uploadBtn').disabled = true;
document.getElementById('progress-container').style.display = 'none';
document.getElementById('progress-fill').style.width = '0%';
document.getElementById('progress-fill').style.backgroundColor = '#27ae60';
}
function openFolderModal() {
document.getElementById('folderPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
document.getElementById('folderModal').classList.add('open');
document.getElementById('folderName').value = '';
}
function closeFolderModal() {
document.getElementById('folderModal').classList.remove('open');
}
function validateFile() {
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const file = fileInput.files[0];
uploadBtn.disabled = !file;
}
function uploadFile() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) {
alert('Please select a file first!');
return;
}
const formData = new FormData();
formData.append('file', file);
const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const uploadBtn = document.getElementById('uploadBtn');
progressContainer.style.display = 'block';
uploadBtn.disabled = true;
const xhr = new XMLHttpRequest();
// Include path as query parameter since multipart form data doesn't make
// form fields available until after file upload completes
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = percent + '%';
progressText.textContent = 'Uploading: ' + percent + '%';
}
};
xhr.onload = function() {
if (xhr.status === 200) {
progressText.textContent = 'Upload complete!';
setTimeout(function() {
window.location.reload();
}, 1000);
} else {
progressText.textContent = 'Upload failed: ' + xhr.responseText;
progressFill.style.backgroundColor = '#e74c3c';
uploadBtn.disabled = false;
}
};
xhr.onerror = function() {
progressText.textContent = 'Upload failed - network error';
progressFill.style.backgroundColor = '#e74c3c';
uploadBtn.disabled = false;
};
xhr.send(formData);
}
function createFolder() {
const folderName = document.getElementById('folderName').value.trim();
if (!folderName) {
alert('Please enter a folder name!');
return;
}
// Validate folder name (no special characters except underscore and hyphen)
const validName = /^[a-zA-Z0-9_\-]+$/.test(folderName);
if (!validName) {
alert('Folder name can only contain letters, numbers, underscores, and hyphens.');
return;
}
const formData = new FormData();
formData.append('name', folderName);
formData.append('path', currentPath);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/mkdir', true);
xhr.onload = function() {
if (xhr.status === 200) {
window.location.reload();
} else {
alert('Failed to create folder: ' + xhr.responseText);
}
};
xhr.onerror = function() {
alert('Failed to create folder - network error');
};
xhr.send(formData);
}
// Delete functions
function openDeleteModal(name, path, isFolder) {
document.getElementById('deleteItemName').textContent = (isFolder ? '📁 ' : '📄 ') + name;
document.getElementById('deleteItemPath').value = path;
document.getElementById('deleteItemType').value = isFolder ? 'folder' : 'file';
document.getElementById('deleteModal').classList.add('open');
}
function closeDeleteModal() {
document.getElementById('deleteModal').classList.remove('open');
}
function confirmDelete() {
const path = document.getElementById('deleteItemPath').value;
const itemType = document.getElementById('deleteItemType').value;
const formData = new FormData();
formData.append('path', path);
formData.append('type', itemType);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/delete', true);
xhr.onload = function() {
if (xhr.status === 200) {
window.location.reload();
} else {
alert('Failed to delete: ' + xhr.responseText);
closeDeleteModal();
}
};
xhr.onerror = function() {
alert('Failed to delete - network error');
closeDeleteModal();
};
xhr.send(formData);
}
hydrate();
</script>
</body>
</html>

View File

@ -83,7 +83,7 @@
<h2>Device Status</h2> <h2>Device Status</h2>
<div class="info-row"> <div class="info-row">
<span class="label">Version</span> <span class="label">Version</span>
<span class="value">%VERSION%</span> <span class="value" id="version"></span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="label">WiFi Status</span> <span class="label">WiFi Status</span>
@ -91,11 +91,11 @@
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="label">IP Address</span> <span class="label">IP Address</span>
<span class="value">%IP_ADDRESS%</span> <span class="value" id="ip-address"></span>
</div> </div>
<div class="info-row"> <div class="info-row">
<span class="label">Free Memory</span> <span class="label">Free Memory</span>
<span class="value">%FREE_HEAP% bytes</span> <span class="value" id="free-heap"></span>
</div> </div>
</div> </div>
@ -104,5 +104,26 @@
CrossPoint E-Reader • Open Source CrossPoint E-Reader • Open Source
</p> </p>
</div> </div>
<script>
async function fetchStatus() {
try {
const response = await fetch('/api/status');
if (!response.ok) {
throw new Error('Failed to fetch status: ' + response.status + ' ' + response.statusText);
}
const data = await response.json();
document.getElementById('version').textContent = data.version || 'N/A';
document.getElementById('ip-address').textContent = data.ip || 'N/A';
document.getElementById('free-heap').textContent = data.freeHeap
? data.freeHeap.toLocaleString() + ' bytes'
: 'N/A';
} catch (error) {
console.error('Error fetching status:', error);
}
}
// Fetch status on page load
window.onload = fetchStatus;
</script>
</body> </body>
</html> </html>