10 Commits
0.8.1 ... 0.9.0

Author SHA1 Message Date
Dave Allie
504c7b307d Cut release 0.9.0 2025-12-24 21:49:47 +10:00
Dave Allie
b6bc1f7ed3 New book.bin spine and table of contents cache (#104)
## Summary

* Use single unified cache file for book spine, table of contents, and
core metadata (title, author, cover image)
* Use new temp item store file in OPF parsing to store items to be
rescaned when parsing spine
  * This avoids us holding these items in memory
* Use new toc.bin.tmp and spine.bin.tmp to build out partial toc / spine
data as part of parsing content.opf and the NCX file
  * These files are re-read multiple times to ultimately build book.bin

## Additional Context

* Spec for file format included below as an image
* This should help with:
  * #10 
  * #60 
  * #99
2025-12-24 22:36:13 +11:00
Dave Allie
ea0abaf351 Prevent SD card error causing boot loop (#122)
## Summary

* Prevent SD card error causing boot loop
* We need the screen and fonts to be initialized to show the full screen
error message
* Prior to this change, trying to render the font would crash the
firmware and boot loop it
2025-12-24 22:33:21 +11:00
Dave Allie
2771579007 Add support for blockquote, strong, and em tags (#121)
## Summary

* Add support for blockquote, strong, and em tags
2025-12-24 22:33:17 +11:00
Dave Allie
27035b2b91 Handle 16x16 MCU blocks in JPEG decoding (#120)
## Summary

* Handle 16x16 MCU blocks in JPEG decoding
* We were only correctly handling 8x8 blocks, which means that we did
not correctly support a lot of JPGs leading to an interlacing style on
the images

## Additional Context

* Fixes https://github.com/daveallie/crosspoint-reader/issues/118
2025-12-24 22:21:41 +11:00
Dave Allie
1107590b56 Standardize File handling with FsHelpers (#110)
## Summary

* Standardize File handling with FsHelpers
* Better central place to manage to logic of if files exist/open for
reading/writing
2025-12-23 14:14:10 +11:00
Dave Allie
66ddb52103 Pin espressif32 platform version 2025-12-23 12:17:12 +11:00
Brendan O'Leary
9f4f71fabe Add AP mode option for file transfers (#98)
## Summary

* **What is the goal of this PR?** Adds WiFi Access Point (AP) mode
support for File Transfer, allowing the device to create its own WiFi
network that users can connect to directly - useful when no existing
WiFi network is available. And in my experience is faster when the
device is right next to your laptop (but maybe further from your wifi)

* **What changes are included?**
- New `NetworkModeSelectionActivity` - an interstitial screen asking
users to choose between:
- "Join a Network" - connects to an existing WiFi network (existing
behavior)
- "Create Hotspot" - creates a WiFi access point named
"CrossPoint-Reader"
  - Modified `CrossPointWebServerActivity` to:
    - Launch the network mode selection screen before proceeding
- Support starting an Access Point with mDNS (`crosspoint.local`) and
DNS server for captive portal behavior
    - Display appropriate connection info for both modes
- Modified `CrossPointWebServer` to support starting when WiFi is in AP
mode (not just STA connected mode)

## Additional Context

* **AP Mode Details**: The device creates an open WiFi network named
"CrossPoint-Reader". Once connected, users can access the file transfer
page at `http://crosspoint.local/` or `http://192.168.4.1/`
* **DNS Captive Portal**: A DNS server redirects all domain requests to
the device's IP, enabling captive portal behavior on some devices
* **mDNS**: Hostname resolution via `crosspoint.local` is enabled for
both AP and STA modes
* **No breaking changes**: The "Join a Network" option preserves the
existing WiFi connection flow
* **Memory impact**: Minimal - the AP mode uses roughly the same
resources as STA mode
2025-12-22 17:24:14 +11:00
Dave Allie
d23020e268 OTA updates (#96)
## Summary

* Adds support for OTA
  * Gets latest firmware bin from latest GitHub release
* I have noticed it be a little flaky unpacking the JSON and
occasionally failing to start
2025-12-22 17:16:46 +11:00
Dave Allie
f4491875ab Thoroughly deinitialise expat parsers before freeing them (#103)
## Summary

* Thoroughly deinitialise expat parsers before freeing them
* Spotted a few crashes when de-initing expat parsers
2025-12-22 17:16:39 +11:00
43 changed files with 2011 additions and 419 deletions

View File

@@ -1,13 +1,11 @@
#include "Epub.h" #include "Epub.h"
#include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <JpegToBmpConverter.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"
@@ -44,7 +42,15 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
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()); Serial.printf("[%lu] [EBP] Parsing content.opf: %s\n", millis(), contentOpfFilePath.c_str());
size_t contentOpfSize; size_t contentOpfSize;
@@ -53,7 +59,9 @@ bool Epub::parseContentOpf(const std::string& contentOpfFilePath) {
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());
@@ -66,26 +74,20 @@ bool Epub::parseContentOpf(const std::string& contentOpfFilePath) {
} }
// 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());
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());
@@ -95,13 +97,18 @@ bool Epub::parseTocNcxFile() {
Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str()); Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str());
const auto tmpNcxPath = getCachePath() + "/toc.ncx"; const auto tmpNcxPath = getCachePath() + "/toc.ncx";
File tempNcxFile = SD.open(tmpNcxPath.c_str(), FILE_WRITE); File tempNcxFile;
if (!FsHelpers::openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) {
return false;
}
readItemContentsToStream(tocNcxItem, tempNcxFile, 1024); readItemContentsToStream(tocNcxItem, tempNcxFile, 1024);
tempNcxFile.close(); tempNcxFile.close();
tempNcxFile = SD.open(tmpNcxPath.c_str(), FILE_READ); if (!FsHelpers::openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
return false;
}
const auto ncxSize = tempNcxFile.size(); const auto ncxSize = tempNcxFile.size();
TocNcxParser ncxParser(contentBasePath, ncxSize); 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());
@@ -130,9 +137,7 @@ bool Epub::parseTocNcxFile() {
tempNcxFile.close(); tempNcxFile.close();
SD.remove(tmpNcxPath.c_str()); SD.remove(tmpNcxPath.c_str());
this->toc = std::move(ncxParser.toc); Serial.printf("[%lu] [EBP] Parsed TOC items\n", millis());
Serial.printf("[%lu] [EBP] Parsed %d TOC items\n", millis(), this->toc.size());
return true; return true;
} }
@@ -140,48 +145,79 @@ bool Epub::parseTocNcxFile() {
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());
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() {
Serial.printf("[%lu] [EBP] Calculating book size\n", millis());
const size_t spineItemsCount = getSpineItemsCount();
size_t cumSpineItemSize = 0;
const ZipFile zip("/sd" + filepath);
for (size_t i = 0; i < spineItemsCount; i++) {
std::string spineItem = getSpineItem(i);
size_t s = 0;
getItemSize(zip, spineItem, &s);
cumSpineItemSize += s;
cumulativeSpineItemSize.emplace_back(cumSpineItemSize);
} }
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 {
@@ -217,7 +253,14 @@ 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;
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return blank;
}
return bookMetadataCache->coreMetadata.title;
}
std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
@@ -227,24 +270,42 @@ bool Epub::generateCoverBmp() const {
return true; return true;
} }
if (coverImageItem.empty()) { 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()); Serial.printf("[%lu] [EBP] No known cover image\n", millis());
return false; return false;
} }
if (coverImageItem.substr(coverImageItem.length() - 4) == ".jpg" || if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageItem.substr(coverImageItem.length() - 5) == ".jpeg") { coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis()); Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis());
File coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_WRITE, true); const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
readItemContentsToStream(coverImageItem, coverJpg, 1024);
File coverJpg;
if (!FsHelpers::openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false;
}
readItemContentsToStream(coverImageHref, coverJpg, 1024);
coverJpg.close(); coverJpg.close();
coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_READ); if (!FsHelpers::openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
File coverBmp = SD.open(getCoverBmpPath().c_str(), FILE_WRITE, true); return false;
}
File coverBmp;
if (!FsHelpers::openFileForWrite("EBP", getCoverBmpPath(), coverBmp)) {
coverJpg.close();
return false;
}
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp);
coverJpg.close(); coverJpg.close();
coverBmp.close(); coverBmp.close();
SD.remove((getCachePath() + "/.cover.jpg").c_str()); SD.remove(coverJpgTempPath.c_str());
if (!success) { if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis()); Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis());
@@ -259,45 +320,9 @@ bool Epub::generateCoverBmp() const {
return false; return false;
} }
std::string normalisePath(const std::string& path) { uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
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;
}
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, 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) {
@@ -310,7 +335,7 @@ 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);
} }
@@ -321,103 +346,93 @@ bool Epub::getItemSize(const std::string& itemHref, size_t* size) const {
} }
bool Epub::getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size) { bool Epub::getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size) {
const std::string path = normalisePath(itemHref); 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()) {
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const {
if (spineIndex < 0 || spineIndex >= static_cast<int>(cumulativeSpineItemSize.size())) {
Serial.printf("[%lu] [EBP] getCumulativeSpineItemSize index:%d is out of range\n", millis(), spineIndex);
return 0; return 0;
} }
return cumulativeSpineItemSize.at(spineIndex); return bookMetadataCache->getSpineCount();
} }
std::string& Epub::getSpineItem(const int spineIndex) { size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return getSpineItem(spineIndex).cumulativeSize; }
static std::string emptyString;
if (spine.empty()) { BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
Serial.printf("[%lu] [EBP] getSpineItem called but spine is empty\n", millis()); if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return emptyString; Serial.printf("[%lu] [EBP] getSpineItem called but cache not loaded\n", millis());
return {};
} }
if (spineIndex < 0 || spineIndex >= static_cast<int>(spine.size())) {
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 {
static EpubTocEntry emptyEntry = {}; if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
if (toc.empty()) { Serial.printf("[%lu] [EBP] getTocItem called but cache not loaded\n", millis());
Serial.printf("[%lu] [EBP] getTocItem called but toc is empty\n", millis()); return {};
return emptyEntry;
}
if (tocTndex < 0 || tocTndex >= static_cast<int>(toc.size())) {
Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocTndex);
return toc.at(0);
} }
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 i;
}
}
Serial.printf("[%lu] [EBP] Section not found\n", millis());
// not found - default to the start of the book
return 0; return 0;
}
return spineIndex;
} }
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,29 @@
#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); static bool getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size);
public: public:
@@ -54,14 +45,14 @@ class Epub {
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,10 +0,0 @@
#pragma once
#include <string>
struct EpubTocEntry {
std::string title;
std::string href;
std::string anchor;
uint8_t 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

@@ -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 = 5;
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();
@@ -28,7 +29,10 @@ void Section::onPageComplete(std::unique_ptr<Page> page) {
void Section::writeCacheMetadata(const int fontId, const float lineCompression, const int marginTop, void Section::writeCacheMetadata(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 { const bool extraParagraphSpacing) 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);
@@ -44,17 +48,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 bool extraParagraphSpacing) {
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;
@@ -117,15 +116,14 @@ 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 bool extraParagraphSpacing) {
const auto localPath = epub->getSpineItem(spineIndex); const auto localPath = epub->getSpineItem(spineIndex).href;
// TODO: Should we get rid of this file all together?
// It currently saves us a bit of memory by allowing for all the inflation bits to be released
// 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); File tmpHtml;
bool success = epub->readItemContentsToStream(localPath, f, 1024); if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
f.close(); return false;
}
bool success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
tmpHtml.close();
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\n", millis());
@@ -134,10 +132,8 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression,
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s\n", millis(), tmpHtmlPath.c_str()); Serial.printf("[%lu] [SCT] Streamed temp HTML to %s\n", millis(), tmpHtmlPath.c_str());
const auto sdTmpHtmlPath = "/sd" + tmpHtmlPath; ChapterHtmlSlimParser visitor(tmpHtmlPath, renderer, fontId, lineCompression, marginTop, marginRight, marginBottom,
marginLeft, extraParagraphSpacing,
ChapterHtmlSlimParser visitor(sdTmpHtmlPath.c_str(), renderer, fontId, lineCompression, marginTop, marginRight,
marginBottom, marginLeft, extraParagraphSpacing,
[this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); }); [this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); });
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();
@@ -153,13 +149,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

@@ -17,27 +17,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 +45,22 @@ 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);
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);
// 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,13 @@
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"}; 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"};
@@ -214,48 +215,59 @@ 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;
} }
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); 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,7 +15,7 @@ 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;
int depth = 0; int depth = 0;
@@ -45,7 +45,7 @@ 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)

