Merge branch 'master' into hyphenation-v2

This commit is contained in:
Arthur Tazhitdinov 2025-12-25 19:41:27 +05:00
commit 54d7a9437e
83 changed files with 7070 additions and 1994 deletions

View File

@ -34,7 +34,7 @@ jobs:
sudo apt-get install -y clang-format-21
- name: Run cppcheck
run: pio check --fail-on-defect medium --fail-on-defect high
run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high
- name: Run clang-format
run: PATH="/usr/lib/llvm-21/bin:$PATH" ./bin/clang-format-fix && git diff --exit-code || (echo "Please run 'bin/clang-format-fix' to fix formatting issues" && exit 1)

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,12 +3,11 @@
#include <GfxRenderer.h>
#include <algorithm>
#include <iterator>
#include <utility>
#include <cmath>
#include <functional>
#include <limits>
#include <vector>
#include "hyphenation/Hyphenator.h"
constexpr int MAX_COST = std::numeric_limits<int>::max();
void ParsedText::addWord(std::string word, const EpdFontStyle fontStyle) {
@ -20,183 +19,164 @@ void ParsedText::addWord(std::string word, const EpdFontStyle fontStyle) {
// Consumes data to minimize memory usage
void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const int horizontalMargin,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine) {
const std::function<void(std::shared_ptr<TextBlock>)>& processLine,
const bool includeLastLine) {
if (words.empty()) {
return;
}
// horizontalMargin accounts for both left and right gutters, leaving the drawable width.
const int pageWidth = renderer.getScreenWidth() - horizontalMargin;
if (pageWidth <= 0) {
words.clear();
wordStyles.clear();
return;
const int spaceWidth = renderer.getSpaceWidth(fontId);
const auto wordWidths = calculateWordWidths(renderer, fontId);
const auto lineBreakIndices = computeLineBreaks(pageWidth, spaceWidth, wordWidths);
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
for (size_t i = 0; i < lineCount; ++i) {
extractLine(i, pageWidth, spaceWidth, wordWidths, lineBreakIndices, processLine);
}
}
std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) {
const size_t totalWordCount = words.size();
std::vector<uint16_t> wordWidths;
wordWidths.reserve(totalWordCount);
// add em-space at the beginning of first word in paragraph to indent
if (!extraParagraphSpacing) {
std::string& first_word = words.front();
first_word.insert(0, "\xe2\x80\x83");
}
const int spaceWidth = renderer.getSpaceWidth(fontId);
// Maintain classic prose indenting when extra paragraph spacing is disabled.
const bool allowIndent = !extraParagraphSpacing && (style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN);
const bool allowHyphenation = hyphenationEnabled;
const int indentWidth = allowIndent ? renderer.getTextWidth(fontId, "m", REGULAR) : 0;
const int firstLinePageWidth = allowIndent ? std::max(pageWidth - indentWidth, 0) : pageWidth;
auto pageWidthForLine = [&](const bool isFirstLine) -> int { return isFirstLine ? firstLinePageWidth : pageWidth; };
auto wordsIt = words.begin();
auto wordStylesIt = wordStyles.begin();
auto wordIt = words.begin();
auto styleIt = wordStyles.begin();
auto lineStartWordIt = wordIt;
auto lineStartStyleIt = styleIt;
while (wordsIt != words.end()) {
wordWidths.push_back(renderer.getTextWidth(fontId, wordsIt->c_str(), *wordStylesIt));
int lineWidthWithSpaces = 0;
int lineWordWidthSum = 0;
size_t lineWordCount = 0;
std::vector<uint16_t> lineWordWidths;
lineWordWidths.reserve(16);
std::advance(wordsIt, 1);
std::advance(wordStylesIt, 1);
}
// Guard against malicious/invalid content generating unbounded line counts.
size_t producedLines = 0;
return wordWidths;
}
std::vector<size_t> ParsedText::computeLineBreaks(const int pageWidth, const int spaceWidth,
const std::vector<uint16_t>& wordWidths) const {
const size_t totalWordCount = words.size();
// DP table to store the minimum badness (cost) of lines starting at index i
std::vector<int> dp(totalWordCount);
// 'ans[i]' stores the index 'j' of the *last word* in the optimal line starting at 'i'
std::vector<size_t> ans(totalWordCount);
// Base Case
dp[totalWordCount - 1] = 0;
ans[totalWordCount - 1] = totalWordCount - 1;
for (int i = totalWordCount - 2; i >= 0; --i) {
int currlen = -spaceWidth;
dp[i] = MAX_COST;
for (size_t j = i; j < totalWordCount; ++j) {
// Current line length: previous width + space + current word width
currlen += wordWidths[j] + spaceWidth;
if (currlen > pageWidth) {
break;
}
int cost;
if (j == totalWordCount - 1) {
cost = 0; // Last line
} else {
const int remainingSpace = pageWidth - currlen;
// Use long long for the square to prevent overflow
const long long cost_ll = static_cast<long long>(remainingSpace) * remainingSpace + dp[j + 1];
if (cost_ll > MAX_COST) {
cost = MAX_COST;
} else {
cost = static_cast<int>(cost_ll);
}
}
if (cost < dp[i]) {
dp[i] = cost;
ans[i] = j; // j is the index of the last word in this optimal line
}
}
}
// Stores the index of the word that starts the next line (last_word_index + 1)
std::vector<size_t> lineBreakIndices;
size_t currentWordIndex = 0;
constexpr size_t MAX_LINES = 1000;
// commitLine moves buffered words/styles into a TextBlock and delivers it upstream.
auto commitLine = [&](const bool isLastLine) {
if (lineWordCount == 0) {
return;
while (currentWordIndex < totalWordCount) {
if (lineBreakIndices.size() >= MAX_LINES) {
break;
}
const bool isFirstLine = producedLines == 0;
const int linePageWidth = pageWidthForLine(isFirstLine);
size_t nextBreakIndex = ans[currentWordIndex] + 1;
lineBreakIndices.push_back(nextBreakIndex);
std::list<std::string> lineWords;
std::list<EpdFontStyle> lineStyles;
auto wordEndIt = wordIt;
auto styleEndIt = styleIt;
lineWords.splice(lineWords.begin(), words, lineStartWordIt, wordEndIt);
lineStyles.splice(lineStyles.begin(), wordStyles, lineStartStyleIt, styleEndIt);
const int gaps = lineWordCount > 0 ? static_cast<int>(lineWordCount - 1) : 0;
const int baseSpaceTotal = spaceWidth * gaps;
const int spaceBudget = linePageWidth - lineWordWidthSum;
int spacing = spaceWidth;
int spacingRemainder = 0;
if (style == TextBlock::JUSTIFIED && !isLastLine && gaps > 0) {
// Spread the remaining width evenly across the gaps for justification.
const int additional = std::max(0, spaceBudget - baseSpaceTotal);
spacing = spaceWidth + (gaps > 0 ? additional / gaps : 0);
spacingRemainder = (gaps > 0) ? additional % gaps : 0;
}
int renderedWidth = lineWordWidthSum;
if (gaps > 0) {
renderedWidth += spacing * gaps;
}
uint16_t xpos = 0;
if (style == TextBlock::RIGHT_ALIGN) {
xpos = renderedWidth < linePageWidth ? linePageWidth - renderedWidth : 0;
} else if (style == TextBlock::CENTER_ALIGN) {
xpos = renderedWidth < linePageWidth ? (linePageWidth - renderedWidth) / 2 : 0;
} else if (allowIndent && isFirstLine) {
xpos = indentWidth;
}
// Cache the x positions for each word so TextBlock can render without recomputing layout.
std::list<uint16_t> lineXPos;
for (size_t idx = 0; idx < lineWordWidths.size(); ++idx) {
lineXPos.push_back(xpos);
xpos += lineWordWidths[idx];
if (idx + 1 < lineWordWidths.size()) {
int gap = spacing;
if (spacingRemainder > 0) {
gap += 1;
spacingRemainder--;
}
xpos += gap;
}
}
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineStyles), style));
producedLines++;
lineWordWidths.clear();
lineWordWidthSum = 0;
lineWidthWithSpaces = 0;
lineWordCount = 0;
lineStartWordIt = wordIt;
lineStartStyleIt = styleIt;
};
while (wordIt != words.end() && producedLines < MAX_LINES) {
const int currentLinePageWidth = pageWidthForLine(producedLines == 0);
if (lineWordCount == 0) {
lineStartWordIt = wordIt;
lineStartStyleIt = styleIt;
}
const int wordWidth = renderer.getTextWidth(fontId, wordIt->c_str(), *styleIt);
const int gapWidth = (lineWordCount > 0) ? spaceWidth : 0;
const int candidateWidth = lineWidthWithSpaces + gapWidth + wordWidth;
if (candidateWidth <= currentLinePageWidth) {
lineWordWidths.push_back(static_cast<uint16_t>(wordWidth));
lineWordWidthSum += wordWidth;
lineWidthWithSpaces = candidateWidth;
lineWordCount++;
++wordIt;
++styleIt;
continue;
}
const int availableWidth = currentLinePageWidth - lineWidthWithSpaces - gapWidth;
if (lineWordCount > 0 && availableWidth <= 0) {
commitLine(false);
continue;
}
if (allowHyphenation && lineWordCount > 0 && availableWidth > 0) {
// Try hyphenating the next word so the current line stays compact.
HyphenationResult split;
if (Hyphenator::splitWord(renderer, fontId, *wordIt, *styleIt, availableWidth, &split, false)) {
*wordIt = std::move(split.head);
auto nextWordIt = std::next(wordIt);
auto nextStyleIt = std::next(styleIt);
words.insert(nextWordIt, std::move(split.tail));
wordStyles.insert(nextStyleIt, *styleIt);
continue;
}
}
if (lineWordCount == 0) {
HyphenationResult split;
// Single overlong words get force-split so they can be displayed within the margins.
if (Hyphenator::splitWord(renderer, fontId, *wordIt, *styleIt, currentLinePageWidth, &split, true)) {
*wordIt = std::move(split.head);
auto nextWordIt = std::next(wordIt);
auto nextStyleIt = std::next(styleIt);
words.insert(nextWordIt, std::move(split.tail));
wordStyles.insert(nextStyleIt, *styleIt);
continue;
}
lineWordWidths.push_back(static_cast<uint16_t>(wordWidth));
lineWordWidthSum += wordWidth;
lineWidthWithSpaces = candidateWidth;
lineWordCount = 1;
++wordIt;
++styleIt;
commitLine(wordIt == words.end());
continue;
}
// No more tricks available; flush the collected words and move on.
commitLine(false);
currentWordIndex = nextBreakIndex;
}
if (lineWordCount > 0 && producedLines < MAX_LINES) {
commitLine(true);
return lineBreakIndices;
}
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth,
const std::vector<uint16_t>& wordWidths, const std::vector<size_t>& lineBreakIndices,
const std::function<void(std::shared_ptr<TextBlock>)>& processLine) {
const size_t lineBreak = lineBreakIndices[breakIndex];
const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0;
const size_t lineWordCount = lineBreak - lastBreakAt;
// Calculate total word width for this line
int lineWordWidthSum = 0;
for (size_t i = lastBreakAt; i < lineBreak; i++) {
lineWordWidthSum += wordWidths[i];
}
words.clear();
wordStyles.clear();
// Calculate spacing
const int spareSpace = pageWidth - lineWordWidthSum;
int spacing = spaceWidth;
const bool isLastLine = breakIndex == lineBreakIndices.size() - 1;
if (style == TextBlock::JUSTIFIED && !isLastLine && lineWordCount >= 2) {
spacing = spareSpace / (lineWordCount - 1);
}
// Calculate initial x position
uint16_t xpos = 0;
if (style == TextBlock::RIGHT_ALIGN) {
xpos = spareSpace - (lineWordCount - 1) * spaceWidth;
} else if (style == TextBlock::CENTER_ALIGN) {
xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
}
// Pre-calculate X positions for words
std::list<uint16_t> lineXPos;
for (size_t i = lastBreakAt; i < lineBreak; i++) {
const uint16_t currentWordWidth = wordWidths[i];
lineXPos.push_back(xpos);
xpos += currentWordWidth + spacing;
}
// Iterators always start at the beginning as we are moving content with splice below
auto wordEndIt = words.begin();
auto wordStyleEndIt = wordStyles.begin();
std::advance(wordEndIt, lineWordCount);
std::advance(wordStyleEndIt, lineWordCount);
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
std::list<std::string> lineWords;
lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
std::list<EpdFontStyle> lineWordStyles;
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
processLine(std::make_shared<TextBlock>(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style));
}

