Compare commits

...

5 Commits

Author SHA1 Message Date
Dave Allie
75fd818c93
Rebuild of SpineTocCache as BookMetadataCache using new file format 2025-12-23 15:51:24 +11:00
Dave Allie
09e73b34b5
Merge branch 'master' into feature/cached-toc 2025-12-23 14:16:31 +11:00
Dave Allie
1107590b56
Standardize File handling with FsHelpers (#110)
Some checks failed
CI / build (push) Has been cancelled
## 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)
Some checks are pending
CI / build (push) Waiting to run
## 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
32 changed files with 1171 additions and 605 deletions

View File

@ -1,11 +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 "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"
@ -42,7 +42,7 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
return true; return true;
} }
bool Epub::parseContentOpf(bool useCache) { bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
std::string contentOpfFilePath; std::string contentOpfFilePath;
if (!findContentOpfFile(&contentOpfFilePath)) { if (!findContentOpfFile(&contentOpfFilePath)) {
Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis()); Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis());
@ -59,7 +59,9 @@ bool Epub::parseContentOpf(bool useCache) {
return false; return false;
} }
ContentOpfParser opfParser(getBasePath(), contentOpfSize, useCache ? spineTocCache.get() : nullptr); 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());
@ -72,10 +74,10 @@ bool Epub::parseContentOpf(bool useCache) {
} }
// 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;
@ -95,13 +97,18 @@ bool Epub::parseTocNcxFile() const {
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, spineTocCache.get()); 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());
@ -139,18 +146,10 @@ 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());
// Initialize spine/TOC cache // Initialize spine/TOC cache
spineTocCache.reset(new SpineTocCache(cachePath)); bookMetadataCache.reset(new BookMetadataCache(cachePath));
// Try to load existing cache first // Try to load existing cache first
if (spineTocCache->load()) { if (bookMetadataCache->load()) {
Serial.printf("[%lu] [EBP] Loaded spine/TOC from cache\n", millis());
// Still need to parse content.opf for title and cover
if (!parseContentOpf(false)) {
Serial.printf("[%lu] [EBP] Could not parse content.opf\n", millis());
return false;
}
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
return true; return true;
} }
@ -160,33 +159,59 @@ bool Epub::load() {
setupCacheDir(); setupCacheDir();
// Begin building cache - stream entries to disk immediately // Begin building cache - stream entries to disk immediately
if (!spineTocCache->beginWrite()) { if (!bookMetadataCache->beginWrite()) {
Serial.printf("[%lu] [EBP] Could not begin writing cache\n", millis()); Serial.printf("[%lu] [EBP] Could not begin writing cache\n", millis());
return false; return false;
} }
if (!parseContentOpf(true)) {
// OPF Pass
BookMetadataCache::BookMetadata bookMetadata;
if (!bookMetadataCache->beginContentOpfPass()) {
Serial.printf("[%lu] [EBP] Could not begin writing content.opf pass\n", millis());
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()) {
Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis());
return false;
}
// Close the cache files // Close the cache files
if (!spineTocCache->endWrite()) { if (!bookMetadataCache->endWrite()) {
Serial.printf("[%lu] [EBP] Could not end writing cache\n", millis()); Serial.printf("[%lu] [EBP] Could not end writing cache\n", millis());
return false; return false;
} }
// Now compute mappings and sizes (this loads entries temporarily, computes, then rewrites) // Build final book.bin
if (!spineTocCache->updateMapsAndSizes(filepath)) { if (!bookMetadataCache->buildBookBin(filepath, bookMetadata)) {
Serial.printf("[%lu] [EBP] Could not update mappings and sizes\n", millis()); Serial.printf("[%lu] [EBP] Could not update mappings and sizes\n", millis());
return false; 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 // Reload the cache from disk so it's in the correct state
spineTocCache.reset(new SpineTocCache(cachePath)); bookMetadataCache.reset(new BookMetadataCache(cachePath));
if (!spineTocCache->load()) { if (!bookMetadataCache->load()) {
Serial.printf("[%lu] [EBP] Failed to reload cache after writing\n", millis()); Serial.printf("[%lu] [EBP] Failed to reload cache after writing\n", millis());
return false; return false;
} }
@ -228,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"; }
@ -238,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());
@ -301,75 +351,63 @@ bool Epub::getItemSize(const ZipFile& zip, const std::string& itemHref, size_t*
} }
int Epub::getSpineItemsCount() const { int Epub::getSpineItemsCount() const {
if (!spineTocCache || !spineTocCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return 0; return 0;
} }
return spineTocCache->getSpineCount(); return bookMetadataCache->getSpineCount();
} }
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return getSpineItem(spineIndex).cumulativeSize; }
if (!spineTocCache || !spineTocCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getCumulativeSpineItemSize called but cache not loaded\n", millis());
return 0;
}
if (spineIndex < 0 || spineIndex >= spineTocCache->getSpineCount()) { BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
Serial.printf("[%lu] [EBP] getCumulativeSpineItemSize index:%d is out of range\n", millis(), spineIndex); if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return 0;
}
return spineTocCache->getSpineEntry(spineIndex).cumulativeSize;
}
std::string Epub::getSpineHref(const int spineIndex) const {
if (!spineTocCache || !spineTocCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getSpineItem called but cache not loaded\n", millis()); Serial.printf("[%lu] [EBP] getSpineItem called but cache not loaded\n", millis());
return ""; return {};
} }
if (spineIndex < 0 || spineIndex >= spineTocCache->getSpineCount()) { 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 spineTocCache->getSpineEntry(0).href; return bookMetadataCache->getSpineEntry(0);
} }
return spineTocCache->getSpineEntry(spineIndex).href; return bookMetadataCache->getSpineEntry(spineIndex);
} }
SpineTocCache::TocEntry Epub::getTocItem(const int tocIndex) const { BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const {
if (!spineTocCache || !spineTocCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getTocItem called but cache not loaded\n", millis()); Serial.printf("[%lu] [EBP] getTocItem called but cache not loaded\n", millis());
return {}; return {};
} }
if (tocIndex < 0 || tocIndex >= spineTocCache->getTocCount()) { if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocIndex); Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocIndex);
return {}; return {};
} }
return spineTocCache->getTocEntry(tocIndex); return bookMetadataCache->getTocEntry(tocIndex);
} }
int Epub::getTocItemsCount() const { int Epub::getTocItemsCount() const {
if (!spineTocCache || !spineTocCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
return 0; return 0;
} }
return spineTocCache->getTocCount(); 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 (!spineTocCache || !spineTocCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex called but cache not loaded\n", millis()); Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex called but cache not loaded\n", millis());
return 0; return 0;
} }
if (tocIndex < 0 || tocIndex >= spineTocCache->getTocCount()) { 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;
} }
const int spineIndex = spineTocCache->getTocEntry(tocIndex).spineIndex; const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex;
if (spineIndex < 0) { if (spineIndex < 0) {
Serial.printf("[%lu] [EBP] Section not found for TOC index %d\n", millis(), tocIndex); Serial.printf("[%lu] [EBP] Section not found for TOC index %d\n", millis(), tocIndex);
return 0; return 0;
@ -378,22 +416,10 @@ int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
return spineIndex; return spineIndex;
} }
int Epub::getTocIndexForSpineIndex(const int spineIndex) const { int Epub::getTocIndexForSpineIndex(const int spineIndex) const { return getSpineItem(spineIndex).tocIndex; }
if (!spineTocCache || !spineTocCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getTocIndexForSpineIndex called but cache not loaded\n", millis());
return -1;
}
if (spineIndex < 0 || spineIndex >= spineTocCache->getSpineCount()) {
Serial.printf("[%lu] [EBP] getTocIndexForSpineIndex: spineIndex %d out of range\n", millis(), spineIndex);
return -1;
}
return spineTocCache->getSpineEntry(spineIndex).tocIndex;
}
size_t Epub::getBookSize() const { size_t Epub::getBookSize() const {
if (!spineTocCache || !spineTocCache->isLoaded() || spineTocCache->getSpineCount() == 0) { if (!bookMetadataCache || !bookMetadataCache->isLoaded() || bookMetadataCache->getSpineCount() == 0) {
return 0; return 0;
} }
return getCumulativeSpineItemSize(getSpineItemsCount() - 1); return getCumulativeSpineItemSize(getSpineItemsCount() - 1);

View File

@ -6,15 +6,11 @@
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
#include "Epub/SpineTocCache.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?
@ -24,10 +20,10 @@ class Epub {
// Uniq cache key based on filepath // Uniq cache key based on filepath
std::string cachePath; std::string cachePath;
// Spine and TOC cache // Spine and TOC cache
std::unique_ptr<SpineTocCache> spineTocCache; std::unique_ptr<BookMetadataCache> bookMetadataCache;
bool findContentOpfFile(std::string* contentOpfFile) const; bool findContentOpfFile(std::string* contentOpfFile) const;
bool parseContentOpf(bool useCache); bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
bool parseTocNcxFile() const; bool parseTocNcxFile() const;
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);
@ -50,13 +46,13 @@ 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 getSpineHref(int spineIndex) const; BookMetadataCache::SpineEntry getSpineItem(int spineIndex) const;
BookMetadataCache::TocEntry getTocItem(int tocIndex) const;
int getSpineItemsCount() const; int getSpineItemsCount() const;
size_t getCumulativeSpineItemSize(int spineIndex) const;
SpineTocCache::TocEntry getTocItem(int tocIndex) const;
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) const; uint8_t calculateProgress(const int currentSpineIndex, const float currentSpineRead) const;

View File

@ -0,0 +1,323 @@
#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 spineEntry = readSpineEntry(spineFile);
serialization::writePod(bookFile, spineFile.position() + lutOffset + lutSize);
}
// Loop through toc entries, writing LUT positions
tocFile.seek(0);
for (int i = 0; i < tocCount; i++) {
auto tocEntry = readTocEntry(tocFile);
serialization::writePod(bookFile, tocFile.position() + 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);
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);
Serial.printf("[%lu] [BMC] getTocEntry tocEntryPos: %d\n", millis(), tocEntryPos);
bookFile.seek(tocEntryPos);
return readTocEntry(tocFile);
}
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