View File

@@ -16,6 +16,8 @@ bool ContainerParser::setup() {
ContainerParser::~ContainerParser() { 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;
} }

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 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);
@@ -22,9 +27,18 @@ bool ContentOpfParser::setup() {
ContentOpfParser::~ContentOpfParser() { 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;
} }
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); }
@@ -40,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;
@@ -51,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;
@@ -85,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;
} }
@@ -126,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()) {
@@ -139,15 +175,30 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
return; return;
} }
// NOTE: This relies on spine appearing after item manifest (which is pretty safe as it's part of the EPUB spec)
// Only run the spine parsing if there's a cache to add it to
if (self->cache) {
if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) { if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) {
for (int i = 0; atts[i]; i += 2) { for (int i = 0; atts[i]; i += 2) {
if (strcmp(atts[i], "idref") == 0) { if (strcmp(atts[i], "idref") == 0) {
self->spineRefs.emplace_back(atts[i + 1]); 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; break;
} }
} }
}
}
return; return;
} }
}
} }
void XMLCALL ContentOpfParser::characterData(void* userData, const XML_Char* s, const int len) { void XMLCALL ContentOpfParser::characterData(void* userData, const XML_Char* s, const int len) {
@@ -165,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,12 +32,11 @@ 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; ~ContentOpfParser() override;
bool setup(); bool setup();

View File

@@ -1,8 +1,9 @@
#include "TocNcxParser.h" #include "TocNcxParser.h"
#include <Esp.h>
#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) {
@@ -18,6 +19,9 @@ bool TocNcxParser::setup() {
TocNcxParser::~TocNcxParser() { 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;
} }
@@ -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.push_back({std::move(self->currentLabel), std::move(href), std::move(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,6 +13,7 @@ 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;
@@ -24,10 +24,8 @@ class TocNcxParser final : public Print {
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)
: baseContentPath(baseContentPath), remainingSize(xmlSize) {}
~TocNcxParser() override; ~TocNcxParser() override;
bool setup(); bool setup();

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

@@ -182,6 +182,9 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) {
} }
// Process MCU block into MCU row buffer // Process MCU block into MCU row buffer
// MCUs are composed of 8x8 blocks. For 16x16 MCUs, there are four 8x8 blocks:
// Block layout for 16x16 MCU: [0, 64] (top row of blocks)
// [128, 192] (bottom row of blocks)
for (int blockY = 0; blockY < mcuPixelHeight; blockY++) { for (int blockY = 0; blockY < mcuPixelHeight; blockY++) {
for (int blockX = 0; blockX < mcuPixelWidth; blockX++) { for (int blockX = 0; blockX < mcuPixelWidth; blockX++) {
const int pixelX = mcuX * mcuPixelWidth + blockX; const int pixelX = mcuX * mcuPixelWidth + blockX;
@@ -191,16 +194,27 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) {
continue; continue;
} }
// Calculate which 8x8 block and position within that block
const int block8x8Col = blockX / 8; // 0 or 1 for 16-wide MCU
const int block8x8Row = blockY / 8; // 0 or 1 for 16-tall MCU
const int pixelInBlockX = blockX % 8;
const int pixelInBlockY = blockY % 8;
// Calculate byte offset: each 8x8 block is 64 bytes
// Blocks are arranged: [0, 64], [128, 192]
const int blockOffset = (block8x8Row * (mcuPixelWidth / 8) + block8x8Col) * 64;
const int mcuIndex = blockOffset + pixelInBlockY * 8 + pixelInBlockX;
// Get grayscale value // Get grayscale value
uint8_t gray; uint8_t gray;
if (imageInfo.m_comps == 1) { if (imageInfo.m_comps == 1) {
// Grayscale image // Grayscale image
gray = imageInfo.m_pMCUBufR[blockY * mcuPixelWidth + blockX]; gray = imageInfo.m_pMCUBufR[mcuIndex];
} else { } else {
// RGB image - convert to grayscale // RGB image - convert to grayscale
const uint8_t r = imageInfo.m_pMCUBufR[blockY * mcuPixelWidth + blockX]; const uint8_t r = imageInfo.m_pMCUBufR[mcuIndex];
const uint8_t g = imageInfo.m_pMCUBufG[blockY * mcuPixelWidth + blockX]; const uint8_t g = imageInfo.m_pMCUBufG[mcuIndex];
const uint8_t b = imageInfo.m_pMCUBufB[blockY * mcuPixelWidth + blockX]; const uint8_t b = imageInfo.m_pMCUBufB[mcuIndex];
// Luminance formula: Y = 0.299*R + 0.587*G + 0.114*B // Luminance formula: Y = 0.299*R + 0.587*G + 0.114*B
// Using integer approximation: (30*R + 59*G + 11*B) / 100 // Using integer approximation: (30*R + 59*G + 11*B) / 100
gray = (r * 30 + g * 59 + b * 11) / 100; gray = (r * 30 + g * 59 + b * 11) / 100;

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

@@ -1,9 +1,9 @@
[platformio] [platformio]
crosspoint_version = 0.8.1 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

View File

@@ -1,26 +1,28 @@
#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;
constexpr uint8_t SETTINGS_COUNT = 3; constexpr uint8_t SETTINGS_COUNT = 3;
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, sleepScreen); serialization::writePod(outputFile, sleepScreen);
@@ -33,13 +35,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) {

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;
} }

View File

@@ -1,6 +1,7 @@
#include "SleepActivity.h" #include "SleepActivity.h"
#include <Epub.h> #include <Epub.h>
#include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SD.h> #include <SD.h>
@@ -76,8 +77,8 @@ void SleepActivity::renderCustomSleepScreen() const {
// Generate a random number between 1 and numFiles // Generate a random number between 1 and numFiles
const auto randomFileIndex = random(numFiles); const auto randomFileIndex = random(numFiles);
const 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);
@@ -93,8 +94,8 @@ void SleepActivity::renderCustomSleepScreen() const {
// 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());
@@ -186,8 +187,8 @@ void SleepActivity::renderCoverSleepScreen() const {
return renderDefaultSleepScreen(); return renderDefaultSleepScreen();
} }
auto file = SD.open(lastEpub.getCoverBmpPath().c_str(), FILE_READ); File file;
if (file) { if (FsHelpers::openFileForRead("SLP", lastEpub.getCoverBmpPath(), file)) {
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
renderBitmapSleepScreen(bitmap); renderBitmapSleepScreen(bitmap);

View File

@@ -1,12 +1,28 @@
#include "CrossPointWebServerActivity.h" #include "CrossPointWebServerActivity.h"
#include <DNSServer.h>
#include <ESPmDNS.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h> #include <InputManager.h>
#include <WiFi.h> #include <WiFi.h>
#include "NetworkModeSelectionActivity.h"
#include "WifiSelectionActivity.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();
@@ -20,7 +36,9 @@ void CrossPointWebServerActivity::onEnter() {
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;
@@ -33,14 +51,12 @@ 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()); ));
enterNewActivity(new WifiSelectionActivity(renderer, inputManager,
[this](const bool connected) { onWifiSelectionComplete(connected); }));
} }
void CrossPointWebServerActivity::onExit() { void CrossPointWebServerActivity::onExit() {
@@ -53,13 +69,29 @@ void CrossPointWebServerActivity::onExit() {
// Stop the web server first (before disconnecting WiFi) // Stop the web server first (before disconnecting WiFi)
stopWebServer(); stopWebServer();
// Stop mDNS
MDNS.end();
// Stop DNS server if running (AP mode)
if (dnsServer) {
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
Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis()); Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis());
delay(500); delay(500);
// Disconnect WiFi gracefully // Disconnect WiFi gracefully
if (isApMode) {
Serial.printf("[%lu] [WEBACT] Stopping WiFi AP...\n", millis());
WiFi.softAPdisconnect(true);
} else {
Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis()); Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis());
WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame
}
delay(100); // Allow disconnect frame to be sent 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());
@@ -89,6 +121,33 @@ void CrossPointWebServerActivity::onExit() {
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());
} }
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) { 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);
@@ -96,17 +155,83 @@ void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected)
// Get connection info before exiting subactivity // Get connection info before exiting subactivity
connectedIP = static_cast<WifiSelectionActivity*>(subActivity.get())->getConnectedIP(); connectedIP = static_cast<WifiSelectionActivity*>(subActivity.get())->getConnectedIP();
connectedSSID = WiFi.SSID().c_str(); connectedSSID = WiFi.SSID().c_str();
isApMode = false;
exitActivity(); exitActivity();
// 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,6 +275,11 @@ void CrossPointWebServerActivity::loop() {
// Handle different states // Handle different states
if (state == WebServerActivityState::SERVER_RUNNING) { if (state == WebServerActivityState::SERVER_RUNNING) {
// Handle DNS requests for captive portal (AP mode only)
if (isApMode && dnsServer) {
dnsServer->processNextRequest();
}
// 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()) {
@@ -193,35 +323,71 @@ 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 CrossPointWebServerActivity::renderServerRunning() const { void CrossPointWebServerActivity::renderServerRunning() const {
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
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); // Use consistent line spacing
constexpr int LINE_SPACING = 28; // Space between lines
renderer.drawCenteredText(READER_FONT_ID, 15, "File Transfer", true, BOLD);
if (isApMode) {
// AP mode display - center the content block
const 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);
// 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);
} else {
// STA mode display (original behavior)
const int startY = 65;
std::string ssidInfo = "Network: " + connectedSSID; std::string ssidInfo = "Network: " + connectedSSID;
if (ssidInfo.length() > 28) { if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "..."); ssidInfo.replace(25, ssidInfo.length() - 25, "...");
} }
renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR); renderer.drawCenteredText(UI_FONT_ID, startY, ssidInfo.c_str(), true, REGULAR);
std::string ipInfo = "IP Address: " + connectedIP; std::string ipInfo = "IP Address: " + connectedIP;
renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR); renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ipInfo.c_str(), true, REGULAR);
// Show web server URL prominently // Show web server URL prominently
std::string webInfo = "http://" + connectedIP + "/"; std::string webInfo = "http://" + connectedIP + "/";
renderer.drawCenteredText(UI_FONT_ID, top + 70, webInfo.c_str(), true, BOLD); renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 2, webInfo.c_str(), true, BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, top + 110, "Open this URL in your browser", true, REGULAR); // 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);
}
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press BACK to exit", true, REGULAR); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press BACK to exit", true, REGULAR);
} }