View File

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

View File

@ -1,11 +1,9 @@
#include "Section.h"
#include <FsHelpers.h>
#include <SD.h>
#include <Serialization.h>
#include <fstream>
#include "FsHelpers.h"
#include "Page.h"
#include "parsers/ChapterHtmlSlimParser.h"
@ -16,7 +14,10 @@ constexpr uint8_t SECTION_FILE_VERSION = 6;
void Section::onPageComplete(std::unique_ptr<Page> page) {
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);
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,
const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing, const bool hyphenationEnabled) 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, fontId);
serialization::writePod(outputFile, lineCompression);
@ -45,17 +49,12 @@ void Section::writeCacheMetadata(const int fontId, const float lineCompression,
bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const int marginTop,
const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing, const bool hyphenationEnabled) {
if (!SD.exists(cachePath.c_str())) {
return false;
}
const auto sectionFilePath = cachePath + "/section.bin";
if (!SD.exists(sectionFilePath.c_str())) {
File inputFile;
if (!FsHelpers::openFileForRead("SCT", sectionFilePath, inputFile)) {
return false;
}
std::ifstream inputFile(("/sd" + sectionFilePath).c_str());
// Match parameters
{
uint8_t version;
@ -120,15 +119,14 @@ bool Section::clearCache() const {
bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop,
const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing, const bool hyphenationEnabled) {
const auto localPath = epub->getSpineItem(spineIndex);
// 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 localPath = epub->getSpineItem(spineIndex).href;
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
File f = SD.open(tmpHtmlPath.c_str(), FILE_WRITE, true);
bool success = epub->readItemContentsToStream(localPath, f, 1024);
f.close();
File tmpHtml;
if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
return false;
}
bool success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
tmpHtml.close();
if (!success) {
Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file\n", millis());
@ -137,10 +135,8 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression,
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s\n", millis(), tmpHtmlPath.c_str());
const auto sdTmpHtmlPath = "/sd" + tmpHtmlPath;
ChapterHtmlSlimParser visitor(sdTmpHtmlPath.c_str(), renderer, fontId, lineCompression, marginTop, marginRight,
marginBottom, marginLeft, extraParagraphSpacing, hyphenationEnabled,
ChapterHtmlSlimParser visitor(tmpHtmlPath, renderer, fontId, lineCompression, marginTop, marginRight, marginBottom,
marginLeft, extraParagraphSpacing, hyphenationEnabled,
[this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); });
success = visitor.parseAndBuildPages();
@ -157,13 +153,12 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression,
}
std::unique_ptr<Page> Section::loadPageFromSD() const {
const auto filePath = "/sd" + 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());
const auto filePath = cachePath + "/page_" + std::to_string(currentPage) + ".bin";
File inputFile;
if (!FsHelpers::openFileForRead("SCT", filePath, inputFile)) {
return nullptr;
}
std::ifstream inputFile(filePath);
auto page = Page::deserialize(inputFile);
inputFile.close();
return page;

View File

@ -21,9 +21,10 @@ class Section {
int currentPage = 0;
explicit Section(const std::shared_ptr<Epub>& epub, const int spineIndex, GfxRenderer& renderer)
: epub(epub), spineIndex(spineIndex), renderer(renderer) {
cachePath = epub->getCachePath() + "/" + std::to_string(spineIndex);
}
: epub(epub),
spineIndex(spineIndex),
renderer(renderer),
cachePath(epub->getCachePath() + "/" + std::to_string(spineIndex)) {}
~Section() = default;
bool loadCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom,
int marginLeft, bool extraParagraphSpacing, bool hyphenationEnabled);

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
const uint32_t wc = words.size();
serialization::writePod(os, wc);
for (const auto& w : words) serialization::writeString(os, w);
serialization::writePod(file, wc);
for (const auto& w : words) serialization::writeString(file, w);
// wordXpos
const uint32_t xc = wordXpos.size();
serialization::writePod(os, xc);
for (auto x : wordXpos) serialization::writePod(os, x);
serialization::writePod(file, xc);
for (auto x : wordXpos) serialization::writePod(file, x);
// wordStyles
const uint32_t sc = wordStyles.size();
serialization::writePod(os, sc);
for (auto s : wordStyles) serialization::writePod(os, s);
serialization::writePod(file, sc);
for (auto s : wordStyles) serialization::writePod(file, s);
// 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;
std::list<std::string> words;
std::list<uint16_t> wordXpos;
@ -45,22 +45,22 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(std::istream& is) {
BLOCK_STYLE style;
// words
serialization::readPod(is, wc);
serialization::readPod(file, wc);
words.resize(wc);
for (auto& w : words) serialization::readString(is, w);
for (auto& w : words) serialization::readString(file, w);
// wordXpos
serialization::readPod(is, xc);
serialization::readPod(file, xc);
wordXpos.resize(xc);
for (auto& x : wordXpos) serialization::readPod(is, x);
for (auto& x : wordXpos) serialization::readPod(file, x);
// wordStyles
serialization::readPod(is, sc);
serialization::readPod(file, sc);
wordStyles.resize(sc);
for (auto& s : wordStyles) serialization::readPod(is, s);
for (auto& s : wordStyles) serialization::readPod(file, s);
// 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));
}

View File

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

View File

@ -1,5 +1,6 @@
#include "ChapterHtmlSlimParser.h"
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <expat.h>
@ -10,13 +11,13 @@
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
const char* BLOCK_TAGS[] = {"p", "li", "div", "br"};
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
const char* BOLD_TAGS[] = {"b"};
const char* BOLD_TAGS[] = {"b", "strong"};
constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]);
const char* ITALIC_TAGS[] = {"i"};
const char* ITALIC_TAGS[] = {"i", "em"};
constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]);
const char* IMAGE_TAGS[] = {"img"};
@ -143,6 +144,17 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
self->partWordBuffer[self->partWordBufferIndex++] = s[i];
}
// If we have > 750 words buffered up, perform the layout and consume out all but the last line
// There should be enough here to build out 1-2 full pages and doing this will free up a lot of
// memory.
// Spotted when reading Intermezzo, there are some really long text blocks in there.
if (self->currentTextBlock->size() > 750) {
Serial.printf("[%lu] [EHP] Text block too long, splitting into multiple pages\n", millis());
self->currentTextBlock->layoutAndExtractLines(
self->renderer, self->fontId, self->marginLeft + self->marginRight,
[self](const std::shared_ptr<TextBlock>& textBlock) { self->addLineToPage(textBlock); }, false);
}
}
void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) {
@ -203,48 +215,59 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false;
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
FILE* file = fopen(filepath, "r");
if (!file) {
Serial.printf("[%lu] [EHP] Couldn't open file %s\n", millis(), filepath);
File file;
if (!FsHelpers::openFileForRead("EHP", filepath, file)) {
XML_ParserFree(parser);
return false;
}
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
do {
void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) {
Serial.printf("[%lu] [EHP] Couldn't allocate memory for buffer\n", millis());
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
fclose(file);
file.close();
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());
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
fclose(file);
file.close();
return false;
}
done = feof(file);
done = file.available() == 0;
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),
XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
fclose(file);
file.close();
return false;
}
} while (!done);
XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr);
XML_ParserFree(parser);
fclose(file);
file.close();
// Process last page if there is still text
if (currentTextBlock) {

View File

@ -15,7 +15,7 @@ class GfxRenderer;
#define MAX_WORD_SIZE 200
class ChapterHtmlSlimParser {
const char* filepath;
const std::string& filepath;
GfxRenderer& renderer;
std::function<void(std::unique_ptr<Page>)> completePageFn;
int depth = 0;
@ -46,7 +46,7 @@ class ChapterHtmlSlimParser {
static void XMLCALL endElement(void* userData, const XML_Char* name);
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 int marginBottom, const int marginLeft, const bool extraParagraphSpacing,
const bool hyphenationEnabled,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

@ -128,7 +128,7 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const {
int bitShift = 6;
// Helper lambda to pack 2bpp color into the output stream
auto packPixel = [&](uint8_t lum) {
auto packPixel = [&](const uint8_t lum) {
uint8_t color = (lum >> 6); // Simple 2-bit reduction: 0-255 -> 0-3
currentOutByte |= (color << bitShift);
if (bitShift == 0) {
@ -140,38 +140,49 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const {
}
};
uint8_t lum;
switch (bpp) {
case 8: {
case 32: {
const uint8_t* p = rowBuffer;
for (int x = 0; x < width; x++) {
packPixel(paletteLum[rowBuffer[x]]);
lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
packPixel(lum);
p += 4;
}
break;
}
case 24: {
const uint8_t* p = rowBuffer;
for (int x = 0; x < width; x++) {
uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
packPixel(lum);
p += 3;
}
break;
}
case 8: {
for (int x = 0; x < width; x++) {
packPixel(paletteLum[rowBuffer[x]]);
}
break;
}
case 2: {
for (int x = 0; x < width; x++) {
lum = paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03];
packPixel(lum);
}
break;
}
case 1: {
for (int x = 0; x < width; x++) {
uint8_t lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00;
lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00;
packPixel(lum);
}
break;
}
case 32: {
const uint8_t* p = rowBuffer;
for (int x = 0; x < width; x++) {
uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
packPixel(lum);
p += 4;
}
break;
}
default:
return BmpReaderError::UnsupportedBpp;
}
// Flush remaining bits if width is not a multiple of 4

View File

@ -136,6 +136,13 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) {
Serial.printf("[%lu] [GFX] !! Failed to allocate BMP row buffers\n", millis());
free(outputRow);
free(rowBytes);
return;
}
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
// The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative).
// Screen's (0, 0) is the top-left corner.
@ -183,6 +190,10 @@ void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScre
void GfxRenderer::invertScreen() const {
uint8_t* buffer = einkDisplay.getFrameBuffer();
if (!buffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis());
return;
}
for (int i = 0; i < EInkDisplay::BUFFER_SIZE; i++) {
buffer[i] = ~buffer[i];
}
@ -256,6 +267,10 @@ void GfxRenderer::freeBwBufferChunks() {
*/
void GfxRenderer::storeBwBuffer() {
const uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
if (!frameBuffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis());
return;
}
// Allocate and copy each chunk
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
@ -306,6 +321,12 @@ void GfxRenderer::restoreBwBuffer() {
}
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
if (!frameBuffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis());
freeBwBufferChunks();
return;
}
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
// Check if chunk is missing
if (!bwBufferChunks[i]) {

View File

@ -0,0 +1,258 @@
#include "JpegToBmpConverter.h"
#include <picojpeg.h>
#include <cstdio>
#include <cstring>
// Context structure for picojpeg callback
struct JpegReadContext {
File& file;
uint8_t buffer[512];
size_t bufferPos;
size_t bufferFilled;
};
// Helper function: Convert 8-bit grayscale to 2-bit (0-3)
uint8_t JpegToBmpConverter::grayscaleTo2Bit(const uint8_t grayscale) {
// Simple threshold mapping:
// 0-63 -> 0 (black)
// 64-127 -> 1 (dark gray)
// 128-191 -> 2 (light gray)
// 192-255 -> 3 (white)
return grayscale >> 6;
}
inline void write16(Print& out, const uint16_t value) {
// out.write(reinterpret_cast<const uint8_t *>(&value), 2);
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
}
inline void write32(Print& out, const uint32_t value) {
// out.write(reinterpret_cast<const uint8_t *>(&value), 4);
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
out.write((value >> 16) & 0xFF);
out.write((value >> 24) & 0xFF);
}
inline void write32Signed(Print& out, const int32_t value) {
// out.write(reinterpret_cast<const uint8_t *>(&value), 4);
out.write(value & 0xFF);
out.write((value >> 8) & 0xFF);
out.write((value >> 16) & 0xFF);
out.write((value >> 24) & 0xFF);
}
// Helper function: Write BMP header with 2-bit color depth
void JpegToBmpConverter::writeBmpHeader(Print& bmpOut, const int width, const int height) {
// Calculate row padding (each row must be multiple of 4 bytes)
const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up
const int imageSize = bytesPerRow * height;
const uint32_t fileSize = 70 + imageSize; // 14 (file header) + 40 (DIB header) + 16 (palette) + image
// BMP File Header (14 bytes)
bmpOut.write('B');
bmpOut.write('M');
write32(bmpOut, fileSize); // File size
write32(bmpOut, 0); // Reserved
write32(bmpOut, 70); // Offset to pixel data
// DIB Header (BITMAPINFOHEADER - 40 bytes)
write32(bmpOut, 40);
write32Signed(bmpOut, width);
write32Signed(bmpOut, -height); // Negative height = top-down bitmap
write16(bmpOut, 1); // Color planes
write16(bmpOut, 2); // Bits per pixel (2 bits)
write32(bmpOut, 0); // BI_RGB (no compression)
write32(bmpOut, imageSize);
write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI)
write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI)
write32(bmpOut, 4); // colorsUsed
write32(bmpOut, 4); // colorsImportant
// Color Palette (4 colors x 4 bytes = 16 bytes)
// Format: Blue, Green, Red, Reserved (BGRA)
uint8_t palette[16] = {
0x00, 0x00, 0x00, 0x00, // Color 0: Black
0x55, 0x55, 0x55, 0x00, // Color 1: Dark gray (85)
0xAA, 0xAA, 0xAA, 0x00, // Color 2: Light gray (170)
0xFF, 0xFF, 0xFF, 0x00 // Color 3: White
};
for (const uint8_t i : palette) {
bmpOut.write(i);
}
}
// Callback function for picojpeg to read JPEG data
unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const unsigned char buf_size,
unsigned char* pBytes_actually_read, void* pCallback_data) {
auto* context = static_cast<JpegReadContext*>(pCallback_data);
if (!context || !context->file) {
return PJPG_STREAM_READ_ERROR;
}
// Check if we need to refill our context buffer
if (context->bufferPos >= context->bufferFilled) {
context->bufferFilled = context->file.read(context->buffer, sizeof(context->buffer));
context->bufferPos = 0;
if (context->bufferFilled == 0) {
// EOF or error
*pBytes_actually_read = 0;
return 0; // Success (EOF is normal)
}
}
// Copy available bytes to picojpeg's buffer
const size_t available = context->bufferFilled - context->bufferPos;
const size_t toRead = available < buf_size ? available : buf_size;
memcpy(pBuf, context->buffer + context->bufferPos, toRead);
context->bufferPos += toRead;
*pBytes_actually_read = static_cast<unsigned char>(toRead);
return 0; // Success
}
// Core function: Convert JPEG file to 2-bit BMP
bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) {
Serial.printf("[%lu] [JPG] Converting JPEG to BMP\n", millis());
// Setup context for picojpeg callback
JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0};
// Initialize picojpeg decoder
pjpeg_image_info_t imageInfo;
const unsigned char status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
if (status != 0) {
Serial.printf("[%lu] [JPG] JPEG decode init failed with error code: %d\n", millis(), status);
return false;
}
Serial.printf("[%lu] [JPG] JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d\n", millis(), imageInfo.m_width,
imageInfo.m_height, imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol);
// Write BMP header
writeBmpHeader(bmpOut, imageInfo.m_width, imageInfo.m_height);
// Calculate row parameters
const int bytesPerRow = (imageInfo.m_width * 2 + 31) / 32 * 4;
// Allocate row buffer for packed 2-bit pixels
auto* rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow));
if (!rowBuffer) {
Serial.printf("[%lu] [JPG] Failed to allocate row buffer\n", millis());
return false;
}
// Allocate a buffer for one MCU row worth of grayscale pixels
// This is the minimal memory needed for streaming conversion
const int mcuPixelHeight = imageInfo.m_MCUHeight;
const int mcuRowPixels = imageInfo.m_width * mcuPixelHeight;
auto* mcuRowBuffer = static_cast<uint8_t*>(malloc(mcuRowPixels));
if (!mcuRowBuffer) {
Serial.printf("[%lu] [JPG] Failed to allocate MCU row buffer\n", millis());
free(rowBuffer);
return false;
}
// Process MCUs row-by-row and write to BMP as we go (top-down)
const int mcuPixelWidth = imageInfo.m_MCUWidth;
for (int mcuY = 0; mcuY < imageInfo.m_MCUSPerCol; mcuY++) {
// Clear the MCU row buffer
memset(mcuRowBuffer, 0, mcuRowPixels);
// Decode one row of MCUs
for (int mcuX = 0; mcuX < imageInfo.m_MCUSPerRow; mcuX++) {
const unsigned char mcuStatus = pjpeg_decode_mcu();
if (mcuStatus != 0) {
if (mcuStatus == PJPG_NO_MORE_BLOCKS) {
Serial.printf("[%lu] [JPG] Unexpected end of blocks at MCU (%d, %d)\n", millis(), mcuX, mcuY);
} else {
Serial.printf("[%lu] [JPG] JPEG decode MCU failed at (%d, %d) with error code: %d\n", millis(), mcuX, mcuY,
mcuStatus);
}
free(mcuRowBuffer);
free(rowBuffer);
return false;
}
// Process MCU block into MCU row buffer
// MCUs are composed of 8x8 blocks. For 16x16 MCUs, there are four 8x8 blocks:
// Block layout for 16x16 MCU: [0, 64] (top row of blocks)
// [128, 192] (bottom row of blocks)
for (int blockY = 0; blockY < mcuPixelHeight; blockY++) {
for (int blockX = 0; blockX < mcuPixelWidth; blockX++) {
const int pixelX = mcuX * mcuPixelWidth + blockX;
// Skip pixels outside image width (can happen with MCU alignment)
if (pixelX >= imageInfo.m_width) {
continue;
}
// Calculate which 8x8 block and position within that block
const int block8x8Col = blockX / 8; // 0 or 1 for 16-wide MCU
const int block8x8Row = blockY / 8; // 0 or 1 for 16-tall MCU
const int pixelInBlockX = blockX % 8;
const int pixelInBlockY = blockY % 8;
// Calculate byte offset: each 8x8 block is 64 bytes
// Blocks are arranged: [0, 64], [128, 192]
const int blockOffset = (block8x8Row * (mcuPixelWidth / 8) + block8x8Col) * 64;
const int mcuIndex = blockOffset + pixelInBlockY * 8 + pixelInBlockX;
// Get grayscale value
uint8_t gray;
if (imageInfo.m_comps == 1) {
// Grayscale image
gray = imageInfo.m_pMCUBufR[mcuIndex];
} else {
// RGB image - convert to grayscale
const uint8_t r = imageInfo.m_pMCUBufR[mcuIndex];
const uint8_t g = imageInfo.m_pMCUBufG[mcuIndex];
const uint8_t b = imageInfo.m_pMCUBufB[mcuIndex];
// Luminance formula: Y = 0.299*R + 0.587*G + 0.114*B
// Using integer approximation: (30*R + 59*G + 11*B) / 100
gray = (r * 30 + g * 59 + b * 11) / 100;
}
// Store grayscale value in MCU row buffer
mcuRowBuffer[blockY * imageInfo.m_width + pixelX] = gray;
}
}
}
// Write all pixel rows from this MCU row to BMP file
const int startRow = mcuY * mcuPixelHeight;
const int endRow = (mcuY + 1) * mcuPixelHeight;
for (int y = startRow; y < endRow && y < imageInfo.m_height; y++) {
memset(rowBuffer, 0, bytesPerRow);
// Pack 4 pixels per byte (2 bits each)
for (int x = 0; x < imageInfo.m_width; x++) {
const int bufferY = y - startRow;
const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x];
const uint8_t twoBit = grayscaleTo2Bit(gray);
const int byteIndex = (x * 2) / 8;
const int bitOffset = 6 - ((x * 2) % 8); // 6, 4, 2, 0
rowBuffer[byteIndex] |= (twoBit << bitOffset);
}
// Write row with padding
bmpOut.write(rowBuffer, bytesPerRow);
}
}
// Clean up
free(mcuRowBuffer);
free(rowBuffer);
Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis());
return true;
}