@ -4,8 +4,14 @@
#include <string> #include <string>
class SpineTocCache { class BookMetadataCache {
public: public:
struct BookMetadata {
std::string title;
std::string author;
std::string coverItemHref;
};
struct SpineEntry { struct SpineEntry {
std::string href; std::string href;
size_t cumulativeSize; size_t cumulativeSize;
@ -34,13 +40,14 @@ class SpineTocCache {
private: private:
std::string cachePath; std::string cachePath;
size_t lutOffset;
uint16_t spineCount; uint16_t spineCount;
uint16_t tocCount; uint16_t tocCount;
bool loaded; bool loaded;
bool buildMode; bool buildMode;
File bookFile;
// Temp file handles during build // Temp file handles during build
File metaFile;
File spineFile; File spineFile;
File tocFile; File tocFile;
@ -50,24 +57,31 @@ class SpineTocCache {
TocEntry readTocEntry(File& file) const; TocEntry readTocEntry(File& file) const;
public: public:
explicit SpineTocCache(std::string cachePath) BookMetadata coreMetadata;
: cachePath(std::move(cachePath)), spineCount(0), tocCount(0), loaded(false), buildMode(false) {}
~SpineTocCache() = default; 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) // Building phase (stream to disk immediately)
bool beginWrite(); bool beginWrite();
void addSpineEntry(const std::string& href); bool beginContentOpfPass();
void addTocEntry(const std::string& title, const std::string& href, const std::string& anchor, uint8_t level); 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 endWrite();
bool cleanupTmpFiles() const;
// Post-processing to update mappings and sizes // Post-processing to update mappings and sizes
bool updateMapsAndSizes(const std::string& epubPath); bool buildBookBin(const std::string& epubPath, const BookMetadata& metadata);
// Reading phase (read mode) // Reading phase (read mode)
bool load(); bool load();
SpineEntry getSpineEntry(int index); SpineEntry getSpineEntry(int index);
TocEntry getTocEntry(int index); TocEntry getTocEntry(int index);
int getSpineCount() const; int getSpineCount() const { return spineCount; }
int getTocCount() const; int getTocCount() const { return tocCount; }
bool isLoaded() const; bool isLoaded() const { return loaded; }
}; };

View File

@ -16,7 +16,7 @@ bool FsHelpers::openFileForRead(const char* moduleName, const std::string& path,
bool FsHelpers::openFileForWrite(const char* moduleName, const std::string& path, File& file) { bool FsHelpers::openFileForWrite(const char* moduleName, const std::string& path, File& file) {
file = SD.open(path.c_str(), FILE_WRITE, true); file = SD.open(path.c_str(), FILE_WRITE, true);
if (!file) { if (!file) {
Serial.printf("[%lu] [%s] Failed to open spine file for writing: %s\n", millis(), moduleName, path.c_str()); Serial.printf("[%lu] [%s] Failed to open file for writing: %s\n", millis(), moduleName, path.c_str());
return false; return false;
} }
return true; return true;

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->getSpineHref(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

@ -1,311 +0,0 @@
#include "SpineTocCache.h"
#include <HardwareSerial.h>
#include <SD.h>
#include <Serialization.h>
#include <ZipFile.h>
#include <vector>
#include "FsHelpers.h"
namespace {
constexpr uint8_t SPINE_TOC_CACHE_VERSION = 1;
constexpr size_t SPINE_TOC_META_HEADER_SIZE = sizeof(SPINE_TOC_CACHE_VERSION) + sizeof(uint16_t) * 2;
constexpr char spineTocMetaBinFile[] = "/spine_toc_meta.bin";
constexpr char spineBinFile[] = "/spine.bin";
constexpr char tocBinFile[] = "/toc.bin";
} // namespace
bool SpineTocCache::beginWrite() {
buildMode = true;
spineCount = 0;
tocCount = 0;
Serial.printf("[%lu] [STC] Beginning write to cache path: %s\n", millis(), cachePath.c_str());
// Open spine file for writing
if (!FsHelpers::openFileForWrite("STC", cachePath + spineBinFile, spineFile)) {
return false;
}
// Open TOC file for writing
if (!FsHelpers::openFileForWrite("STC", cachePath + tocBinFile, tocFile)) {
spineFile.close();
return false;
}
// Open meta file for writing
if (!FsHelpers::openFileForWrite("STC", cachePath + spineTocMetaBinFile, metaFile)) {
spineFile.close();
tocFile.close();
return false;
}
// Write 0s into first slots, LUT is written during `addSpineEntry` and `addTocEntry`, and counts are rewritten at
// the end
serialization::writePod(metaFile, SPINE_TOC_CACHE_VERSION);
serialization::writePod(metaFile, spineCount);
serialization::writePod(metaFile, tocCount);
Serial.printf("[%lu] [STC] Began writing cache files\n", millis());
return true;
}
size_t SpineTocCache::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 SpineTocCache::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 SpineTocCache::addSpineEntry(const std::string& href) {
if (!buildMode || !spineFile || !metaFile) {
Serial.printf("[%lu] [STC] addSpineEntry called but not in build mode\n", millis());
return;
}
const SpineEntry entry(href, 0, -1);
const auto position = writeSpineEntry(spineFile, entry);
serialization::writePod(metaFile, position);
spineCount++;
}
void SpineTocCache::addTocEntry(const std::string& title, const std::string& href, const std::string& anchor,
const uint8_t level) {
if (!buildMode || !tocFile || !metaFile) {
Serial.printf("[%lu] [STC] addTocEntry called but not in build mode\n", millis());
return;
}
const TocEntry entry(title, href, anchor, level, -1);
const auto position = writeTocEntry(tocFile, entry);
serialization::writePod(metaFile, position);
tocCount++;
}
bool SpineTocCache::endWrite() {
if (!buildMode) {
Serial.printf("[%lu] [STC] endWrite called but not in build mode\n", millis());
return false;
}
spineFile.close();
tocFile.close();
// Write correct counts into meta file
metaFile.seek(sizeof(SPINE_TOC_CACHE_VERSION));
serialization::writePod(metaFile, spineCount);
serialization::writePod(metaFile, tocCount);
metaFile.close();
buildMode = false;
Serial.printf("[%lu] [STC] Wrote %d spine, %d TOC entries\n", millis(), spineCount, tocCount);
return true;
}
SpineTocCache::SpineEntry SpineTocCache::readSpineEntry(File& file) const {
SpineEntry entry;
serialization::readString(file, entry.href);
serialization::readPod(file, entry.cumulativeSize);
serialization::readPod(file, entry.tocIndex);
return entry;
}
SpineTocCache::TocEntry SpineTocCache::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;
}
bool SpineTocCache::updateMapsAndSizes(const std::string& epubPath) {
Serial.printf("[%lu] [STC] Computing mappings and sizes for %d spine, %d TOC entries\n", millis(), spineCount,
tocCount);
std::vector<SpineEntry> spineEntries;
spineEntries.reserve(spineCount);
// Load only the spine items, update them in memory while loading one TOC at a time and storing it
{
if (!FsHelpers::openFileForRead("STC", cachePath + spineBinFile, spineFile)) {
return false;
}
for (int i = 0; i < spineCount; i++) {
spineEntries.push_back(readSpineEntry(spineFile));
}
spineFile.close();
}
// Iterate over TOC entries and update them with the spine mapping
// We do this by moving the TOC file and then making a new one parsing through both at the same time
{
SD.rename((cachePath + tocBinFile).c_str(), (cachePath + tocBinFile + ".tmp").c_str());
File tempTocFile;
if (!FsHelpers::openFileForRead("STC", cachePath + tocBinFile + ".tmp", tempTocFile)) {
SD.remove((cachePath + tocBinFile + ".tmp").c_str());
return false;
}
if (!FsHelpers::openFileForWrite("STC", cachePath + tocBinFile, tocFile)) {
tempTocFile.close();
SD.remove((cachePath + tocBinFile + ".tmp").c_str());
return false;
}
for (int i = 0; i < tocCount; i++) {
auto tocEntry = readTocEntry(tempTocFile);
// Find the matching spine entry
for (int j = 0; j < spineCount; j++) {
if (spineEntries[j].href == tocEntry.href) {
tocEntry.spineIndex = static_cast<int16_t>(j);
// Point the spine to the first TOC entry we come across (in the case that there are multiple)
if (spineEntries[j].tocIndex == -1) spineEntries[j].tocIndex = static_cast<int16_t>(i);
break;
}
}
writeTocEntry(tocFile, tocEntry);
}
tocFile.close();
tempTocFile.close();
SD.remove((cachePath + tocBinFile + ".tmp").c_str());
}
// By this point all the spine items in memory should have the right `tocIndex` and the TOC file is complete
// Next, compute cumulative sizes
{
const ZipFile zip("/sd" + epubPath);
size_t cumSize = 0;
for (int i = 0; i < spineCount; i++) {
size_t itemSize = 0;
const std::string path = FsHelpers::normalisePath(spineEntries[i].href);
if (zip.getInflatedFileSize(path.c_str(), &itemSize)) {
cumSize += itemSize;
spineEntries[i].cumulativeSize = cumSize;
} else {
Serial.printf("[%lu] [STC] Warning: Could not get size for spine item: %s\n", millis(), path.c_str());
}
}
Serial.printf("[%lu] [STC] Book size: %lu\n", millis(), cumSize);
}
// Rewrite spine file with updated data
{
if (!FsHelpers::openFileForWrite("STC", cachePath + spineBinFile, spineFile)) {
// metaFile.close();
return false;
}
for (const auto& entry : spineEntries) {
writeSpineEntry(spineFile, entry);
}
spineFile.close();
}
// Clear vectors to free memory
spineEntries.clear();
spineEntries.shrink_to_fit();
Serial.printf("[%lu] [STC] Updated cache with mappings and sizes\n", millis());
return true;
}
// Opens (and leaves open all three files for fast access)
bool SpineTocCache::load() {
// Load metadata
if (!FsHelpers::openFileForRead("STC", cachePath + spineTocMetaBinFile, metaFile)) {
return false;
}
uint8_t version;
serialization::readPod(metaFile, version);
if (version != SPINE_TOC_CACHE_VERSION) {
Serial.printf("[%lu] [STC] Cache version mismatch: expected %d, got %d\n", millis(), SPINE_TOC_CACHE_VERSION,
version);
metaFile.close();
return false;
}
if (!FsHelpers::openFileForRead("STC", cachePath + spineBinFile, spineFile)) {
metaFile.close();
return false;
}
if (!FsHelpers::openFileForRead("STC", cachePath + tocBinFile, tocFile)) {
metaFile.close();
spineFile.close();
return false;
}
serialization::readPod(metaFile, spineCount);
serialization::readPod(metaFile, tocCount);
loaded = true;
Serial.printf("[%lu] [STC] Loaded cache metadata: %d spine, %d TOC entries\n", millis(), spineCount, tocCount);
return true;
}
SpineTocCache::SpineEntry SpineTocCache::getSpineEntry(const int index) {
if (!loaded) {
Serial.printf("[%lu] [STC] getSpineEntry called but cache not loaded\n", millis());
return {};
}
if (index < 0 || index >= static_cast<int>(spineCount)) {
Serial.printf("[%lu] [STC] getSpineEntry index %d out of range\n", millis(), index);
return {};
}
// Seek to spine LUT item, read from LUT and get out data
metaFile.seek(SPINE_TOC_META_HEADER_SIZE + sizeof(size_t) * index);
size_t spineEntryPos;
serialization::readPod(metaFile, spineEntryPos);
spineFile.seek(spineEntryPos);
auto entry = readSpineEntry(spineFile);
return entry;
}
SpineTocCache::TocEntry SpineTocCache::getTocEntry(const int index) {
if (!loaded) {
Serial.printf("[%lu] [STC] getTocEntry called but cache not loaded\n", millis());
return {};
}
if (index < 0 || index >= static_cast<int>(tocCount)) {
Serial.printf("[%lu] [STC] getTocEntry index %d out of range\n", millis(), index);
return {};
}
// Seek to TOC LUT item, read from LUT and get out data
metaFile.seek(SPINE_TOC_META_HEADER_SIZE + sizeof(size_t) * spineCount + sizeof(size_t) * index);
size_t tocEntryPos;
serialization::readPod(metaFile, tocEntryPos);
tocFile.seek(tocEntryPos);
auto entry = readTocEntry(tocFile);
return entry;
}
int SpineTocCache::getSpineCount() const { return spineCount; }
int SpineTocCache::getTocCount() const { return tocCount; }
bool SpineTocCache::isLoaded() const { return loaded; }

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>
@ -214,9 +215,8 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false; return false;
} }
FILE* file = fopen(filepath, "r"); File file;
if (!file) { if (!FsHelpers::openFileForRead("EHP", filepath, file)) {
Serial.printf("[%lu] [EHP] Couldn't open file %s\n", millis(), filepath);
XML_ParserFree(parser); XML_ParserFree(parser);
return false; return false;
} }
@ -233,23 +233,23 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); 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_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); 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),
@ -258,7 +258,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser); XML_ParserFree(parser);
fclose(file); file.close();
return false; return false;
} }
} while (!done); } while (!done);
@ -267,7 +267,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); 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