View File

@@ -7,12 +7,15 @@
#include <memory> #include <memory>
#include <string> #include <string>
#include "NetworkModeSelectionActivity.h"
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
#include "network/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
}; };
@@ -20,8 +23,10 @@ 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
*/ */
@@ -29,15 +34,19 @@ 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;
// Network mode
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;
@@ -47,7 +56,9 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
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();

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.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press OK to select, BACK to cancel", true, REGULAR);
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

@@ -1,9 +1,9 @@
#include "EpubReaderActivity.h" #include "EpubReaderActivity.h"
#include <Epub/Page.h> #include <Epub/Page.h>
#include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h> #include <InputManager.h>
#include <SD.h>
#include "Battery.h" #include "Battery.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
@@ -37,8 +37,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);
@@ -212,7 +212,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,
@@ -282,7 +282,8 @@ 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;
if (FsHelpers::openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[4]; uint8_t data[4];
data[0] = currentSpineIndex & 0xFF; data[0] = currentSpineIndex & 0xFF;
data[1] = (currentSpineIndex >> 8) & 0xFF; data[1] = (currentSpineIndex >> 8) & 0xFF;
@@ -290,6 +291,7 @@ void EpubReaderActivity::renderScreen() {
data[3] = (section->currentPage >> 8) & 0xFF; data[3] = (section->currentPage >> 8) & 0xFF;
f.write(data, 4); f.write(data, 4);
f.close(); f.close();
}
} }
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) { void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) {

View File

@@ -29,7 +29,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

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

@@ -4,16 +4,19 @@
#include <InputManager.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 { namespace {
constexpr int settingsCount = 3; constexpr int settingsCount = 4;
const SettingInfo settingsList[settingsCount] = { const SettingInfo settingsList[settingsCount] = {
// Should match with SLEEP_SCREEN_MODE // Should match with SLEEP_SCREEN_MODE
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
{"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}}, {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}},
{"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}}; {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}},
{"Check for updates", SettingType::ACTION, nullptr, {}},
};
} // namespace } // namespace
void SettingsActivity::taskTrampoline(void* param) { void SettingsActivity::taskTrampoline(void* param) {
@@ -41,7 +44,7 @@ void SettingsActivity::onEnter() {
} }
void SettingsActivity::onExit() { void SettingsActivity::onExit() {
Activity::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);
@@ -54,6 +57,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();
@@ -81,7 +89,7 @@ void SettingsActivity::loop() {
} }
} }
void SettingsActivity::toggleCurrentSetting() const { void SettingsActivity::toggleCurrentSetting() {
// Validate index // Validate index
if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) { if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) {
return; return;
@@ -96,6 +104,16 @@ void SettingsActivity::toggleCurrentSetting() const {
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const uint8_t currentValue = SETTINGS.*(setting.valuePtr); const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size()); 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 { } else {
// Only toggle if it's a toggle type and has a value pointer // Only toggle if it's a toggle type and has a value pointer
return; return;
@@ -107,7 +125,7 @@ void SettingsActivity::toggleCurrentSetting() const {
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();
@@ -152,6 +170,8 @@ void SettingsActivity::render() const {
// Draw help text // Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "Press OK to toggle, BACK to save & exit"); renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "Press OK to toggle, BACK to save & exit");
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,16 +3,15 @@
#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 }; enum class SettingType { TOGGLE, ENUM, ACTION };
// Structure to hold setting information // Structure to hold setting information
struct SettingInfo { struct SettingInfo {
@@ -22,7 +21,7 @@ struct SettingInfo {
std::vector<std::string> enumValues; 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;
@@ -32,11 +31,11 @@ class SettingsActivity 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;
void toggleCurrentSetting() const; void toggleCurrentSetting();
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("Settings", 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

@@ -158,9 +158,23 @@ void onGoHome() {
enterNewActivity(new HomeActivity(renderer, inputManager, onGoToReaderHome, onGoToSettings, onGoToFileTransfer)); enterNewActivity(new HomeActivity(renderer, inputManager, 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() {
t1 = millis(); t1 = millis();
// Only start serial if USB connected
pinMode(UART0_RXD, INPUT);
if (digitalRead(UART0_RXD) == HIGH) {
Serial.begin(115200); Serial.begin(115200);
}
Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis());
@@ -172,8 +186,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;
@@ -184,14 +200,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));

View File

@@ -1,6 +1,7 @@
#include "CrossPointWebServer.h" #include "CrossPointWebServer.h"
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <FsHelpers.h>
#include <SD.h> #include <SD.h>
#include <WiFi.h> #include <WiFi.h>
@@ -30,12 +31,22 @@ 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.reset(new WebServer(port)); server.reset(new WebServer(port));
@@ -70,7 +81,9 @@ void CrossPointWebServer::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());
} }
@@ -141,10 +154,14 @@ void CrossPointWebServer::handleNotFound() const {
} }
void CrossPointWebServer::handleStatus() const { 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 += "}";
@@ -323,8 +340,7 @@ void CrossPointWebServer::handleUpload() const {
} }
// 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;

View File

@@ -35,6 +35,7 @@ class CrossPointWebServer {
private: private:
std::unique_ptr<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

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);
};