View File

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

View File

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

View File

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

View File

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

2087
lib/picojpeg/picojpeg.c Normal file

File diff suppressed because it is too large Load Diff

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

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

View File

@ -1,16 +1,16 @@
[platformio]
crosspoint_version = 0.7.0
crosspoint_version = 0.9.0
default_envs = default
[base]
platform = espressif32
platform = espressif32 @ 6.12.0
board = esp32-c3-devkitm-1
framework = arduino
monitor_speed = 115200
upload_speed = 921600
check_tool = cppcheck
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
check_skip_packages = yes
check_severity = medium, high
board_upload.flash_size = 16MB
board_upload.maximum_size = 16777216
@ -39,6 +39,7 @@ lib_deps =
BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor
InputManager=symlink://open-x4-sdk/libs/hardware/InputManager
EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay
ArduinoJson @ 7.4.2
[env:default]
extends = base

View File

@ -1,29 +1,31 @@
#include "CrossPointSettings.h"
#include <FsHelpers.h>
#include <HardwareSerial.h>
#include <SD.h>
#include <Serialization.h>
#include <cstdint>
#include <fstream>
// Initialize the static instance
CrossPointSettings CrossPointSettings::instance;
namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
constexpr uint8_t SETTINGS_COUNT = 4;
constexpr char SETTINGS_FILE[] = "/sd/.crosspoint/settings.bin";
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace
bool CrossPointSettings::saveToFile() const {
// Make sure the directory exists
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_COUNT);
serialization::writePod(outputFile, whiteSleepScreen);
serialization::writePod(outputFile, sleepScreen);
serialization::writePod(outputFile, extraParagraphSpacing);
serialization::writePod(outputFile, shortPwrBtn);
serialization::writePod(outputFile, hyphenationEnabled);
@ -34,13 +36,11 @@ bool CrossPointSettings::saveToFile() const {
}
bool CrossPointSettings::loadFromFile() {
if (!SD.exists(SETTINGS_FILE + 3)) { // +3 to skip "/sd" prefix
Serial.printf("[%lu] [CPS] Settings file does not exist, using defaults\n", millis());
File inputFile;
if (!FsHelpers::openFileForRead("CPS", SETTINGS_FILE, inputFile)) {
return false;
}
std::ifstream inputFile(SETTINGS_FILE);
uint8_t version;
serialization::readPod(inputFile, version);
if (version != SETTINGS_FILE_VERSION) {
@ -55,7 +55,7 @@ bool CrossPointSettings::loadFromFile() {
// load settings that exist
uint8_t settingsRead = 0;
do {
serialization::readPod(inputFile, whiteSleepScreen);
serialization::readPod(inputFile, sleepScreen);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, extraParagraphSpacing);
if (++settingsRead >= fileSettingsCount) break;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
#include "HomeActivity.h"
#include <GfxRenderer.h>
#include <InputManager.h>
#include <SD.h>
#include "config.h"
@ -15,6 +16,8 @@ void HomeActivity::taskTrampoline(void* param) {
}
void HomeActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
selectorIndex = 0;
@ -31,6 +34,8 @@ void HomeActivity::onEnter() {
}
void HomeActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
@ -79,8 +84,8 @@ void HomeActivity::displayTaskLoop() {
void HomeActivity::render() const {
renderer.clearScreen();
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD);
// Draw selection

View File

@ -23,7 +23,7 @@ class HomeActivity final : public Activity {
public:
explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onReaderOpen,
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen)
: Activity(renderer, inputManager),
: Activity("Home", renderer, inputManager),
onReaderOpen(onReaderOpen),
onSettingsOpen(onSettingsOpen),
onFileTransferOpen(onFileTransferOpen) {}

View File

@ -1,23 +1,44 @@
#include "CrossPointWebServerActivity.h"
#include <DNSServer.h>
#include <ESPmDNS.h>
#include <GfxRenderer.h>
#include <InputManager.h>
#include <WiFi.h>
#include "NetworkModeSelectionActivity.h"
#include "WifiSelectionActivity.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) {
auto* self = static_cast<CrossPointWebServerActivity*>(param);
self->displayTaskLoop();
}
void CrossPointWebServerActivity::onEnter() {
Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onEnter ==========\n", millis());
ActivityWithSubactivity::onEnter();
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onEnter: %d bytes\n", millis(), ESP.getFreeHeap());
renderingMutex = xSemaphoreCreateMutex();
// Reset state
state = WebServerActivityState::WIFI_SELECTION;
state = WebServerActivityState::MODE_SELECTION;
networkMode = NetworkMode::JOIN_NETWORK;
isApMode = false;
connectedIP.clear();
connectedSSID.clear();
lastHandleClientTime = 0;
@ -30,19 +51,17 @@ void CrossPointWebServerActivity::onEnter() {
&displayTaskHandle // Task handle
);
// Turn on WiFi immediately
Serial.printf("[%lu] [WEBACT] Turning on WiFi...\n", millis());
WiFi.mode(WIFI_STA);
// Launch WiFi selection subactivity
Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis());
wifiSelection.reset(new WifiSelectionActivity(renderer, inputManager,
[this](bool connected) { onWifiSelectionComplete(connected); }));
wifiSelection->onEnter();
// Launch network mode selection subactivity
Serial.printf("[%lu] [WEBACT] Launching NetworkModeSelectionActivity...\n", millis());
enterNewActivity(new NetworkModeSelectionActivity(
renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); },
[this]() { onGoBack(); } // Cancel goes back to home
));
}
void CrossPointWebServerActivity::onExit() {
Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onExit START ==========\n", millis());
ActivityWithSubactivity::onExit();
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap());
state = WebServerActivityState::SHUTTING_DOWN;
@ -50,12 +69,15 @@ void CrossPointWebServerActivity::onExit() {
// Stop the web server first (before disconnecting WiFi)
stopWebServer();
// Exit WiFi selection subactivity if still active
if (wifiSelection) {
Serial.printf("[%lu] [WEBACT] Exiting WifiSelectionActivity...\n", millis());
wifiSelection->onExit();
wifiSelection.reset();
Serial.printf("[%lu] [WEBACT] WifiSelectionActivity exited\n", millis());
// 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
@ -63,9 +85,14 @@ void CrossPointWebServerActivity::onExit() {
delay(500);
// Disconnect WiFi gracefully
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
if (isApMode) {
Serial.printf("[%lu] [WEBACT] Stopping WiFi AP...\n", millis());
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());
WiFi.mode(WIFI_OFF);
@ -92,29 +119,119 @@ void CrossPointWebServerActivity::onExit() {
Serial.printf("[%lu] [WEBACT] Mutex deleted\n", millis());
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap());
Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onExit COMPLETE ==========\n", millis());
}
void CrossPointWebServerActivity::onWifiSelectionComplete(bool connected) {
void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) {
Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(),
mode == NetworkMode::JOIN_NETWORK ? "Join Network" : "Create Hotspot");
networkMode = mode;
isApMode = (mode == NetworkMode::CREATE_HOTSPOT);
// Exit mode selection subactivity
exitActivity();
if (mode == NetworkMode::JOIN_NETWORK) {
// STA mode - launch WiFi selection
Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis());
WiFi.mode(WIFI_STA);
state = WebServerActivityState::WIFI_SELECTION;
Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis());
enterNewActivity(new WifiSelectionActivity(renderer, inputManager,
[this](const bool connected) { onWifiSelectionComplete(connected); }));
} else {
// AP mode - start access point
state = WebServerActivityState::AP_STARTING;
updateRequired = true;
startAccessPoint();
}
}
void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) {
Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected);
if (connected) {
// Get connection info before exiting subactivity
connectedIP = wifiSelection->getConnectedIP();
connectedIP = static_cast<WifiSelectionActivity*>(subActivity.get())->getConnectedIP();
connectedSSID = WiFi.SSID().c_str();
isApMode = false;
// Exit the wifi selection subactivity
wifiSelection->onExit();
wifiSelection.reset();
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
startWebServer();
} else {
// User cancelled - go back
onGoBack();
// User cancelled - go back to mode selection
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() {
Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis());
@ -150,47 +267,45 @@ void CrossPointWebServerActivity::stopWebServer() {
}
void CrossPointWebServerActivity::loop() {
if (subActivity) {
// Forward loop to subactivity
subActivity->loop();
return;
}
// Handle different states
switch (state) {
case WebServerActivityState::WIFI_SELECTION:
// Forward loop to WiFi selection subactivity
if (wifiSelection) {
wifiSelection->loop();
}
break;
if (state == WebServerActivityState::SERVER_RUNNING) {
// Handle DNS requests for captive portal (AP mode only)
if (isApMode && dnsServer) {
dnsServer->processNextRequest();
}
case WebServerActivityState::SERVER_RUNNING:
// Handle web server requests - call handleClient multiple times per loop
// to improve responsiveness and upload throughput
if (webServer && webServer->isRunning()) {
unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime;
// Handle web server requests - call handleClient multiple times per loop
// to improve responsiveness and upload throughput
if (webServer && webServer->isRunning()) {
const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime;
// Log if there's a significant gap between handleClient calls (>100ms)
if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) {
Serial.printf("[%lu] [WEBACT] WARNING: %lu ms gap since last handleClient\n", millis(),
timeSinceLastHandleClient);
}
// Call handleClient multiple times to process pending requests faster
// This is critical for upload performance - HTTP file uploads send data
// in chunks and each handleClient() call processes incoming data
constexpr int HANDLE_CLIENT_ITERATIONS = 10;
for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) {
webServer->handleClient();
}
lastHandleClientTime = millis();
// Log if there's a significant gap between handleClient calls (>100ms)
if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) {
Serial.printf("[%lu] [WEBACT] WARNING: %lu ms gap since last handleClient\n", millis(),
timeSinceLastHandleClient);
}
// Handle exit on Back button
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
onGoBack();
return;
// Call handleClient multiple times to process pending requests faster
// This is critical for upload performance - HTTP file uploads send data
// in chunks and each handleClient() call processes incoming data
constexpr int HANDLE_CLIENT_ITERATIONS = 10;
for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) {
webServer->handleClient();
}
break;
lastHandleClientTime = millis();
}
case WebServerActivityState::SHUTTING_DOWN:
// Do nothing - waiting for cleanup
break;
// Handle exit on Back button
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
onGoBack();
return;
}
}
}
@ -208,36 +323,71 @@ void CrossPointWebServerActivity::displayTaskLoop() {
void CrossPointWebServerActivity::render() const {
// 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) {
renderer.clearScreen();
renderServerRunning();
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 {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 5) / 2;
const auto pageHeight = renderer.getScreenHeight();
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;
if (ssidInfo.length() > 28) {
ssidInfo = ssidInfo.substr(0, 25) + "...";
renderer.drawCenteredText(READER_FONT_ID, 15, "File Transfer", true, BOLD);
if (isApMode) {
// AP mode display - center the content block
const int startY = 55;
renderer.drawCenteredText(UI_FONT_ID, startY, "Hotspot Mode", true, BOLD);
std::string ssidInfo = "Network: " + connectedSSID;
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ssidInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 2, "Connect your device to this WiFi network",
true, REGULAR);
// Show primary URL (hostname)
std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/";
renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, BOLD);
// Show IP address as fallback
std::string ipUrl = "or http://" + connectedIP + "/";
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, ipUrl.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Open this URL in your browser", true, REGULAR);
} else {
// STA mode display (original behavior)
const int startY = 65;
std::string ssidInfo = "Network: " + connectedSSID;
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);
}