@ -1,12 +1,16 @@
#include "ContentOpfParser.h" #include "ContentOpfParser.h"
#include <FsHelpers.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <Serialization.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);
@ -29,6 +33,12 @@ ContentOpfParser::~ContentOpfParser() {
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); }
@ -95,13 +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;
self->tempItemStore = SD.open("/.crosspoint/.tmp-items.bin", FILE_WRITE, true); 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;
self->tempItemStore = SD.open("/.crosspoint/.tmp-items.bin", FILE_READ); 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;
} }
@ -138,10 +156,13 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
} }
} }
// Write items down to SD card
serialization::writeString(self->tempItemStore, itemId); serialization::writeString(self->tempItemStore, itemId);
serialization::writeString(self->tempItemStore, href); serialization::writeString(self->tempItemStore, href);
// // Write items down to SD card
// self->items[itemId] = 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()) {
@ -154,7 +175,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
return; return;
} }
// NOTE: This relies on spine appearing after item manifest // 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 // Only run the spine parsing if there's a cache to add it to
if (self->cache) { 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)) {
@ -169,7 +190,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
serialization::readString(self->tempItemStore, itemId); serialization::readString(self->tempItemStore, itemId);
serialization::readString(self->tempItemStore, href); serialization::readString(self->tempItemStore, href);
if (itemId == idref) { if (itemId == idref) {
self->cache->addSpineEntry(href); self->cache->createSpineEntry(href);
break; break;
} }
} }

View File

@ -2,9 +2,10 @@
#include <Print.h> #include <Print.h>
#include "Epub.h" #include "Epub.h"
#include "Epub/SpineTocCache.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,
@ -15,12 +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;
SpineTocCache* cache; BookMetadataCache* cache;
File tempItemStore; 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);
@ -29,10 +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;
explicit ContentOpfParser(const std::string& baseContentPath, const size_t xmlSize, SpineTocCache* cache) explicit ContentOpfParser(const std::string& cachePath, const std::string& baseContentPath, const size_t xmlSize,
: baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {} 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) {
@ -168,7 +169,7 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) {
} }
if (self->cache) { if (self->cache) {
self->cache->addTocEntry(self->currentLabel, href, anchor, self->currentDepth); self->cache->createTocEntry(self->currentLabel, href, anchor, self->currentDepth);
} }
// Clear them so we don't re-add them if there are weird XML structures // Clear them so we don't re-add them if there are weird XML structures

View File

@ -1,10 +1,10 @@
#pragma once #pragma once
#include <Print.h> #include <Print.h>
#include <expat.h>
#include <string> #include <string>
#include "Epub/SpineTocCache.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 };
@ -13,7 +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;
SpineTocCache* cache; BookMetadataCache* cache;
std::string currentLabel; std::string currentLabel;
std::string currentSrc; std::string currentSrc;
@ -24,7 +24,7 @@ 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:
explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize, SpineTocCache* cache) explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache)
: baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {} : baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {}
~TocNcxParser() override; ~TocNcxParser() override;

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

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

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

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