View File

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

View File

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

@ -6,6 +6,7 @@
#include <map>
#include "WifiCredentialStore.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "config.h"
void WifiSelectionActivity::taskTrampoline(void* param) {
@ -14,10 +15,14 @@ void WifiSelectionActivity::taskTrampoline(void* param) {
}
void WifiSelectionActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
// Load saved WiFi credentials
// Load saved WiFi credentials - SD card operations need lock as we use SPI for both
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.loadFromFile();
xSemaphoreGive(renderingMutex);
// Reset state
selectedNetworkIndex = 0;
@ -30,7 +35,6 @@ void WifiSelectionActivity::onEnter() {
usedSavedPassword = false;
savePromptSelection = 0;
forgetPromptSelection = 0;
keyboard.reset();
// Trigger first update to show scanning message
updateRequired = true;
@ -47,7 +51,8 @@ void WifiSelectionActivity::onEnter() {
}
void WifiSelectionActivity::onExit() {
Serial.printf("[%lu] [WIFI] ========== WifiSelectionActivity onExit START ==========\n", millis());
Activity::onExit();
Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap());
// Stop any ongoing WiFi scan
@ -78,7 +83,6 @@ void WifiSelectionActivity::onExit() {
Serial.printf("[%lu] [WIFI] Mutex deleted\n", millis());
Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap());
Serial.printf("[%lu] [WIFI] ========== WifiSelectionActivity onExit COMPLETE ==========\n", millis());
}
void WifiSelectionActivity::startWifiScan() {
@ -96,7 +100,7 @@ void WifiSelectionActivity::startWifiScan() {
}
void WifiSelectionActivity::processWifiScanResults() {
int16_t scanResult = WiFi.scanComplete();
const int16_t scanResult = WiFi.scanComplete();
if (scanResult == WIFI_SCAN_RUNNING) {
// Scan still in progress
@ -115,7 +119,7 @@ void WifiSelectionActivity::processWifiScanResults() {
for (int i = 0; i < scanResult; i++) {
std::string ssid = WiFi.SSID(i).c_str();
int32_t rssi = WiFi.RSSI(i);
const int32_t rssi = WiFi.RSSI(i);
// Skip hidden networks (empty SSID)
if (ssid.empty()) {
@ -138,6 +142,7 @@ void WifiSelectionActivity::processWifiScanResults() {
// Convert map to vector
networks.clear();
for (const auto& pair : uniqueNetworks) {
// cppcheck-suppress useStlAlgorithm
networks.push_back(pair.second);
}
@ -151,7 +156,7 @@ void WifiSelectionActivity::processWifiScanResults() {
updateRequired = true;
}
void WifiSelectionActivity::selectNetwork(int index) {
void WifiSelectionActivity::selectNetwork(const int index) {
if (index < 0 || index >= static_cast<int>(networks.size())) {
return;
}
@ -177,11 +182,11 @@ void WifiSelectionActivity::selectNetwork(int index) {
if (selectedRequiresPassword) {
// Show password entry
state = WifiSelectionState::PASSWORD_ENTRY;
keyboard.reset(new KeyboardEntryActivity(renderer, inputManager, "Enter WiFi Password",
"", // No initial text
64, // Max password length
false // Show password by default (hard keyboard to use)
));
enterNewActivity(new KeyboardEntryActivity(renderer, inputManager, "Enter WiFi Password",
"", // No initial text
64, // Max password length
false // Show password by default (hard keyboard to use)
));
updateRequired = true;
} else {
// Connect directly for open networks
@ -199,8 +204,8 @@ void WifiSelectionActivity::attemptConnection() {
WiFi.mode(WIFI_STA);
// Get password from keyboard if we just entered it
if (keyboard && !usedSavedPassword) {
enteredPassword = keyboard->getText();
if (subActivity && !usedSavedPassword) {
enteredPassword = static_cast<KeyboardEntryActivity*>(subActivity.get())->getText();
}
if (selectedRequiresPassword && !enteredPassword.empty()) {
@ -215,7 +220,7 @@ void WifiSelectionActivity::checkConnectionStatus() {
return;
}
wl_status_t status = WiFi.status();
const wl_status_t status = WiFi.status();
if (status == WL_CONNECTED) {
// Successfully connected
@ -272,7 +277,8 @@ void WifiSelectionActivity::loop() {
}
// Handle password entry state
if (state == WifiSelectionState::PASSWORD_ENTRY && keyboard) {
if (state == WifiSelectionState::PASSWORD_ENTRY && subActivity) {
const auto keyboard = static_cast<KeyboardEntryActivity*>(subActivity.get());
keyboard->handleInput();
if (keyboard->isComplete()) {
@ -282,7 +288,7 @@ void WifiSelectionActivity::loop() {
if (keyboard->isCancelled()) {
state = WifiSelectionState::NETWORK_LIST;
keyboard.reset();
exitActivity();
updateRequired = true;
return;
}
@ -306,7 +312,9 @@ void WifiSelectionActivity::loop() {
} else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (savePromptSelection == 0) {
// User chose "Yes" - save the password
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.addCredential(selectedSSID, enteredPassword);
xSemaphoreGive(renderingMutex);
}
// Complete - parent will start web server
onComplete(true);
@ -332,13 +340,14 @@ void WifiSelectionActivity::loop() {
} else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (forgetPromptSelection == 0) {
// User chose "Yes" - forget the network
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.removeCredential(selectedSSID);
xSemaphoreGive(renderingMutex);
// Update the network list to reflect the change
for (auto& network : networks) {
if (network.ssid == selectedSSID) {
network.hasSavedPassword = false;
break;
}
const auto network = find_if(networks.begin(), networks.end(),
[this](const WifiNetworkInfo& net) { return net.ssid == selectedSSID; });
if (network != networks.end()) {
network->hasSavedPassword = false;
}
}
// Go back to network list
@ -408,15 +417,18 @@ void WifiSelectionActivity::loop() {
}
}
std::string WifiSelectionActivity::getSignalStrengthIndicator(int32_t rssi) const {
std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi) const {
// Convert RSSI to signal bars representation
if (rssi >= -50) {
return "||||"; // Excellent
} else if (rssi >= -60) {
}
if (rssi >= -60) {
return "||| "; // Good
} else if (rssi >= -70) {
}
if (rssi >= -70) {
return "|| "; // Fair
} else if (rssi >= -80) {
}
if (rssi >= -80) {
return "| "; // Weak
}
return " "; // Very weak
@ -468,8 +480,8 @@ void WifiSelectionActivity::render() const {
}
void WifiSelectionActivity::renderNetworkList() const {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Draw header
renderer.drawCenteredText(READER_FONT_ID, 10, "WiFi Networks", true, BOLD);
@ -482,8 +494,8 @@ void WifiSelectionActivity::renderNetworkList() const {
renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, "Press OK to scan again", true, REGULAR);
} else {
// Calculate how many networks we can display
const int startY = 60;
const int lineHeight = 25;
constexpr int startY = 60;
constexpr int lineHeight = 25;
const int maxVisibleNetworks = (pageHeight - startY - 40) / lineHeight;
// Calculate scroll offset to keep selected item visible
@ -506,7 +518,7 @@ void WifiSelectionActivity::renderNetworkList() const {
// Draw network name (truncate if too long)
std::string displayName = network.ssid;
if (displayName.length() > 16) {
displayName = displayName.substr(0, 13) + "...";
displayName.replace(13, displayName.length() - 13, "...");
}
renderer.drawText(UI_FONT_ID, 20, networkY, displayName.c_str());
@ -544,26 +556,24 @@ void WifiSelectionActivity::renderNetworkList() const {
}
void WifiSelectionActivity::renderPasswordEntry() const {
const auto pageHeight = GfxRenderer::getScreenHeight();
// Draw header
renderer.drawCenteredText(READER_FONT_ID, 5, "WiFi Password", true, BOLD);
// Draw network name with good spacing from header
std::string networkInfo = "Network: " + selectedSSID;
if (networkInfo.length() > 30) {
networkInfo = networkInfo.substr(0, 27) + "...";
networkInfo.replace(27, networkInfo.length() - 27, "...");
}
renderer.drawCenteredText(UI_FONT_ID, 38, networkInfo.c_str(), true, REGULAR);
// Draw keyboard
if (keyboard) {
keyboard->render(58);
if (subActivity) {
static_cast<KeyboardEntryActivity*>(subActivity.get())->render(58);
}
}
void WifiSelectionActivity::renderConnecting() const {
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height) / 2;
@ -574,15 +584,14 @@ void WifiSelectionActivity::renderConnecting() const {
std::string ssidInfo = "to " + selectedSSID;
if (ssidInfo.length() > 25) {
ssidInfo = ssidInfo.substr(0, 22) + "...";
ssidInfo.replace(22, ssidInfo.length() - 22, "...");
}
renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR);
}
}
void WifiSelectionActivity::renderConnected() const {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 4) / 2;
@ -590,19 +599,19 @@ void WifiSelectionActivity::renderConnected() const {
std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo = ssidInfo.substr(0, 25) + "...";
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
}
renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR);
std::string ipInfo = "IP Address: " + connectedIP;
const std::string ipInfo = "IP Address: " + connectedIP;
renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue", true, REGULAR);
}
void WifiSelectionActivity::renderSavePrompt() const {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 3) / 2;
@ -610,7 +619,7 @@ void WifiSelectionActivity::renderSavePrompt() const {
std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo = ssidInfo.substr(0, 25) + "...";
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
}
renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR);
@ -618,9 +627,9 @@ void WifiSelectionActivity::renderSavePrompt() const {
// Draw Yes/No buttons
const int buttonY = top + 80;
const int buttonWidth = 60;
const int buttonSpacing = 30;
const int totalWidth = buttonWidth * 2 + buttonSpacing;
constexpr int buttonWidth = 60;
constexpr int buttonSpacing = 30;
constexpr int totalWidth = buttonWidth * 2 + buttonSpacing;
const int startX = (pageWidth - totalWidth) / 2;
// Draw "Yes" button
@ -641,7 +650,7 @@ void WifiSelectionActivity::renderSavePrompt() const {
}
void WifiSelectionActivity::renderConnectionFailed() const {
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 2) / 2;
@ -651,8 +660,8 @@ void WifiSelectionActivity::renderConnectionFailed() const {
}
void WifiSelectionActivity::renderForgetPrompt() const {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (pageHeight - height * 3) / 2;
@ -660,7 +669,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) {
ssidInfo = ssidInfo.substr(0, 25) + "...";
ssidInfo.replace(25, ssidInfo.length() - 25, "...");
}
renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR);
@ -668,9 +677,9 @@ void WifiSelectionActivity::renderForgetPrompt() const {
// Draw Yes/No buttons
const int buttonY = top + 80;
const int buttonWidth = 60;
const int buttonSpacing = 30;
const int totalWidth = buttonWidth * 2 + buttonSpacing;
constexpr int buttonWidth = 60;
constexpr int buttonSpacing = 30;
constexpr int totalWidth = buttonWidth * 2 + buttonSpacing;
const int startX = (pageWidth - totalWidth) / 2;
// Draw "Yes" button

View File

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

View File

@ -1,11 +1,13 @@
#include "EpubReaderActivity.h"
#include <Epub/Page.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <SD.h>
#include <InputManager.h>
#include "Battery.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h"
#include "config.h"
@ -25,6 +27,8 @@ void EpubReaderActivity::taskTrampoline(void* param) {
}
void EpubReaderActivity::onEnter() {
ActivityWithSubactivity::onEnter();
if (!epub) {
return;
}
@ -33,8 +37,8 @@ void EpubReaderActivity::onEnter() {
epub->setupCacheDir();
File f = SD.open((epub->getCachePath() + "/progress.bin").c_str());
if (f) {
File f;
if (FsHelpers::openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
if (f.read(data, 4) == 4) {
currentSpineIndex = data[0] + (data[1] << 8);
@ -44,6 +48,10 @@ void EpubReaderActivity::onEnter() {
f.close();
}
// Save current epub as last opened epub
APP_STATE.openEpubPath = epub->getPath();
APP_STATE.saveToFile();
// Trigger first update
updateRequired = true;
@ -56,6 +64,8 @@ void EpubReaderActivity::onEnter() {
}
void EpubReaderActivity::onExit() {
ActivityWithSubactivity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
@ -70,8 +80,8 @@ void EpubReaderActivity::onExit() {
void EpubReaderActivity::loop() {
// Pass input responsibility to sub activity if exists
if (subAcitivity) {
subAcitivity->loop();
if (subActivity) {
subActivity->loop();
return;
}
@ -79,11 +89,11 @@ void EpubReaderActivity::loop() {
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
// Don't start activity transition while rendering
xSemaphoreTake(renderingMutex, portMAX_DELAY);
subAcitivity.reset(new EpubReaderChapterSelectionActivity(
exitActivity();
enterNewActivity(new EpubReaderChapterSelectionActivity(
this->renderer, this->inputManager, epub, currentSpineIndex,
[this] {
subAcitivity->onExit();
subAcitivity.reset();
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex) {
@ -92,11 +102,9 @@ void EpubReaderActivity::loop() {
nextPageNumber = 0;
section.reset();
}
subAcitivity->onExit();
subAcitivity.reset();
exitActivity();
updateRequired = true;
}));
subAcitivity->onEnter();
xSemaphoreGive(renderingMutex);
}
@ -204,7 +212,7 @@ void EpubReaderActivity::renderScreen() {
}
if (!section) {
const auto filepath = epub->getSpineItem(currentSpineIndex);
const auto filepath = epub->getSpineItem(currentSpineIndex).href;
Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, marginLeft,
@ -274,14 +282,16 @@ void EpubReaderActivity::renderScreen() {
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
}
File f = SD.open((epub->getCachePath() + "/progress.bin").c_str(), FILE_WRITE);
uint8_t data[4];
data[0] = currentSpineIndex & 0xFF;
data[1] = (currentSpineIndex >> 8) & 0xFF;
data[2] = section->currentPage & 0xFF;
data[3] = (section->currentPage >> 8) & 0xFF;
f.write(data, 4);
f.close();
File f;
if (FsHelpers::openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[4];
data[0] = currentSpineIndex & 0xFF;
data[1] = (currentSpineIndex >> 8) & 0xFF;
data[2] = section->currentPage & 0xFF;
data[3] = (section->currentPage >> 8) & 0xFF;
f.write(data, 4);
f.close();
}
}
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) {
@ -325,8 +335,8 @@ void EpubReaderActivity::renderStatusBar() const {
constexpr auto textY = 776;
// Calculate progress in book
float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount;
uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg);
const float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount;
const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg);
// Right aligned text for progress counter
const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) +
@ -383,7 +393,7 @@ void EpubReaderActivity::renderStatusBar() const {
title = tocItem.title;
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
while (titleWidth > availableTextWidth && title.length() > 11) {
title = title.substr(0, title.length() - 8) + "...";
title.replace(title.length() - 8, 8, "...");
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
}
}

View File

@ -5,14 +5,13 @@
#include <freertos/semphr.h>
#include <freertos/task.h>
#include "../Activity.h"
#include "activities/ActivityWithSubactivity.h"
class EpubReaderActivity final : public Activity {
class EpubReaderActivity final : public ActivityWithSubactivity {
std::shared_ptr<Epub> epub;
std::unique_ptr<Section> section = nullptr;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
std::unique_ptr<Activity> subAcitivity = nullptr;
int currentSpineIndex = 0;
int nextPageNumber = 0;
int pagesUntilFullRefresh = 0;
@ -28,7 +27,7 @@ class EpubReaderActivity final : public Activity {
public:
explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoBack)
: Activity(renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {}
: ActivityWithSubactivity("EpubReader", renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@ -1,12 +1,15 @@
#include "EpubReaderChapterSelectionActivity.h"
#include <GfxRenderer.h>
#include <InputManager.h>
#include <SD.h>
#include "config.h"
namespace {
constexpr int PAGE_ITEMS = 24;
constexpr int SKIP_PAGE_MS = 700;
} // namespace
void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderChapterSelectionActivity*>(param);
@ -14,6 +17,8 @@ void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) {
}
void EpubReaderChapterSelectionActivity::onEnter() {
Activity::onEnter();
if (!epub) {
return;
}
@ -24,7 +29,7 @@ void EpubReaderChapterSelectionActivity::onEnter() {
// Trigger first update
updateRequired = true;
xTaskCreate(&EpubReaderChapterSelectionActivity::taskTrampoline, "EpubReaderChapterSelectionActivityTask",
2048, // Stack size
4096, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
@ -32,6 +37,8 @@ void EpubReaderChapterSelectionActivity::onEnter() {
}
void EpubReaderChapterSelectionActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {

View File

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

View File

@ -1,10 +1,16 @@
#include "FileSelectionActivity.h"
#include <GfxRenderer.h>
#include <InputManager.h>
#include <SD.h>
#include "config.h"
namespace {
constexpr int PAGE_ITEMS = 23;
constexpr int SKIP_PAGE_MS = 700;
} // namespace
void sortFileList(std::vector<std::string>& strs) {
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
if (str1.back() == '/' && str2.back() != '/') return true;
@ -43,6 +49,8 @@ void FileSelectionActivity::loadFiles() {
}
void FileSelectionActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
basepath = "/";
@ -61,6 +69,8 @@ void FileSelectionActivity::onEnter() {
}
void FileSelectionActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
@ -73,10 +83,12 @@ void FileSelectionActivity::onExit() {
}
void FileSelectionActivity::loop() {
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);
const bool prevReleased =
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT);
const bool nextReleased =
inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT);
const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS;
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (files.empty()) {
@ -93,7 +105,7 @@ void FileSelectionActivity::loop() {
}
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) {
if (basepath != "/") {
basepath = basepath.substr(0, basepath.rfind('/'));
basepath.replace(basepath.find_last_of('/'), std::string::npos, "");
if (basepath.empty()) basepath = "/";
loadFiles();
updateRequired = true;
@ -101,11 +113,19 @@ void FileSelectionActivity::loop() {
// At root level, go back home
onGoHome();
}
} else if (prevPressed) {
selectorIndex = (selectorIndex + files.size() - 1) % files.size();
} else if (prevReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + files.size()) % files.size();
} else {
selectorIndex = (selectorIndex + files.size() - 1) % files.size();
}
updateRequired = true;
} else if (nextPressed) {
selectorIndex = (selectorIndex + 1) % files.size();
} else if (nextReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % files.size();
} else {
selectorIndex = (selectorIndex + 1) % files.size();
}
updateRequired = true;
}
}
@ -126,21 +146,27 @@ void FileSelectionActivity::render() const {
renderer.clearScreen();
const auto pageWidth = GfxRenderer::getScreenWidth();
renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD);
renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD);
// Help text
renderer.drawText(SMALL_FONT_ID, 20, GfxRenderer::getScreenHeight() - 30, "Press BACK for Home");
if (files.empty()) {
renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found");
} else {
// Draw selection
renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30);
renderer.displayBuffer();
return;
}
for (size_t i = 0; i < files.size(); i++) {
const auto file = files[i];
renderer.drawText(UI_FONT_ID, 20, 60 + i * 30, file.c_str(), i != selectorIndex);
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 + 2, pageWidth - 1, 30);
for (int i = pageStartIndex; i < files.size() && i < pageStartIndex + PAGE_ITEMS; i++) {
auto item = files[i];
int itemWidth = renderer.getTextWidth(UI_FONT_ID, item.c_str());
while (itemWidth > renderer.getScreenWidth() - 40 && item.length() > 8) {
item.replace(item.length() - 5, 5, "...");
itemWidth = renderer.getTextWidth(UI_FONT_ID, item.c_str());
}
renderer.drawText(UI_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != selectorIndex);
}
renderer.displayBuffer();

View File

@ -28,7 +28,7 @@ class FileSelectionActivity final : public Activity {
explicit FileSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void(const std::string&)>& onSelect,
const std::function<void()>& onGoHome)
: Activity(renderer, inputManager), onSelect(onSelect), onGoHome(onGoHome) {}
: Activity("FileSelection", renderer, inputManager), onSelect(onSelect), onGoHome(onGoHome) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@ -2,7 +2,6 @@
#include <SD.h>
#include "CrossPointState.h"
#include "Epub.h"
#include "EpubReaderActivity.h"
#include "FileSelectionActivity.h"
@ -29,8 +28,6 @@ void ReaderActivity::onSelectEpubFile(const std::string& path) {
auto epub = loadEpub(path);
if (epub) {
APP_STATE.openEpubPath = path;
APP_STATE.saveToFile();
onGoToEpubReader(std::move(epub));
} else {
exitActivity();
@ -53,6 +50,8 @@ void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
}
void ReaderActivity::onEnter() {
ActivityWithSubactivity::onEnter();
if (initialEpubPath.empty()) {
onGoToFileSelection();
return;

View File

@ -17,7 +17,7 @@ class ReaderActivity final : public ActivityWithSubactivity {
public:
explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialEpubPath,
const std::function<void()>& onGoBack)
: ActivityWithSubactivity(renderer, inputManager),
: ActivityWithSubactivity("Reader", renderer, inputManager),
initialEpubPath(std::move(initialEpubPath)),
onGoBack(onGoBack) {}
void onEnter() override;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,15 +12,10 @@ const char* const KeyboardEntryActivity::keyboard[NUM_ROWS] = {
const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"",
"ZXCVBNM<>?", "^ _____<OK"};
KeyboardEntryActivity::KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::string& title, const std::string& initialText, size_t maxLength,
bool isPassword)
: Activity(renderer, inputManager), title(title), text(initialText), maxLength(maxLength), isPassword(isPassword) {}
void KeyboardEntryActivity::setText(const std::string& newText) {
text = newText;
if (maxLength > 0 && text.length() > maxLength) {
text = text.substr(0, maxLength);
text.resize(maxLength);
}
}
@ -37,15 +32,13 @@ void KeyboardEntryActivity::reset(const std::string& newTitle, const std::string
}
void KeyboardEntryActivity::onEnter() {
Activity::onEnter();
// Reset state when entering the activity
complete = false;
cancelled = false;
}
void KeyboardEntryActivity::onExit() {
// Clean up if needed
}
void KeyboardEntryActivity::loop() {
handleInput();
render(10);

View File

@ -34,7 +34,12 @@ class KeyboardEntryActivity : public Activity {
* @param isPassword If true, display asterisks instead of actual characters
*/
KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, const std::string& title = "Enter Text",
const std::string& initialText = "", size_t maxLength = 0, bool isPassword = false);
const std::string& initialText = "", const size_t maxLength = 0, const bool isPassword = false)
: Activity("KeyboardEntry", renderer, inputManager),
title(title),
text(initialText),
maxLength(maxLength),
isPassword(isPassword) {}
/**
* Handle button input. Call this in your main loop.
@ -85,7 +90,6 @@ class KeyboardEntryActivity : public Activity {
// Activity overrides
void onEnter() override;
void onExit() override;
void loop() override;
private:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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