View File

@ -3,7 +3,7 @@ crosspoint_version = 0.8.1
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,14 +69,30 @@ 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
Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis()); if (isApMode) {
WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame Serial.printf("[%lu] [WEBACT] Stopping WiFi AP...\n", millis());
delay(100); // Allow disconnect frame to be sent WiFi.softAPdisconnect(true);
} else {
Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis());
WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame
}
delay(100); // Allow disconnect frame to be sent
Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis()); Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis());
WiFi.mode(WIFI_OFF); WiFi.mode(WIFI_OFF);
@ -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
std::string ssidInfo = "Network: " + connectedSSID; renderer.drawCenteredText(READER_FONT_ID, 15, "File Transfer", true, BOLD);
if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "..."); 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;
if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
}
renderer.drawCenteredText(UI_FONT_ID, startY, ssidInfo.c_str(), true, REGULAR);
std::string ipInfo = "IP Address: " + connectedIP;
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ipInfo.c_str(), true, REGULAR);
// Show web server URL prominently
std::string webInfo = "http://" + connectedIP + "/";
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 2, webInfo.c_str(), true, BOLD);
// Also show hostname URL
std::string hostnameUrl = std::string("or http://") + AP_HOSTNAME + ".local/";
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser", true, REGULAR);
} }
renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR);
std::string ipInfo = "IP Address: " + connectedIP;
renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR);
// Show web server URL prominently
std::string webInfo = "http://" + connectedIP + "/";
renderer.drawCenteredText(UI_FONT_ID, top + 70, webInfo.c_str(), true, BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, top + 110, "Open this URL in your browser", true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press BACK to exit", true, REGULAR); 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->getSpineHref(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,14 +282,16 @@ void EpubReaderActivity::renderScreen() {
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
} }
File f = SD.open((epub->getCachePath() + "/progress.bin").c_str(), FILE_WRITE); File f;
uint8_t data[4]; if (FsHelpers::openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
data[0] = currentSpineIndex & 0xFF; uint8_t data[4];
data[1] = (currentSpineIndex >> 8) & 0xFF; data[0] = currentSpineIndex & 0xFF;
data[2] = section->currentPage & 0xFF; data[1] = (currentSpineIndex >> 8) & 0xFF;
data[3] = (section->currentPage >> 8) & 0xFF; data[2] = section->currentPage & 0xFF;
f.write(data, 4); data[3] = (section->currentPage >> 8) & 0xFF;
f.close(); f.write(data, 4);
f.close();
}
} }
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) { void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) {

View File

@ -160,7 +160,12 @@ void onGoHome() {
void setup() { void setup() {
t1 = millis(); t1 = millis();
Serial.begin(115200);
// Only start serial if USB connected
pinMode(UART0_RXD, INPUT);
if (digitalRead(UART0_RXD) == HIGH) {
Serial.begin(115200);
}
Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis());

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