mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-08 08:37:38 +03:00
Compare commits
10 Commits
2976113c0a
...
cb21668cb7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb21668cb7 | ||
|
|
1a462f0506 | ||
|
|
8e1ba0019b | ||
|
|
c8f4870d7c | ||
|
|
5fdf23f1d2 | ||
|
|
2fb417ee90 | ||
|
|
c8f6160fbc | ||
|
|
8e4484cd22 | ||
|
|
0332e1103a | ||
|
|
5790d6f5dc |
@ -25,7 +25,7 @@ This project is **not affiliated with Xteink**; it's built as a community projec
|
|||||||
|
|
||||||
## Features & Usage
|
## Features & Usage
|
||||||
|
|
||||||
- [x] EPUB parsing and rendering
|
- [x] EPUB parsing and rendering (EPUB 2 and EPUB 3)
|
||||||
- [ ] Image support within EPUB
|
- [ ] Image support within EPUB
|
||||||
- [x] Saved reading position
|
- [x] Saved reading position
|
||||||
- [x] File explorer with file picker
|
- [x] File explorer with file picker
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
#include "Epub/parsers/ContainerParser.h"
|
#include "Epub/parsers/ContainerParser.h"
|
||||||
#include "Epub/parsers/ContentOpfParser.h"
|
#include "Epub/parsers/ContentOpfParser.h"
|
||||||
|
#include "Epub/parsers/TocNavParser.h"
|
||||||
#include "Epub/parsers/TocNcxParser.h"
|
#include "Epub/parsers/TocNcxParser.h"
|
||||||
|
|
||||||
bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
|
bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
|
||||||
@ -80,6 +81,10 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
|
|||||||
tocNcxItem = opfParser.tocNcxPath;
|
tocNcxItem = opfParser.tocNcxPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!opfParser.tocNavPath.empty()) {
|
||||||
|
tocNavItem = opfParser.tocNavPath;
|
||||||
|
}
|
||||||
|
|
||||||
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
|
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -141,6 +146,60 @@ bool Epub::parseTocNcxFile() const {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Epub::parseTocNavFile() const {
|
||||||
|
// the nav file should have been specified in the content.opf file (EPUB 3)
|
||||||
|
if (tocNavItem.empty()) {
|
||||||
|
Serial.printf("[%lu] [EBP] No nav file specified\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [EBP] Parsing toc nav file: %s\n", millis(), tocNavItem.c_str());
|
||||||
|
|
||||||
|
const auto tmpNavPath = getCachePath() + "/toc.nav";
|
||||||
|
FsFile tempNavFile;
|
||||||
|
if (!SdMan.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
readItemContentsToStream(tocNavItem, tempNavFile, 1024);
|
||||||
|
tempNavFile.close();
|
||||||
|
if (!SdMan.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto navSize = tempNavFile.size();
|
||||||
|
|
||||||
|
TocNavParser navParser(contentBasePath, navSize, bookMetadataCache.get());
|
||||||
|
|
||||||
|
if (!navParser.setup()) {
|
||||||
|
Serial.printf("[%lu] [EBP] Could not setup toc nav parser\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto navBuffer = static_cast<uint8_t*>(malloc(1024));
|
||||||
|
if (!navBuffer) {
|
||||||
|
Serial.printf("[%lu] [EBP] Could not allocate memory for toc nav parser\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (tempNavFile.available()) {
|
||||||
|
const auto readSize = tempNavFile.read(navBuffer, 1024);
|
||||||
|
const auto processedSize = navParser.write(navBuffer, readSize);
|
||||||
|
|
||||||
|
if (processedSize != readSize) {
|
||||||
|
Serial.printf("[%lu] [EBP] Could not process all toc nav data\n", millis());
|
||||||
|
free(navBuffer);
|
||||||
|
tempNavFile.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
free(navBuffer);
|
||||||
|
tempNavFile.close();
|
||||||
|
SdMan.remove(tmpNavPath.c_str());
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [EBP] Parsed TOC nav items\n", millis());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// load in the meta data for the epub file
|
// load in the meta data for the epub file
|
||||||
bool Epub::load(const bool buildIfMissing) {
|
bool Epub::load(const bool buildIfMissing) {
|
||||||
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
|
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
|
||||||
@ -184,15 +243,31 @@ bool Epub::load(const bool buildIfMissing) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TOC Pass
|
// TOC Pass - try EPUB 3 nav first, fall back to NCX
|
||||||
if (!bookMetadataCache->beginTocPass()) {
|
if (!bookMetadataCache->beginTocPass()) {
|
||||||
Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis());
|
Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!parseTocNcxFile()) {
|
|
||||||
Serial.printf("[%lu] [EBP] Could not parse toc\n", millis());
|
bool tocParsed = false;
|
||||||
return false;
|
|
||||||
|
// Try EPUB 3 nav document first (preferred)
|
||||||
|
if (!tocNavItem.empty()) {
|
||||||
|
Serial.printf("[%lu] [EBP] Attempting to parse EPUB 3 nav document\n", millis());
|
||||||
|
tocParsed = parseTocNavFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to NCX if nav parsing failed or wasn't available
|
||||||
|
if (!tocParsed && !tocNcxItem.empty()) {
|
||||||
|
Serial.printf("[%lu] [EBP] Falling back to NCX TOC\n", millis());
|
||||||
|
tocParsed = parseTocNcxFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tocParsed) {
|
||||||
|
Serial.printf("[%lu] [EBP] Warning: Could not parse any TOC format\n", millis());
|
||||||
|
// Continue anyway - book will work without TOC
|
||||||
|
}
|
||||||
|
|
||||||
if (!bookMetadataCache->endTocPass()) {
|
if (!bookMetadataCache->endTocPass()) {
|
||||||
Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis());
|
Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis());
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -12,8 +12,10 @@
|
|||||||
class ZipFile;
|
class ZipFile;
|
||||||
|
|
||||||
class Epub {
|
class Epub {
|
||||||
// the ncx file
|
// the ncx file (EPUB 2)
|
||||||
std::string tocNcxItem;
|
std::string tocNcxItem;
|
||||||
|
// the nav file (EPUB 3)
|
||||||
|
std::string tocNavItem;
|
||||||
// where is the EPUBfile?
|
// where is the EPUBfile?
|
||||||
std::string filepath;
|
std::string filepath;
|
||||||
// the base path for items in the EPUB file
|
// the base path for items in the EPUB file
|
||||||
@ -26,6 +28,7 @@ class Epub {
|
|||||||
bool findContentOpfFile(std::string* contentOpfFile) const;
|
bool findContentOpfFile(std::string* contentOpfFile) const;
|
||||||
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
|
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
|
||||||
bool parseTocNcxFile() const;
|
bool parseTocNcxFile() const;
|
||||||
|
bool parseTocNavFile() const;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
|
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {
|
||||||
|
|||||||
@ -161,6 +161,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
|||||||
std::string itemId;
|
std::string itemId;
|
||||||
std::string href;
|
std::string href;
|
||||||
std::string mediaType;
|
std::string mediaType;
|
||||||
|
std::string properties;
|
||||||
|
|
||||||
for (int i = 0; atts[i]; i += 2) {
|
for (int i = 0; atts[i]; i += 2) {
|
||||||
if (strcmp(atts[i], "id") == 0) {
|
if (strcmp(atts[i], "id") == 0) {
|
||||||
@ -169,6 +170,8 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
|||||||
href = self->baseContentPath + atts[i + 1];
|
href = self->baseContentPath + atts[i + 1];
|
||||||
} else if (strcmp(atts[i], "media-type") == 0) {
|
} else if (strcmp(atts[i], "media-type") == 0) {
|
||||||
mediaType = atts[i + 1];
|
mediaType = atts[i + 1];
|
||||||
|
} else if (strcmp(atts[i], "properties") == 0) {
|
||||||
|
properties = atts[i + 1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,6 +191,15 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
|
|||||||
href.c_str());
|
href.c_str());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EPUB 3: Check for nav document (properties contains "nav")
|
||||||
|
if (!properties.empty() && self->tocNavPath.empty()) {
|
||||||
|
// Properties is space-separated, check if "nav" is present as a word
|
||||||
|
if (properties == "nav" || properties.find("nav ") == 0 || properties.find(" nav") != std::string::npos) {
|
||||||
|
self->tocNavPath = href;
|
||||||
|
Serial.printf("[%lu] [COF] Found EPUB 3 nav document: %s\n", millis(), href.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,7 @@ class ContentOpfParser final : public Print {
|
|||||||
std::string title;
|
std::string title;
|
||||||
std::string author;
|
std::string author;
|
||||||
std::string tocNcxPath;
|
std::string tocNcxPath;
|
||||||
|
std::string tocNavPath; // EPUB 3 nav document path
|
||||||
std::string coverItemHref;
|
std::string coverItemHref;
|
||||||
std::string textReferenceHref;
|
std::string textReferenceHref;
|
||||||
|
|
||||||
|
|||||||
184
lib/Epub/Epub/parsers/TocNavParser.cpp
Normal file
184
lib/Epub/Epub/parsers/TocNavParser.cpp
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
#include "TocNavParser.h"
|
||||||
|
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
|
||||||
|
#include "../BookMetadataCache.h"
|
||||||
|
|
||||||
|
bool TocNavParser::setup() {
|
||||||
|
parser = XML_ParserCreate(nullptr);
|
||||||
|
if (!parser) {
|
||||||
|
Serial.printf("[%lu] [NAV] Couldn't allocate memory for parser\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
XML_SetUserData(parser, this);
|
||||||
|
XML_SetElementHandler(parser, startElement, endElement);
|
||||||
|
XML_SetCharacterDataHandler(parser, characterData);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
TocNavParser::~TocNavParser() {
|
||||||
|
if (parser) {
|
||||||
|
XML_StopParser(parser, XML_FALSE);
|
||||||
|
XML_SetElementHandler(parser, nullptr, nullptr);
|
||||||
|
XML_SetCharacterDataHandler(parser, nullptr);
|
||||||
|
XML_ParserFree(parser);
|
||||||
|
parser = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t TocNavParser::write(const uint8_t data) { return write(&data, 1); }
|
||||||
|
|
||||||
|
size_t TocNavParser::write(const uint8_t* buffer, const size_t size) {
|
||||||
|
if (!parser) return 0;
|
||||||
|
|
||||||
|
const uint8_t* currentBufferPos = buffer;
|
||||||
|
auto remainingInBuffer = size;
|
||||||
|
|
||||||
|
while (remainingInBuffer > 0) {
|
||||||
|
void* const buf = XML_GetBuffer(parser, 1024);
|
||||||
|
if (!buf) {
|
||||||
|
Serial.printf("[%lu] [NAV] Couldn't allocate memory for buffer\n", millis());
|
||||||
|
XML_StopParser(parser, XML_FALSE);
|
||||||
|
XML_SetElementHandler(parser, nullptr, nullptr);
|
||||||
|
XML_SetCharacterDataHandler(parser, nullptr);
|
||||||
|
XML_ParserFree(parser);
|
||||||
|
parser = nullptr;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto toRead = remainingInBuffer < 1024 ? remainingInBuffer : 1024;
|
||||||
|
memcpy(buf, currentBufferPos, toRead);
|
||||||
|
|
||||||
|
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
|
||||||
|
Serial.printf("[%lu] [NAV] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
|
||||||
|
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||||
|
XML_StopParser(parser, XML_FALSE);
|
||||||
|
XML_SetElementHandler(parser, nullptr, nullptr);
|
||||||
|
XML_SetCharacterDataHandler(parser, nullptr);
|
||||||
|
XML_ParserFree(parser);
|
||||||
|
parser = nullptr;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBufferPos += toRead;
|
||||||
|
remainingInBuffer -= toRead;
|
||||||
|
remainingSize -= toRead;
|
||||||
|
}
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
void XMLCALL TocNavParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
|
||||||
|
auto* self = static_cast<TocNavParser*>(userData);
|
||||||
|
|
||||||
|
// Track HTML structure loosely - we mainly care about finding <nav epub:type="toc">
|
||||||
|
if (strcmp(name, "html") == 0) {
|
||||||
|
self->state = IN_HTML;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self->state == IN_HTML && strcmp(name, "body") == 0) {
|
||||||
|
self->state = IN_BODY;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for <nav epub:type="toc"> anywhere in body (or nested elements)
|
||||||
|
if (self->state >= IN_BODY && strcmp(name, "nav") == 0) {
|
||||||
|
for (int i = 0; atts[i]; i += 2) {
|
||||||
|
if ((strcmp(atts[i], "epub:type") == 0 || strcmp(atts[i], "type") == 0) && strcmp(atts[i + 1], "toc") == 0) {
|
||||||
|
self->state = IN_NAV_TOC;
|
||||||
|
Serial.printf("[%lu] [NAV] Found nav toc element\n", millis());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process ol/li/a if we're inside the toc nav
|
||||||
|
if (self->state < IN_NAV_TOC) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(name, "ol") == 0) {
|
||||||
|
self->olDepth++;
|
||||||
|
self->state = IN_OL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self->state == IN_OL && strcmp(name, "li") == 0) {
|
||||||
|
self->state = IN_LI;
|
||||||
|
self->currentLabel.clear();
|
||||||
|
self->currentHref.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self->state == IN_LI && strcmp(name, "a") == 0) {
|
||||||
|
self->state = IN_ANCHOR;
|
||||||
|
// Get href attribute
|
||||||
|
for (int i = 0; atts[i]; i += 2) {
|
||||||
|
if (strcmp(atts[i], "href") == 0) {
|
||||||
|
self->currentHref = atts[i + 1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void XMLCALL TocNavParser::characterData(void* userData, const XML_Char* s, const int len) {
|
||||||
|
auto* self = static_cast<TocNavParser*>(userData);
|
||||||
|
|
||||||
|
// Only collect text when inside an anchor within the TOC nav
|
||||||
|
if (self->state == IN_ANCHOR) {
|
||||||
|
self->currentLabel.append(s, len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) {
|
||||||
|
auto* self = static_cast<TocNavParser*>(userData);
|
||||||
|
|
||||||
|
if (strcmp(name, "a") == 0 && self->state == IN_ANCHOR) {
|
||||||
|
// Create TOC entry when closing anchor tag (we have all data now)
|
||||||
|
if (!self->currentLabel.empty() && !self->currentHref.empty()) {
|
||||||
|
std::string href = self->baseContentPath + self->currentHref;
|
||||||
|
std::string anchor;
|
||||||
|
|
||||||
|
const size_t pos = href.find('#');
|
||||||
|
if (pos != std::string::npos) {
|
||||||
|
anchor = href.substr(pos + 1);
|
||||||
|
href = href.substr(0, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self->cache) {
|
||||||
|
// olDepth gives us the nesting level (1-based from the outer ol)
|
||||||
|
self->cache->createTocEntry(self->currentLabel, href, anchor, self->olDepth);
|
||||||
|
}
|
||||||
|
|
||||||
|
self->currentLabel.clear();
|
||||||
|
self->currentHref.clear();
|
||||||
|
}
|
||||||
|
self->state = IN_LI;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(name, "li") == 0 && (self->state == IN_LI || self->state == IN_OL)) {
|
||||||
|
self->state = IN_OL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(name, "ol") == 0 && self->state >= IN_NAV_TOC) {
|
||||||
|
self->olDepth--;
|
||||||
|
if (self->olDepth == 0) {
|
||||||
|
self->state = IN_NAV_TOC;
|
||||||
|
} else {
|
||||||
|
self->state = IN_LI; // Back to parent li
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp(name, "nav") == 0 && self->state >= IN_NAV_TOC) {
|
||||||
|
self->state = IN_BODY;
|
||||||
|
Serial.printf("[%lu] [NAV] Finished parsing nav toc\n", millis());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
lib/Epub/Epub/parsers/TocNavParser.h
Normal file
47
lib/Epub/Epub/parsers/TocNavParser.h
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Print.h>
|
||||||
|
#include <expat.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
class BookMetadataCache;
|
||||||
|
|
||||||
|
// Parser for EPUB 3 nav.xhtml navigation documents
|
||||||
|
// Parses HTML5 nav elements with epub:type="toc" to extract table of contents
|
||||||
|
class TocNavParser final : public Print {
|
||||||
|
enum ParserState {
|
||||||
|
START,
|
||||||
|
IN_HTML,
|
||||||
|
IN_BODY,
|
||||||
|
IN_NAV_TOC, // Inside <nav epub:type="toc">
|
||||||
|
IN_OL, // Inside <ol>
|
||||||
|
IN_LI, // Inside <li>
|
||||||
|
IN_ANCHOR, // Inside <a>
|
||||||
|
};
|
||||||
|
|
||||||
|
const std::string& baseContentPath;
|
||||||
|
size_t remainingSize;
|
||||||
|
XML_Parser parser = nullptr;
|
||||||
|
ParserState state = START;
|
||||||
|
BookMetadataCache* cache;
|
||||||
|
|
||||||
|
// Track nesting depth for <ol> elements to determine TOC depth
|
||||||
|
uint8_t olDepth = 0;
|
||||||
|
// Current entry data being collected
|
||||||
|
std::string currentLabel;
|
||||||
|
std::string currentHref;
|
||||||
|
|
||||||
|
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:
|
||||||
|
explicit TocNavParser(const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache)
|
||||||
|
: baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {}
|
||||||
|
~TocNavParser() override;
|
||||||
|
|
||||||
|
bool setup();
|
||||||
|
|
||||||
|
size_t write(uint8_t) override;
|
||||||
|
size_t write(const uint8_t* buffer, size_t size) override;
|
||||||
|
};
|
||||||
@ -327,6 +327,148 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const {
|
||||||
|
const int screenWidth = getScreenWidth();
|
||||||
|
constexpr int buttonWidth = 40; // Width on screen (height when rotated)
|
||||||
|
constexpr int buttonHeight = 80; // Height on screen (width when rotated)
|
||||||
|
constexpr int buttonX = 5; // Distance from right edge
|
||||||
|
// Position for the button group - buttons share a border so they're adjacent
|
||||||
|
constexpr int topButtonY = 345; // Top button position
|
||||||
|
|
||||||
|
const char* labels[] = {topBtn, bottomBtn};
|
||||||
|
|
||||||
|
// Draw the shared border for both buttons as one unit
|
||||||
|
const int x = screenWidth - buttonX - buttonWidth;
|
||||||
|
|
||||||
|
// Draw top button outline (3 sides, bottom open)
|
||||||
|
if (topBtn != nullptr && topBtn[0] != '\0') {
|
||||||
|
drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
|
||||||
|
drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
|
||||||
|
drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw shared middle border
|
||||||
|
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
|
||||||
|
drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw bottom button outline (3 sides, top is shared)
|
||||||
|
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
|
||||||
|
drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
|
||||||
|
drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
|
||||||
|
topButtonY + 2 * buttonHeight - 1); // Right
|
||||||
|
drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw text for each button
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
if (labels[i] != nullptr && labels[i][0] != '\0') {
|
||||||
|
const int y = topButtonY + i * buttonHeight;
|
||||||
|
|
||||||
|
// Draw rotated text centered in the button
|
||||||
|
const int textWidth = getTextWidth(fontId, labels[i]);
|
||||||
|
const int textHeight = getTextHeight(fontId);
|
||||||
|
|
||||||
|
// Center the rotated text in the button
|
||||||
|
const int textX = x + (buttonWidth - textHeight) / 2;
|
||||||
|
const int textY = y + (buttonHeight + textWidth) / 2;
|
||||||
|
|
||||||
|
drawTextRotated90CW(fontId, textX, textY, labels[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int GfxRenderer::getTextHeight(const int fontId) const {
|
||||||
|
if (fontMap.count(fontId) == 0) {
|
||||||
|
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->ascender;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y, const char* text, const bool black,
|
||||||
|
const EpdFontFamily::Style style) const {
|
||||||
|
// Cannot draw a NULL / empty string
|
||||||
|
if (text == nullptr || *text == '\0') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fontMap.count(fontId) == 0) {
|
||||||
|
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto font = fontMap.at(fontId);
|
||||||
|
|
||||||
|
// No printable characters
|
||||||
|
if (!font.hasPrintableChars(text, style)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For 90° clockwise rotation:
|
||||||
|
// Original (glyphX, glyphY) -> Rotated (glyphY, -glyphX)
|
||||||
|
// Text reads from bottom to top
|
||||||
|
|
||||||
|
int yPos = y; // Current Y position (decreases as we draw characters)
|
||||||
|
|
||||||
|
uint32_t cp;
|
||||||
|
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||||
|
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||||
|
if (!glyph) {
|
||||||
|
glyph = font.getGlyph('?', style);
|
||||||
|
}
|
||||||
|
if (!glyph) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int is2Bit = font.getData(style)->is2Bit;
|
||||||
|
const uint32_t offset = glyph->dataOffset;
|
||||||
|
const uint8_t width = glyph->width;
|
||||||
|
const uint8_t height = glyph->height;
|
||||||
|
const int left = glyph->left;
|
||||||
|
const int top = glyph->top;
|
||||||
|
|
||||||
|
const uint8_t* bitmap = &font.getData(style)->bitmap[offset];
|
||||||
|
|
||||||
|
if (bitmap != nullptr) {
|
||||||
|
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||||
|
for (int glyphX = 0; glyphX < width; glyphX++) {
|
||||||
|
const int pixelPosition = glyphY * width + glyphX;
|
||||||
|
|
||||||
|
// 90° clockwise rotation transformation:
|
||||||
|
// screenX = x + (ascender - top + glyphY)
|
||||||
|
// screenY = yPos - (left + glyphX)
|
||||||
|
const int screenX = x + (font.getData(style)->ascender - top + glyphY);
|
||||||
|
const int screenY = yPos - left - glyphX;
|
||||||
|
|
||||||
|
if (is2Bit) {
|
||||||
|
const uint8_t byte = bitmap[pixelPosition / 4];
|
||||||
|
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
|
||||||
|
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
|
||||||
|
|
||||||
|
if (renderMode == BW && bmpVal < 3) {
|
||||||
|
drawPixel(screenX, screenY, black);
|
||||||
|
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||||
|
drawPixel(screenX, screenY, false);
|
||||||
|
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
|
||||||
|
drawPixel(screenX, screenY, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const uint8_t byte = bitmap[pixelPosition / 8];
|
||||||
|
const uint8_t bit_index = 7 - (pixelPosition % 8);
|
||||||
|
|
||||||
|
if ((byte >> bit_index) & 1) {
|
||||||
|
drawPixel(screenX, screenY, black);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next character position (going up, so decrease Y)
|
||||||
|
yPos -= glyph->advanceX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
||||||
|
|
||||||
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }
|
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }
|
||||||
|
|||||||
@ -82,7 +82,15 @@ class GfxRenderer {
|
|||||||
|
|
||||||
// UI Components
|
// UI Components
|
||||||
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const;
|
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const;
|
||||||
|
void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Helper for drawing rotated text (90 degrees clockwise, for side buttons)
|
||||||
|
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
|
||||||
|
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||||
|
int getTextHeight(int fontId) const;
|
||||||
|
|
||||||
|
public:
|
||||||
// Grayscale functions
|
// Grayscale functions
|
||||||
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
|
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
|
||||||
void copyGrayscaleLsbBuffers() const;
|
void copyGrayscaleLsbBuffers() const;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
[platformio]
|
[platformio]
|
||||||
crosspoint_version = 0.11.2
|
crosspoint_version = 0.12.0
|
||||||
default_envs = default
|
default_envs = default
|
||||||
|
|
||||||
[base]
|
[base]
|
||||||
|
|||||||
@ -40,9 +40,9 @@ bool CrossPointSettings::saveToFile() const {
|
|||||||
serialization::writePod(outputFile, fontSize);
|
serialization::writePod(outputFile, fontSize);
|
||||||
serialization::writePod(outputFile, lineSpacing);
|
serialization::writePod(outputFile, lineSpacing);
|
||||||
serialization::writePod(outputFile, paragraphAlignment);
|
serialization::writePod(outputFile, paragraphAlignment);
|
||||||
serialization::writePod(outputFile, sideMargin);
|
serialization::writePod(outputFile, sleepTimeout);
|
||||||
|
serialization::writePod(outputFile, refreshFrequency);
|
||||||
serialization::writeString(outputFile, std::string(opdsServerUrl));
|
serialization::writeString(outputFile, std::string(opdsServerUrl));
|
||||||
serialization::writePod(outputFile, calibreWirelessEnabled);
|
|
||||||
outputFile.close();
|
outputFile.close();
|
||||||
|
|
||||||
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
|
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
|
||||||
@ -91,7 +91,9 @@ bool CrossPointSettings::loadFromFile() {
|
|||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
serialization::readPod(inputFile, paragraphAlignment);
|
serialization::readPod(inputFile, paragraphAlignment);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
serialization::readPod(inputFile, sideMargin);
|
serialization::readPod(inputFile, sleepTimeout);
|
||||||
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
|
serialization::readPod(inputFile, refreshFrequency);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
{
|
{
|
||||||
std::string urlStr;
|
std::string urlStr;
|
||||||
@ -99,9 +101,6 @@ bool CrossPointSettings::loadFromFile() {
|
|||||||
strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1);
|
strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1);
|
||||||
opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0';
|
opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0';
|
||||||
}
|
}
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
|
||||||
serialization::readPod(inputFile, calibreWirelessEnabled);
|
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
|
||||||
} while (false);
|
} while (false);
|
||||||
|
|
||||||
inputFile.close();
|
inputFile.close();
|
||||||
@ -145,6 +144,38 @@ float CrossPointSettings::getReaderLineCompression() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unsigned long CrossPointSettings::getSleepTimeoutMs() const {
|
||||||
|
switch (sleepTimeout) {
|
||||||
|
case SLEEP_1_MIN:
|
||||||
|
return 1UL * 60 * 1000;
|
||||||
|
case SLEEP_5_MIN:
|
||||||
|
return 5UL * 60 * 1000;
|
||||||
|
case SLEEP_10_MIN:
|
||||||
|
default:
|
||||||
|
return 10UL * 60 * 1000;
|
||||||
|
case SLEEP_15_MIN:
|
||||||
|
return 15UL * 60 * 1000;
|
||||||
|
case SLEEP_30_MIN:
|
||||||
|
return 30UL * 60 * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int CrossPointSettings::getRefreshFrequency() const {
|
||||||
|
switch (refreshFrequency) {
|
||||||
|
case REFRESH_1:
|
||||||
|
return 1;
|
||||||
|
case REFRESH_5:
|
||||||
|
return 5;
|
||||||
|
case REFRESH_10:
|
||||||
|
return 10;
|
||||||
|
case REFRESH_15:
|
||||||
|
default:
|
||||||
|
return 15;
|
||||||
|
case REFRESH_30:
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int CrossPointSettings::getReaderFontId() const {
|
int CrossPointSettings::getReaderFontId() const {
|
||||||
switch (fontFamily) {
|
switch (fontFamily) {
|
||||||
case BOOKERLY:
|
case BOOKERLY:
|
||||||
@ -186,17 +217,3 @@ int CrossPointSettings::getReaderFontId() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int CrossPointSettings::getReaderSideMargin() const {
|
|
||||||
switch (sideMargin) {
|
|
||||||
case MARGIN_NONE:
|
|
||||||
return 0;
|
|
||||||
case MARGIN_SMALL:
|
|
||||||
default:
|
|
||||||
return 5;
|
|
||||||
case MARGIN_MEDIUM:
|
|
||||||
return 20;
|
|
||||||
case MARGIN_LARGE:
|
|
||||||
return 30;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -44,7 +44,12 @@ class CrossPointSettings {
|
|||||||
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 };
|
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 };
|
||||||
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 };
|
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 };
|
||||||
enum PARAGRAPH_ALIGNMENT { JUSTIFIED = 0, LEFT_ALIGN = 1, CENTER_ALIGN = 2, RIGHT_ALIGN = 3 };
|
enum PARAGRAPH_ALIGNMENT { JUSTIFIED = 0, LEFT_ALIGN = 1, CENTER_ALIGN = 2, RIGHT_ALIGN = 3 };
|
||||||
enum SIDE_MARGIN { MARGIN_NONE = 0, MARGIN_SMALL = 1, MARGIN_MEDIUM = 2, MARGIN_LARGE = 3 };
|
|
||||||
|
// Auto-sleep timeout options (in minutes)
|
||||||
|
enum SLEEP_TIMEOUT { SLEEP_1_MIN = 0, SLEEP_5_MIN = 1, SLEEP_10_MIN = 2, SLEEP_15_MIN = 3, SLEEP_30_MIN = 4 };
|
||||||
|
|
||||||
|
// E-ink refresh frequency (pages between full refreshes)
|
||||||
|
enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 };
|
||||||
|
|
||||||
// Sleep screen settings
|
// Sleep screen settings
|
||||||
uint8_t sleepScreen = DARK;
|
uint8_t sleepScreen = DARK;
|
||||||
@ -65,11 +70,12 @@ class CrossPointSettings {
|
|||||||
uint8_t fontSize = MEDIUM;
|
uint8_t fontSize = MEDIUM;
|
||||||
uint8_t lineSpacing = NORMAL;
|
uint8_t lineSpacing = NORMAL;
|
||||||
uint8_t paragraphAlignment = JUSTIFIED;
|
uint8_t paragraphAlignment = JUSTIFIED;
|
||||||
uint8_t sideMargin = MARGIN_SMALL;
|
// Auto-sleep timeout setting (default 10 minutes)
|
||||||
|
uint8_t sleepTimeout = SLEEP_10_MIN;
|
||||||
|
// E-ink refresh frequency (default 15 pages)
|
||||||
|
uint8_t refreshFrequency = REFRESH_15;
|
||||||
// OPDS browser settings
|
// OPDS browser settings
|
||||||
char opdsServerUrl[128] = "";
|
char opdsServerUrl[128] = "";
|
||||||
// Calibre wireless device settings
|
|
||||||
uint8_t calibreWirelessEnabled = 0;
|
|
||||||
|
|
||||||
~CrossPointSettings() = default;
|
~CrossPointSettings() = default;
|
||||||
|
|
||||||
@ -83,7 +89,8 @@ class CrossPointSettings {
|
|||||||
bool loadFromFile();
|
bool loadFromFile();
|
||||||
|
|
||||||
float getReaderLineCompression() const;
|
float getReaderLineCompression() const;
|
||||||
int getReaderSideMargin() const;
|
unsigned long getSleepTimeoutMs() const;
|
||||||
|
int getRefreshFrequency() const;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper macro to access settings
|
// Helper macro to access settings
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "Battery.h"
|
#include "Battery.h"
|
||||||
@ -15,21 +16,21 @@ void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left,
|
|||||||
|
|
||||||
// 1 column on left, 2 columns on right, 5 columns of battery body
|
// 1 column on left, 2 columns on right, 5 columns of battery body
|
||||||
constexpr int batteryWidth = 15;
|
constexpr int batteryWidth = 15;
|
||||||
constexpr int batteryHeight = 10;
|
constexpr int batteryHeight = 12;
|
||||||
const int x = left;
|
const int x = left;
|
||||||
const int y = top + 8;
|
const int y = top + 6;
|
||||||
|
|
||||||
// Top line
|
// Top line
|
||||||
renderer.drawLine(x, y, x + batteryWidth - 4, y);
|
renderer.drawLine(x + 1, y, x + batteryWidth - 3, y);
|
||||||
// Bottom line
|
// Bottom line
|
||||||
renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1);
|
renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1);
|
||||||
// Left line
|
// Left line
|
||||||
renderer.drawLine(x, y, x, y + batteryHeight - 1);
|
renderer.drawLine(x, y + 1, x, y + batteryHeight - 2);
|
||||||
// Battery end
|
// Battery end
|
||||||
renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1);
|
renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2);
|
||||||
renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2);
|
renderer.drawPixel(x + batteryWidth - 1, y + 3);
|
||||||
renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3);
|
renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4);
|
||||||
renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3);
|
renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5);
|
||||||
|
|
||||||
// The +1 is to round up, so that we always fill at least one pixel
|
// The +1 is to round up, so that we always fill at least one pixel
|
||||||
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
|
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
|
||||||
@ -37,5 +38,28 @@ void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left,
|
|||||||
filledWidth = batteryWidth - 5; // Ensure we don't overflow
|
filledWidth = batteryWidth - 5; // Ensure we don't overflow
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2);
|
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScreenComponents::drawProgressBar(const GfxRenderer& renderer, const int x, const int y, const int width,
|
||||||
|
const int height, const size_t current, const size_t total) {
|
||||||
|
if (total == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use 64-bit arithmetic to avoid overflow for large files
|
||||||
|
const int percent = static_cast<int>((static_cast<uint64_t>(current) * 100) / total);
|
||||||
|
|
||||||
|
// Draw outline
|
||||||
|
renderer.drawRect(x, y, width, height);
|
||||||
|
|
||||||
|
// Draw filled portion
|
||||||
|
const int fillWidth = (width - 4) * percent / 100;
|
||||||
|
if (fillWidth > 0) {
|
||||||
|
renderer.fillRect(x + 2, y + 2, fillWidth, height - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw percentage text centered below bar
|
||||||
|
const std::string percentText = std::to_string(percent) + "%";
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, y + height + 15, percentText.c_str());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,24 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
class GfxRenderer;
|
class GfxRenderer;
|
||||||
|
|
||||||
class ScreenComponents {
|
class ScreenComponents {
|
||||||
public:
|
public:
|
||||||
static void drawBattery(const GfxRenderer& renderer, int left, int top);
|
static void drawBattery(const GfxRenderer& renderer, int left, int top);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a progress bar with percentage text.
|
||||||
|
* @param renderer The graphics renderer
|
||||||
|
* @param x Left position of the bar
|
||||||
|
* @param y Top position of the bar
|
||||||
|
* @param width Width of the bar
|
||||||
|
* @param height Height of the bar
|
||||||
|
* @param current Current progress value
|
||||||
|
* @param total Total value for 100% progress
|
||||||
|
*/
|
||||||
|
static void drawProgressBar(const GfxRenderer& renderer, int x, int y, int width, int height, size_t current,
|
||||||
|
size_t total);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,57 +2,21 @@
|
|||||||
|
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <HardwareSerial.h>
|
#include <HardwareSerial.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "ScreenComponents.h"
|
||||||
|
#include "WifiCredentialStore.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
#include "network/HttpDownloader.h"
|
#include "network/HttpDownloader.h"
|
||||||
|
#include "util/StringUtils.h"
|
||||||
|
#include "util/UrlUtils.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int PAGE_ITEMS = 23;
|
constexpr int PAGE_ITEMS = 23;
|
||||||
constexpr int SKIP_PAGE_MS = 700;
|
constexpr int SKIP_PAGE_MS = 700;
|
||||||
constexpr char OPDS_ROOT_PATH[] = "opds"; // No leading slash - relative to server URL
|
constexpr char OPDS_ROOT_PATH[] = "opds"; // No leading slash - relative to server URL
|
||||||
|
|
||||||
// Prepend http:// if no protocol specified (server will redirect to https if needed)
|
|
||||||
std::string ensureProtocol(const std::string& url) {
|
|
||||||
if (url.find("://") == std::string::npos) {
|
|
||||||
return "http://" + url;
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract host with protocol from URL (e.g., "http://example.com" from "http://example.com/path")
|
|
||||||
std::string extractHost(const std::string& url) {
|
|
||||||
const size_t protocolEnd = url.find("://");
|
|
||||||
if (protocolEnd == std::string::npos) {
|
|
||||||
// No protocol, find first slash
|
|
||||||
const size_t firstSlash = url.find('/');
|
|
||||||
return firstSlash == std::string::npos ? url : url.substr(0, firstSlash);
|
|
||||||
}
|
|
||||||
// Find the first slash after the protocol
|
|
||||||
const size_t hostStart = protocolEnd + 3;
|
|
||||||
const size_t pathStart = url.find('/', hostStart);
|
|
||||||
return pathStart == std::string::npos ? url : url.substr(0, pathStart);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build full URL from server URL and path
|
|
||||||
// If path starts with /, it's an absolute path from the host root
|
|
||||||
// Otherwise, it's relative to the server URL
|
|
||||||
std::string buildUrl(const std::string& serverUrl, const std::string& path) {
|
|
||||||
const std::string urlWithProtocol = ensureProtocol(serverUrl);
|
|
||||||
if (path.empty()) {
|
|
||||||
return urlWithProtocol;
|
|
||||||
}
|
|
||||||
if (path[0] == '/') {
|
|
||||||
// Absolute path - use just the host
|
|
||||||
return extractHost(urlWithProtocol) + path;
|
|
||||||
}
|
|
||||||
// Relative path - append to server URL
|
|
||||||
if (urlWithProtocol.back() == '/') {
|
|
||||||
return urlWithProtocol + path;
|
|
||||||
}
|
|
||||||
return urlWithProtocol + "/" + path;
|
|
||||||
}
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
|
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
|
||||||
@ -64,13 +28,13 @@ void OpdsBookBrowserActivity::onEnter() {
|
|||||||
Activity::onEnter();
|
Activity::onEnter();
|
||||||
|
|
||||||
renderingMutex = xSemaphoreCreateMutex();
|
renderingMutex = xSemaphoreCreateMutex();
|
||||||
state = BrowserState::LOADING;
|
state = BrowserState::CHECK_WIFI;
|
||||||
entries.clear();
|
entries.clear();
|
||||||
navigationHistory.clear();
|
navigationHistory.clear();
|
||||||
currentPath = OPDS_ROOT_PATH;
|
currentPath = OPDS_ROOT_PATH;
|
||||||
selectorIndex = 0;
|
selectorIndex = 0;
|
||||||
errorMessage.clear();
|
errorMessage.clear();
|
||||||
statusMessage = "Loading...";
|
statusMessage = "Checking WiFi...";
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
|
|
||||||
xTaskCreate(&OpdsBookBrowserActivity::taskTrampoline, "OpdsBookBrowserTask",
|
xTaskCreate(&OpdsBookBrowserActivity::taskTrampoline, "OpdsBookBrowserTask",
|
||||||
@ -80,13 +44,16 @@ void OpdsBookBrowserActivity::onEnter() {
|
|||||||
&displayTaskHandle // Task handle
|
&displayTaskHandle // Task handle
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch feed after setting up the display task
|
// Check WiFi and connect if needed, then fetch feed
|
||||||
fetchFeed(currentPath);
|
checkAndConnectWifi();
|
||||||
}
|
}
|
||||||
|
|
||||||
void OpdsBookBrowserActivity::onExit() {
|
void OpdsBookBrowserActivity::onExit() {
|
||||||
Activity::onExit();
|
Activity::onExit();
|
||||||
|
|
||||||
|
// Turn off WiFi when exiting
|
||||||
|
WiFi.mode(WIFI_OFF);
|
||||||
|
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
if (displayTaskHandle) {
|
if (displayTaskHandle) {
|
||||||
vTaskDelete(displayTaskHandle);
|
vTaskDelete(displayTaskHandle);
|
||||||
@ -112,6 +79,14 @@ void OpdsBookBrowserActivity::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle WiFi check state - only Back works
|
||||||
|
if (state == BrowserState::CHECK_WIFI) {
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
|
onGoHome();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle loading state - only Back works
|
// Handle loading state - only Back works
|
||||||
if (state == BrowserState::LOADING) {
|
if (state == BrowserState::LOADING) {
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
@ -182,6 +157,14 @@ void OpdsBookBrowserActivity::render() const {
|
|||||||
|
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre Library", true, EpdFontFamily::BOLD);
|
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre Library", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
if (state == BrowserState::CHECK_WIFI) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||||
|
const auto labels = mappedInput.mapLabels("« Back", "", "", "");
|
||||||
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (state == BrowserState::LOADING) {
|
if (state == BrowserState::LOADING) {
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||||
const auto labels = mappedInput.mapLabels("« Back", "", "", "");
|
const auto labels = mappedInput.mapLabels("« Back", "", "", "");
|
||||||
@ -200,13 +183,14 @@ void OpdsBookBrowserActivity::render() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state == BrowserState::DOWNLOADING) {
|
if (state == BrowserState::DOWNLOADING) {
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Downloading...");
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, "Downloading...");
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, statusMessage.c_str());
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, statusMessage.c_str());
|
||||||
if (downloadTotal > 0) {
|
if (downloadTotal > 0) {
|
||||||
const int percent = (downloadProgress * 100) / downloadTotal;
|
const int barWidth = pageWidth - 100;
|
||||||
char progressText[32];
|
constexpr int barHeight = 20;
|
||||||
snprintf(progressText, sizeof(progressText), "%d%%", percent);
|
constexpr int barX = 50;
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 40, progressText);
|
const int barY = pageHeight / 2 + 20;
|
||||||
|
ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, downloadProgress, downloadTotal);
|
||||||
}
|
}
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
return;
|
return;
|
||||||
@ -262,7 +246,7 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string url = buildUrl(serverUrl, path);
|
std::string url = UrlUtils::buildUrl(serverUrl, path);
|
||||||
Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str());
|
Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str());
|
||||||
|
|
||||||
std::string content;
|
std::string content;
|
||||||
@ -336,10 +320,14 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
|||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
|
|
||||||
// Build full download URL
|
// Build full download URL
|
||||||
std::string downloadUrl = buildUrl(SETTINGS.opdsServerUrl, book.href);
|
std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href);
|
||||||
|
|
||||||
// Create sanitized filename
|
// Create sanitized filename: "Title - Author.epub" or just "Title.epub" if no author
|
||||||
std::string filename = "/" + sanitizeFilename(book.title) + ".epub";
|
std::string baseName = book.title;
|
||||||
|
if (!book.author.empty()) {
|
||||||
|
baseName += " - " + book.author;
|
||||||
|
}
|
||||||
|
std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub";
|
||||||
|
|
||||||
Serial.printf("[%lu] [OPDS] Downloading: %s -> %s\n", millis(), downloadUrl.c_str(), filename.c_str());
|
Serial.printf("[%lu] [OPDS] Downloading: %s -> %s\n", millis(), downloadUrl.c_str(), filename.c_str());
|
||||||
|
|
||||||
@ -361,33 +349,51 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string OpdsBookBrowserActivity::sanitizeFilename(const std::string& title) const {
|
void OpdsBookBrowserActivity::checkAndConnectWifi() {
|
||||||
std::string result;
|
// Already connected?
|
||||||
result.reserve(title.size());
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
state = BrowserState::LOADING;
|
||||||
for (char c : title) {
|
statusMessage = "Loading...";
|
||||||
// Replace invalid filename characters with underscore
|
updateRequired = true;
|
||||||
if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|') {
|
fetchFeed(currentPath);
|
||||||
result += '_';
|
return;
|
||||||
} else if (c >= 32 && c < 127) {
|
|
||||||
// Keep printable ASCII characters
|
|
||||||
result += c;
|
|
||||||
}
|
|
||||||
// Skip non-printable characters
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim leading/trailing spaces and dots
|
// Try to connect using saved credentials
|
||||||
size_t start = result.find_first_not_of(" .");
|
statusMessage = "Connecting to WiFi...";
|
||||||
if (start == std::string::npos) {
|
updateRequired = true;
|
||||||
return "book"; // Fallback if title is all invalid characters
|
|
||||||
}
|
|
||||||
size_t end = result.find_last_not_of(" .");
|
|
||||||
result = result.substr(start, end - start + 1);
|
|
||||||
|
|
||||||
// Limit filename length (SD card FAT32 has 255 char limit, but let's be safe)
|
WIFI_STORE.loadFromFile();
|
||||||
if (result.length() > 100) {
|
const auto& credentials = WIFI_STORE.getCredentials();
|
||||||
result.resize(100);
|
if (credentials.empty()) {
|
||||||
|
state = BrowserState::ERROR;
|
||||||
|
errorMessage = "No WiFi credentials saved";
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.empty() ? "book" : result;
|
// Use the first saved credential
|
||||||
|
const auto& cred = credentials[0];
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
WiFi.begin(cred.ssid.c_str(), cred.password.c_str());
|
||||||
|
|
||||||
|
// Wait for connection with timeout
|
||||||
|
constexpr int WIFI_TIMEOUT_MS = 10000;
|
||||||
|
const unsigned long startTime = millis();
|
||||||
|
while (WiFi.status() != WL_CONNECTED && millis() - startTime < WIFI_TIMEOUT_MS) {
|
||||||
|
vTaskDelay(100 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
Serial.printf("[%lu] [OPDS] WiFi connected: %s\n", millis(), WiFi.localIP().toString().c_str());
|
||||||
|
state = BrowserState::LOADING;
|
||||||
|
statusMessage = "Loading...";
|
||||||
|
updateRequired = true;
|
||||||
|
fetchFeed(currentPath);
|
||||||
|
} else {
|
||||||
|
state = BrowserState::ERROR;
|
||||||
|
errorMessage = "WiFi connection failed";
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
class OpdsBookBrowserActivity final : public Activity {
|
class OpdsBookBrowserActivity final : public Activity {
|
||||||
public:
|
public:
|
||||||
enum class BrowserState {
|
enum class BrowserState {
|
||||||
|
CHECK_WIFI, // Checking WiFi connection
|
||||||
LOADING, // Fetching OPDS feed
|
LOADING, // Fetching OPDS feed
|
||||||
BROWSING, // Displaying entries (navigation or books)
|
BROWSING, // Displaying entries (navigation or books)
|
||||||
DOWNLOADING, // Downloading selected EPUB
|
DOWNLOADING, // Downloading selected EPUB
|
||||||
@ -52,9 +53,9 @@ class OpdsBookBrowserActivity final : public Activity {
|
|||||||
[[noreturn]] void displayTaskLoop();
|
[[noreturn]] void displayTaskLoop();
|
||||||
void render() const;
|
void render() const;
|
||||||
|
|
||||||
|
void checkAndConnectWifi();
|
||||||
void fetchFeed(const std::string& path);
|
void fetchFeed(const std::string& path);
|
||||||
void navigateToEntry(const OpdsEntry& entry);
|
void navigateToEntry(const OpdsEntry& entry);
|
||||||
void navigateBack();
|
void navigateBack();
|
||||||
void downloadBook(const OpdsEntry& book);
|
void downloadBook(const OpdsEntry& book);
|
||||||
std::string sanitizeFilename(const std::string& title) const;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -21,7 +21,7 @@ void HomeActivity::taskTrampoline(void* param) {
|
|||||||
int HomeActivity::getMenuItemCount() const {
|
int HomeActivity::getMenuItemCount() const {
|
||||||
int count = 3; // Browse files, File transfer, Settings
|
int count = 3; // Browse files, File transfer, Settings
|
||||||
if (hasContinueReading) count++;
|
if (hasContinueReading) count++;
|
||||||
if (hasBrowserUrl) count++;
|
if (hasOpdsUrl) count++;
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,8 +33,8 @@ void HomeActivity::onEnter() {
|
|||||||
// Check if we have a book to continue reading
|
// Check if we have a book to continue reading
|
||||||
hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str());
|
hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str());
|
||||||
|
|
||||||
// Check if browser URL is configured
|
// Check if OPDS browser URL is configured
|
||||||
hasBrowserUrl = strlen(SETTINGS.opdsServerUrl) > 0;
|
hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0;
|
||||||
|
|
||||||
if (hasContinueReading) {
|
if (hasContinueReading) {
|
||||||
// Extract filename from path for display
|
// Extract filename from path for display
|
||||||
@ -102,7 +102,7 @@ void HomeActivity::loop() {
|
|||||||
int idx = 0;
|
int idx = 0;
|
||||||
const int continueIdx = hasContinueReading ? idx++ : -1;
|
const int continueIdx = hasContinueReading ? idx++ : -1;
|
||||||
const int browseFilesIdx = idx++;
|
const int browseFilesIdx = idx++;
|
||||||
const int browseBookIdx = hasBrowserUrl ? idx++ : -1;
|
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
|
||||||
const int fileTransferIdx = idx++;
|
const int fileTransferIdx = idx++;
|
||||||
const int settingsIdx = idx;
|
const int settingsIdx = idx;
|
||||||
|
|
||||||
@ -110,8 +110,8 @@ void HomeActivity::loop() {
|
|||||||
onContinueReading();
|
onContinueReading();
|
||||||
} else if (selectorIndex == browseFilesIdx) {
|
} else if (selectorIndex == browseFilesIdx) {
|
||||||
onReaderOpen();
|
onReaderOpen();
|
||||||
} else if (selectorIndex == browseBookIdx) {
|
} else if (selectorIndex == opdsLibraryIdx) {
|
||||||
onBrowserOpen();
|
onOpdsBrowserOpen();
|
||||||
} else if (selectorIndex == fileTransferIdx) {
|
} else if (selectorIndex == fileTransferIdx) {
|
||||||
onFileTransferOpen();
|
onFileTransferOpen();
|
||||||
} else if (selectorIndex == settingsIdx) {
|
} else if (selectorIndex == settingsIdx) {
|
||||||
@ -289,13 +289,11 @@ void HomeActivity::render() const {
|
|||||||
|
|
||||||
// --- Bottom menu tiles ---
|
// --- Bottom menu tiles ---
|
||||||
// Build menu items dynamically
|
// Build menu items dynamically
|
||||||
std::vector<const char*> menuItems;
|
std::vector<const char*> menuItems = {"Browse Files", "File Transfer", "Settings"};
|
||||||
menuItems.push_back("Browse Files");
|
if (hasOpdsUrl) {
|
||||||
if (hasBrowserUrl) {
|
// Insert Calibre Library after Browse Files
|
||||||
menuItems.push_back("Calibre Library");
|
menuItems.insert(menuItems.begin() + 1, "Calibre Library");
|
||||||
}
|
}
|
||||||
menuItems.push_back("File Transfer");
|
|
||||||
menuItems.push_back("Settings");
|
|
||||||
|
|
||||||
const int menuTileWidth = pageWidth - 2 * margin;
|
const int menuTileWidth = pageWidth - 2 * margin;
|
||||||
constexpr int menuTileHeight = 45;
|
constexpr int menuTileHeight = 45;
|
||||||
|
|||||||
@ -13,14 +13,14 @@ class HomeActivity final : public Activity {
|
|||||||
int selectorIndex = 0;
|
int selectorIndex = 0;
|
||||||
bool updateRequired = false;
|
bool updateRequired = false;
|
||||||
bool hasContinueReading = false;
|
bool hasContinueReading = false;
|
||||||
bool hasBrowserUrl = false;
|
bool hasOpdsUrl = false;
|
||||||
std::string lastBookTitle;
|
std::string lastBookTitle;
|
||||||
std::string lastBookAuthor;
|
std::string lastBookAuthor;
|
||||||
const std::function<void()> onContinueReading;
|
const std::function<void()> onContinueReading;
|
||||||
const std::function<void()> onReaderOpen;
|
const std::function<void()> onReaderOpen;
|
||||||
const std::function<void()> onSettingsOpen;
|
const std::function<void()> onSettingsOpen;
|
||||||
const std::function<void()> onFileTransferOpen;
|
const std::function<void()> onFileTransferOpen;
|
||||||
const std::function<void()> onBrowserOpen;
|
const std::function<void()> onOpdsBrowserOpen;
|
||||||
|
|
||||||
static void taskTrampoline(void* param);
|
static void taskTrampoline(void* param);
|
||||||
[[noreturn]] void displayTaskLoop();
|
[[noreturn]] void displayTaskLoop();
|
||||||
@ -31,13 +31,13 @@ class HomeActivity final : public Activity {
|
|||||||
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
const std::function<void()>& onContinueReading, const std::function<void()>& onReaderOpen,
|
const std::function<void()>& onContinueReading, const std::function<void()>& onReaderOpen,
|
||||||
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen,
|
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen,
|
||||||
const std::function<void()>& onBrowserOpen)
|
const std::function<void()>& onOpdsBrowserOpen)
|
||||||
: Activity("Home", renderer, mappedInput),
|
: Activity("Home", renderer, mappedInput),
|
||||||
onContinueReading(onContinueReading),
|
onContinueReading(onContinueReading),
|
||||||
onReaderOpen(onReaderOpen),
|
onReaderOpen(onReaderOpen),
|
||||||
onSettingsOpen(onSettingsOpen),
|
onSettingsOpen(onSettingsOpen),
|
||||||
onFileTransferOpen(onFileTransferOpen),
|
onFileTransferOpen(onFileTransferOpen),
|
||||||
onBrowserOpen(onBrowserOpen) {}
|
onOpdsBrowserOpen(onOpdsBrowserOpen) {}
|
||||||
void onEnter() override;
|
void onEnter() override;
|
||||||
void onExit() override;
|
void onExit() override;
|
||||||
void loop() override;
|
void loop() override;
|
||||||
|
|||||||
@ -1,17 +1,21 @@
|
|||||||
#include "CalibreWirelessActivity.h"
|
#include "CalibreWirelessActivity.h"
|
||||||
|
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
|
#include <HardwareSerial.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "ScreenComponents.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
#include "util/StringUtils.h"
|
||||||
|
|
||||||
// Define static constexpr members
|
namespace {
|
||||||
constexpr uint16_t CalibreWirelessActivity::UDP_PORTS[];
|
constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678};
|
||||||
|
constexpr uint16_t LOCAL_UDP_PORT = 8134; // Port to receive responses
|
||||||
|
} // namespace
|
||||||
|
|
||||||
void CalibreWirelessActivity::displayTaskTrampoline(void* param) {
|
void CalibreWirelessActivity::displayTaskTrampoline(void* param) {
|
||||||
auto* self = static_cast<CalibreWirelessActivity*>(param);
|
auto* self = static_cast<CalibreWirelessActivity*>(param);
|
||||||
@ -57,9 +61,8 @@ void CalibreWirelessActivity::onEnter() {
|
|||||||
void CalibreWirelessActivity::onExit() {
|
void CalibreWirelessActivity::onExit() {
|
||||||
Activity::onExit();
|
Activity::onExit();
|
||||||
|
|
||||||
// Always turn off the setting when exiting so it shows OFF in settings
|
// Turn off WiFi when exiting
|
||||||
SETTINGS.calibreWirelessEnabled = 0;
|
WiFi.mode(WIFI_OFF);
|
||||||
SETTINGS.saveToFile();
|
|
||||||
|
|
||||||
// Stop UDP listening
|
// Stop UDP listening
|
||||||
udp.stop();
|
udp.stop();
|
||||||
@ -74,13 +77,15 @@ void CalibreWirelessActivity::onExit() {
|
|||||||
currentFile.close();
|
currentFile.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete network task first (it may be blocked on network operations)
|
// Acquire stateMutex before deleting network task to avoid race condition
|
||||||
|
xSemaphoreTake(stateMutex, portMAX_DELAY);
|
||||||
if (networkTaskHandle) {
|
if (networkTaskHandle) {
|
||||||
vTaskDelete(networkTaskHandle);
|
vTaskDelete(networkTaskHandle);
|
||||||
networkTaskHandle = nullptr;
|
networkTaskHandle = nullptr;
|
||||||
}
|
}
|
||||||
|
xSemaphoreGive(stateMutex);
|
||||||
|
|
||||||
// Acquire mutex before deleting display task
|
// Acquire renderingMutex before deleting display task
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
if (displayTaskHandle) {
|
if (displayTaskHandle) {
|
||||||
vTaskDelete(displayTaskHandle);
|
vTaskDelete(displayTaskHandle);
|
||||||
@ -143,8 +148,8 @@ void CalibreWirelessActivity::networkTaskLoop() {
|
|||||||
|
|
||||||
void CalibreWirelessActivity::listenForDiscovery() {
|
void CalibreWirelessActivity::listenForDiscovery() {
|
||||||
// Broadcast "hello" on all UDP discovery ports to find Calibre
|
// Broadcast "hello" on all UDP discovery ports to find Calibre
|
||||||
for (size_t i = 0; i < UDP_PORT_COUNT; i++) {
|
for (const uint16_t port : UDP_PORTS) {
|
||||||
udp.beginPacket("255.255.255.255", UDP_PORTS[i]);
|
udp.beginPacket("255.255.255.255", port);
|
||||||
udp.write(reinterpret_cast<const uint8_t*>("hello"), 5);
|
udp.write(reinterpret_cast<const uint8_t*>("hello"), 5);
|
||||||
udp.endPacket();
|
udp.endPacket();
|
||||||
}
|
}
|
||||||
@ -384,9 +389,10 @@ bool CalibreWirelessActivity::readJsonMessage(std::string& message) {
|
|||||||
void CalibreWirelessActivity::sendJsonResponse(int opcode, const std::string& data) {
|
void CalibreWirelessActivity::sendJsonResponse(int opcode, const std::string& data) {
|
||||||
// Format: length + [opcode, {data}]
|
// Format: length + [opcode, {data}]
|
||||||
std::string json = "[" + std::to_string(opcode) + "," + data + "]";
|
std::string json = "[" + std::to_string(opcode) + "," + data + "]";
|
||||||
std::string packet = std::to_string(json.length()) + json;
|
const std::string lengthPrefix = std::to_string(json.length());
|
||||||
|
json.insert(0, lengthPrefix);
|
||||||
|
|
||||||
tcpClient.write(reinterpret_cast<const uint8_t*>(packet.c_str()), packet.length());
|
tcpClient.write(reinterpret_cast<const uint8_t*>(json.c_str()), json.length());
|
||||||
tcpClient.flush();
|
tcpClient.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -418,17 +424,24 @@ void CalibreWirelessActivity::handleCommand(int opcode, const std::string& data)
|
|||||||
break;
|
break;
|
||||||
case OP_SET_CALIBRE_DEVICE_INFO:
|
case OP_SET_CALIBRE_DEVICE_INFO:
|
||||||
case OP_SET_CALIBRE_DEVICE_NAME:
|
case OP_SET_CALIBRE_DEVICE_NAME:
|
||||||
// Just acknowledge
|
// These set metadata about the connected Calibre instance.
|
||||||
|
// We don't need this info, just acknowledge receipt.
|
||||||
sendJsonResponse(OP_OK, "{}");
|
sendJsonResponse(OP_OK, "{}");
|
||||||
break;
|
break;
|
||||||
case OP_SET_LIBRARY_INFO:
|
case OP_SET_LIBRARY_INFO:
|
||||||
|
// Library metadata (name, UUID) - not needed for receiving books
|
||||||
|
sendJsonResponse(OP_OK, "{}");
|
||||||
|
break;
|
||||||
case OP_SEND_BOOKLISTS:
|
case OP_SEND_BOOKLISTS:
|
||||||
|
// Calibre asking us to send our book list. We report 0 books in
|
||||||
|
// handleGetBookCount, so this is effectively a no-op.
|
||||||
sendJsonResponse(OP_OK, "{}");
|
sendJsonResponse(OP_OK, "{}");
|
||||||
break;
|
break;
|
||||||
case OP_TOTAL_SPACE:
|
case OP_TOTAL_SPACE:
|
||||||
handleFreeSpace();
|
handleFreeSpace();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
Serial.printf("[%lu] [CAL] Unknown opcode: %d\n", millis(), opcode);
|
||||||
sendJsonResponse(OP_OK, "{}");
|
sendJsonResponse(OP_OK, "{}");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -453,8 +466,11 @@ void CalibreWirelessActivity::handleGetInitializationInfo(const std::string& dat
|
|||||||
response += "\"canStreamBooks\":true,";
|
response += "\"canStreamBooks\":true,";
|
||||||
response += "\"canStreamMetadata\":true,";
|
response += "\"canStreamMetadata\":true,";
|
||||||
response += "\"canUseCachedMetadata\":true,";
|
response += "\"canUseCachedMetadata\":true,";
|
||||||
response += "\"ccVersionNumber\":212,"; // Match a known CC version
|
// ccVersionNumber: Calibre Companion protocol version. 212 matches CC 5.4.20+.
|
||||||
response += "\"coverHeight\":240,";
|
// Using a known version ensures compatibility with Calibre's feature detection.
|
||||||
|
response += "\"ccVersionNumber\":212,";
|
||||||
|
// coverHeight: Max cover image height. We don't process covers, so this is informational only.
|
||||||
|
response += "\"coverHeight\":800,";
|
||||||
response += "\"deviceKind\":\"CrossPoint\",";
|
response += "\"deviceKind\":\"CrossPoint\",";
|
||||||
response += "\"deviceName\":\"CrossPoint\",";
|
response += "\"deviceName\":\"CrossPoint\",";
|
||||||
response += "\"extensionPathLengths\":{\"epub\":37},";
|
response += "\"extensionPathLengths\":{\"epub\":37},";
|
||||||
@ -472,17 +488,18 @@ void CalibreWirelessActivity::handleGetDeviceInformation() {
|
|||||||
response += "\"device_info\":{";
|
response += "\"device_info\":{";
|
||||||
response += "\"device_store_uuid\":\"" + getDeviceUuid() + "\",";
|
response += "\"device_store_uuid\":\"" + getDeviceUuid() + "\",";
|
||||||
response += "\"device_name\":\"CrossPoint Reader\",";
|
response += "\"device_name\":\"CrossPoint Reader\",";
|
||||||
response += "\"device_version\":\"1.0\"";
|
response += "\"device_version\":\"" CROSSPOINT_VERSION "\"";
|
||||||
response += "},";
|
response += "},";
|
||||||
response += "\"version\":1,";
|
response += "\"version\":1,";
|
||||||
response += "\"device_version\":\"1.0\"";
|
response += "\"device_version\":\"" CROSSPOINT_VERSION "\"";
|
||||||
response += "}";
|
response += "}";
|
||||||
|
|
||||||
sendJsonResponse(OP_OK, response);
|
sendJsonResponse(OP_OK, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
void CalibreWirelessActivity::handleFreeSpace() {
|
void CalibreWirelessActivity::handleFreeSpace() {
|
||||||
// Report 10GB free space
|
// TODO: Report actual SD card free space instead of hardcoded value
|
||||||
|
// Report 10GB free space for now
|
||||||
sendJsonResponse(OP_OK, "{\"free_space_on_device\":10737418240}");
|
sendJsonResponse(OP_OK, "{\"free_space_on_device\":10737418240}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -558,7 +575,7 @@ void CalibreWirelessActivity::handleSendBook(const std::string& data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize and create full path
|
// Sanitize and create full path
|
||||||
currentFilename = "/" + sanitizeFilename(filename);
|
currentFilename = "/" + StringUtils::sanitizeFilename(filename);
|
||||||
if (currentFilename.find(".epub") == std::string::npos) {
|
if (currentFilename.find(".epub") == std::string::npos) {
|
||||||
currentFilename += ".epub";
|
currentFilename += ".epub";
|
||||||
}
|
}
|
||||||
@ -684,20 +701,11 @@ void CalibreWirelessActivity::render() const {
|
|||||||
|
|
||||||
// Draw progress if receiving
|
// Draw progress if receiving
|
||||||
if (state == CalibreWirelessState::RECEIVING && currentFileSize > 0) {
|
if (state == CalibreWirelessState::RECEIVING && currentFileSize > 0) {
|
||||||
const int percent = static_cast<int>((bytesReceived * 100) / currentFileSize);
|
|
||||||
|
|
||||||
// Progress bar
|
|
||||||
const int barWidth = pageWidth - 100;
|
const int barWidth = pageWidth - 100;
|
||||||
const int barHeight = 20;
|
constexpr int barHeight = 20;
|
||||||
const int barX = 50;
|
constexpr int barX = 50;
|
||||||
const int barY = statusY + 20;
|
const int barY = statusY + 20;
|
||||||
|
ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, bytesReceived, currentFileSize);
|
||||||
renderer.drawRect(barX, barY, barWidth, barHeight);
|
|
||||||
renderer.fillRect(barX + 2, barY + 2, (barWidth - 4) * percent / 100, barHeight - 4);
|
|
||||||
|
|
||||||
// Percentage text
|
|
||||||
const std::string percentText = std::to_string(percent) + "%";
|
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, barY + barHeight + 15, percentText.c_str());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw error if present
|
// Draw error if present
|
||||||
@ -712,31 +720,6 @@ void CalibreWirelessActivity::render() const {
|
|||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string CalibreWirelessActivity::sanitizeFilename(const std::string& name) const {
|
|
||||||
std::string result;
|
|
||||||
result.reserve(name.size());
|
|
||||||
|
|
||||||
for (char c : name) {
|
|
||||||
if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|') {
|
|
||||||
result += '_';
|
|
||||||
} else if (c >= 32 && c < 127) {
|
|
||||||
result += c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trim leading/trailing spaces and dots
|
|
||||||
size_t start = 0;
|
|
||||||
while (start < result.size() && (result[start] == ' ' || result[start] == '.')) {
|
|
||||||
start++;
|
|
||||||
}
|
|
||||||
size_t end = result.size();
|
|
||||||
while (end > start && (result[end - 1] == ' ' || result[end - 1] == '.')) {
|
|
||||||
end--;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.substr(start, end - start);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string CalibreWirelessActivity::getDeviceUuid() const {
|
std::string CalibreWirelessActivity::getDeviceUuid() const {
|
||||||
// Generate a consistent UUID based on MAC address
|
// Generate a consistent UUID based on MAC address
|
||||||
uint8_t mac[6];
|
uint8_t mac[6];
|
||||||
|
|||||||
@ -28,11 +28,14 @@ enum class CalibreWirelessState {
|
|||||||
* CalibreWirelessActivity implements Calibre's "wireless device" protocol.
|
* CalibreWirelessActivity implements Calibre's "wireless device" protocol.
|
||||||
* This allows Calibre desktop to send books directly to the device over WiFi.
|
* This allows Calibre desktop to send books directly to the device over WiFi.
|
||||||
*
|
*
|
||||||
* Protocol:
|
* Protocol specification sourced from Calibre's smart device driver:
|
||||||
* 1. Device listens on UDP ports 54982, 48123, 39001, 44044, 59678
|
* https://github.com/kovidgoyal/calibre/blob/master/src/calibre/devices/smart_device_app/driver.py
|
||||||
* 2. Calibre broadcasts discovery messages
|
*
|
||||||
* 3. Device responds with its TCP server address
|
* Protocol overview:
|
||||||
* 4. Calibre connects via TCP and sends JSON commands
|
* 1. Device broadcasts "hello" on UDP ports 54982, 48123, 39001, 44044, 59678
|
||||||
|
* 2. Calibre responds with its TCP server address
|
||||||
|
* 3. Device connects to Calibre's TCP server
|
||||||
|
* 4. Calibre sends JSON commands with length-prefixed messages
|
||||||
* 5. Books are transferred as binary data after SEND_BOOK command
|
* 5. Books are transferred as binary data after SEND_BOOK command
|
||||||
*/
|
*/
|
||||||
class CalibreWirelessActivity final : public Activity {
|
class CalibreWirelessActivity final : public Activity {
|
||||||
@ -47,9 +50,6 @@ class CalibreWirelessActivity final : public Activity {
|
|||||||
|
|
||||||
// UDP discovery
|
// UDP discovery
|
||||||
WiFiUDP udp;
|
WiFiUDP udp;
|
||||||
static constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678};
|
|
||||||
static constexpr size_t UDP_PORT_COUNT = 5;
|
|
||||||
static constexpr uint16_t LOCAL_UDP_PORT = 8134; // Port to receive responses
|
|
||||||
|
|
||||||
// TCP connection (we connect to Calibre)
|
// TCP connection (we connect to Calibre)
|
||||||
WiFiClient tcpClient;
|
WiFiClient tcpClient;
|
||||||
@ -118,7 +118,6 @@ class CalibreWirelessActivity final : public Activity {
|
|||||||
void handleNoop(const std::string& data);
|
void handleNoop(const std::string& data);
|
||||||
|
|
||||||
// Utility
|
// Utility
|
||||||
std::string sanitizeFilename(const std::string& title) const;
|
|
||||||
std::string getDeviceUuid() const;
|
std::string getDeviceUuid() const;
|
||||||
void setState(CalibreWirelessState newState);
|
void setState(CalibreWirelessState newState);
|
||||||
void setStatus(const std::string& message);
|
void setStatus(const std::string& message);
|
||||||
|
|||||||
@ -13,10 +13,11 @@
|
|||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int pagesPerRefresh = 15;
|
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
||||||
constexpr unsigned long skipChapterMs = 700;
|
constexpr unsigned long skipChapterMs = 700;
|
||||||
constexpr unsigned long goHomeMs = 1000;
|
constexpr unsigned long goHomeMs = 1000;
|
||||||
constexpr int topPadding = 5;
|
constexpr int topPadding = 5;
|
||||||
|
constexpr int horizontalPadding = 5;
|
||||||
constexpr int statusBarMargin = 19;
|
constexpr int statusBarMargin = 19;
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
@ -253,8 +254,8 @@ void EpubReaderActivity::renderScreen() {
|
|||||||
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
||||||
&orientedMarginLeft);
|
&orientedMarginLeft);
|
||||||
orientedMarginTop += topPadding;
|
orientedMarginTop += topPadding;
|
||||||
orientedMarginLeft += SETTINGS.getReaderSideMargin();
|
orientedMarginLeft += horizontalPadding;
|
||||||
orientedMarginRight += SETTINGS.getReaderSideMargin();
|
orientedMarginRight += horizontalPadding;
|
||||||
orientedMarginBottom += statusBarMargin;
|
orientedMarginBottom += statusBarMargin;
|
||||||
|
|
||||||
if (!section) {
|
if (!section) {
|
||||||
@ -378,7 +379,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||||
pagesUntilFullRefresh = pagesPerRefresh;
|
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||||
} else {
|
} else {
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
pagesUntilFullRefresh--;
|
pagesUntilFullRefresh--;
|
||||||
|
|||||||
@ -11,13 +11,13 @@
|
|||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
|
|
||||||
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "XtcReaderChapterSelectionActivity.h"
|
#include "XtcReaderChapterSelectionActivity.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int pagesPerRefresh = 15;
|
|
||||||
constexpr unsigned long skipPageMs = 700;
|
constexpr unsigned long skipPageMs = 700;
|
||||||
constexpr unsigned long goHomeMs = 1000;
|
constexpr unsigned long goHomeMs = 1000;
|
||||||
} // namespace
|
} // namespace
|
||||||
@ -266,7 +266,7 @@ void XtcReaderActivity::renderPage() {
|
|||||||
// Display BW with conditional refresh based on pagesUntilFullRefresh
|
// Display BW with conditional refresh based on pagesUntilFullRefresh
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||||
pagesUntilFullRefresh = pagesPerRefresh;
|
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||||
} else {
|
} else {
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
pagesUntilFullRefresh--;
|
pagesUntilFullRefresh--;
|
||||||
@ -346,7 +346,7 @@ void XtcReaderActivity::renderPage() {
|
|||||||
// Display with appropriate refresh
|
// Display with appropriate refresh
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||||
pagesUntilFullRefresh = pagesPerRefresh;
|
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||||
} else {
|
} else {
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
pagesUntilFullRefresh--;
|
pagesUntilFullRefresh--;
|
||||||
|
|||||||
@ -98,13 +98,7 @@ void CalibreSettingsActivity::handleSelection() {
|
|||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
}));
|
}));
|
||||||
} else if (selectedIndex == 1) {
|
} else if (selectedIndex == 1) {
|
||||||
// Wireless Device - toggle and launch activity if enabling
|
// Wireless Device - launch the activity (handles WiFi connection internally)
|
||||||
const bool wasEnabled = SETTINGS.calibreWirelessEnabled;
|
|
||||||
SETTINGS.calibreWirelessEnabled = !wasEnabled;
|
|
||||||
SETTINGS.saveToFile();
|
|
||||||
|
|
||||||
if (!wasEnabled) {
|
|
||||||
// Just enabled - launch the wireless activity
|
|
||||||
exitActivity();
|
exitActivity();
|
||||||
if (WiFi.status() != WL_CONNECTED) {
|
if (WiFi.status() != WL_CONNECTED) {
|
||||||
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](bool connected) {
|
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](bool connected) {
|
||||||
@ -115,9 +109,6 @@ void CalibreSettingsActivity::handleSelection() {
|
|||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// WiFi connection failed/cancelled, turn off the setting
|
|
||||||
SETTINGS.calibreWirelessEnabled = 0;
|
|
||||||
SETTINGS.saveToFile();
|
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@ -127,10 +118,6 @@ void CalibreSettingsActivity::handleSelection() {
|
|||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Just disabled - just update the display
|
|
||||||
updateRequired = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
xSemaphoreGive(renderingMutex);
|
xSemaphoreGive(renderingMutex);
|
||||||
@ -166,19 +153,13 @@ void CalibreSettingsActivity::render() {
|
|||||||
|
|
||||||
renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected);
|
renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected);
|
||||||
|
|
||||||
// Draw status
|
// Draw status for URL setting
|
||||||
const char* status = "";
|
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
// Calibre Web URL
|
const char* status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]";
|
||||||
status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]";
|
|
||||||
} else if (i == 1) {
|
|
||||||
// Wireless Device
|
|
||||||
status = SETTINGS.calibreWirelessEnabled ? "ON" : "OFF";
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto width = renderer.getTextWidth(UI_10_FONT_ID, status);
|
const auto width = renderer.getTextWidth(UI_10_FONT_ID, status);
|
||||||
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected);
|
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Draw button hints
|
// Draw button hints
|
||||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
|
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
// Define the static settings list
|
// Define the static settings list
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int settingsCount = 14;
|
constexpr int settingsCount = 15;
|
||||||
const SettingInfo settingsList[settingsCount] = {
|
const SettingInfo settingsList[settingsCount] = {
|
||||||
// Should match with SLEEP_SCREEN_MODE
|
// Should match with SLEEP_SCREEN_MODE
|
||||||
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
|
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
|
||||||
@ -41,7 +41,14 @@ const SettingInfo settingsList[settingsCount] = {
|
|||||||
SettingType::ENUM,
|
SettingType::ENUM,
|
||||||
&CrossPointSettings::paragraphAlignment,
|
&CrossPointSettings::paragraphAlignment,
|
||||||
{"Justify", "Left", "Center", "Right"}},
|
{"Justify", "Left", "Center", "Right"}},
|
||||||
{"Reader Side Margin", SettingType::ENUM, &CrossPointSettings::sideMargin, {"None", "Small", "Medium", "Large"}},
|
{"Time to Sleep",
|
||||||
|
SettingType::ENUM,
|
||||||
|
&CrossPointSettings::sleepTimeout,
|
||||||
|
{"1 min", "5 min", "10 min", "15 min", "30 min"}},
|
||||||
|
{"Refresh Frequency",
|
||||||
|
SettingType::ENUM,
|
||||||
|
&CrossPointSettings::refreshFrequency,
|
||||||
|
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}},
|
||||||
{"Calibre Settings", SettingType::ACTION, nullptr, {}},
|
{"Calibre Settings", SettingType::ACTION, nullptr, {}},
|
||||||
{"Check for updates", SettingType::ACTION, nullptr, {}},
|
{"Check for updates", SettingType::ACTION, nullptr, {}},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -329,9 +329,13 @@ void KeyboardEntryActivity::render() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw help text at absolute bottom of screen (consistent with other screens)
|
// Draw help text
|
||||||
const auto pageHeight = renderer.getScreenHeight();
|
const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right");
|
||||||
renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK");
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
|
||||||
|
// Draw side button hints for Up/Down navigation
|
||||||
|
renderer.drawSideButtonHints(UI_10_FONT_ID, "Up", "Down");
|
||||||
|
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
src/main.cpp
51
src/main.cpp
@ -5,7 +5,6 @@
|
|||||||
#include <InputManager.h>
|
#include <InputManager.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
#include <SPI.h>
|
#include <SPI.h>
|
||||||
#include <WiFi.h>
|
|
||||||
#include <builtinFonts/all.h>
|
#include <builtinFonts/all.h>
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@ -23,7 +22,6 @@
|
|||||||
#include "activities/reader/ReaderActivity.h"
|
#include "activities/reader/ReaderActivity.h"
|
||||||
#include "activities/settings/SettingsActivity.h"
|
#include "activities/settings/SettingsActivity.h"
|
||||||
#include "activities/util/FullScreenMessageActivity.h"
|
#include "activities/util/FullScreenMessageActivity.h"
|
||||||
#include "activities/util/KeyboardEntryActivity.h"
|
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
#define SPI_FQ 40000000
|
#define SPI_FQ 40000000
|
||||||
@ -132,8 +130,6 @@ EpdFont ui12RegularFont(&ubuntu_12_regular);
|
|||||||
EpdFont ui12BoldFont(&ubuntu_12_bold);
|
EpdFont ui12BoldFont(&ubuntu_12_bold);
|
||||||
EpdFontFamily ui12FontFamily(&ui12RegularFont, &ui12BoldFont);
|
EpdFontFamily ui12FontFamily(&ui12RegularFont, &ui12BoldFont);
|
||||||
|
|
||||||
// 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
|
// measurement of power button press duration calibration value
|
||||||
unsigned long t1 = 0;
|
unsigned long t1 = 0;
|
||||||
unsigned long t2 = 0;
|
unsigned long t2 = 0;
|
||||||
@ -156,8 +152,10 @@ void verifyWakeupLongPress() {
|
|||||||
// Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration()
|
// Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration()
|
||||||
const auto start = millis();
|
const auto start = millis();
|
||||||
bool abort = false;
|
bool abort = false;
|
||||||
// It takes us some time to wake up from deep sleep, so we need to subtract that from the duration
|
// Subtract the current time, because inputManager only starts counting the HeldTime from the first update()
|
||||||
constexpr uint16_t calibration = 29;
|
// This way, we remove the time we already took to reach here from the duration,
|
||||||
|
// assuming the button was held until now from millis()==0 (i.e. device start time).
|
||||||
|
const uint16_t calibration = start;
|
||||||
const uint16_t calibratedPressDuration =
|
const uint16_t calibratedPressDuration =
|
||||||
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
|
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
|
||||||
|
|
||||||
@ -228,43 +226,9 @@ void onGoToSettings() {
|
|||||||
enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome));
|
enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to launch browser after WiFi is connected
|
|
||||||
void launchBrowserWithUrlCheck() {
|
|
||||||
// If no server URL configured, prompt for one first
|
|
||||||
if (strlen(SETTINGS.opdsServerUrl) == 0) {
|
|
||||||
enterNewActivity(new KeyboardEntryActivity(
|
|
||||||
renderer, mappedInputManager, "Calibre Web URL", "", 10, 127, false,
|
|
||||||
[](const std::string& url) {
|
|
||||||
strncpy(SETTINGS.opdsServerUrl, url.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1);
|
|
||||||
SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0';
|
|
||||||
SETTINGS.saveToFile();
|
|
||||||
exitActivity();
|
|
||||||
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome));
|
|
||||||
},
|
|
||||||
[] {
|
|
||||||
exitActivity();
|
|
||||||
onGoHome();
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void onGoToBrowser() {
|
void onGoToBrowser() {
|
||||||
exitActivity();
|
exitActivity();
|
||||||
// Check WiFi connectivity first
|
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome));
|
||||||
if (WiFi.status() != WL_CONNECTED) {
|
|
||||||
enterNewActivity(new WifiSelectionActivity(renderer, mappedInputManager, [](bool connected) {
|
|
||||||
exitActivity();
|
|
||||||
if (connected) {
|
|
||||||
launchBrowserWithUrlCheck();
|
|
||||||
} else {
|
|
||||||
onGoHome();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
launchBrowserWithUrlCheck();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onGoHome() {
|
void onGoHome() {
|
||||||
@ -368,8 +332,9 @@ void loop() {
|
|||||||
lastActivityTime = millis(); // Reset inactivity timer
|
lastActivityTime = millis(); // Reset inactivity timer
|
||||||
}
|
}
|
||||||
|
|
||||||
if (millis() - lastActivityTime >= AUTO_SLEEP_TIMEOUT_MS) {
|
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
|
||||||
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), AUTO_SLEEP_TIMEOUT_MS);
|
if (millis() - lastActivityTime >= sleepTimeoutMs) {
|
||||||
|
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), sleepTimeoutMs);
|
||||||
enterDeepSleep();
|
enterDeepSleep();
|
||||||
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
|
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
#include <HTTPClient.h>
|
#include <HTTPClient.h>
|
||||||
#include <Update.h>
|
#include <Update.h>
|
||||||
#include <WiFiClientSecure.h>
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest";
|
constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest";
|
||||||
@ -69,44 +68,41 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
|
|||||||
return OK;
|
return OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool OtaUpdater::isUpdateNewer() {
|
bool OtaUpdater::isUpdateNewer() const {
|
||||||
if (!updateAvailable || latestVersion.empty() || latestVersion == CROSSPOINT_VERSION) {
|
if (!updateAvailable || latestVersion.empty() || latestVersion == CROSSPOINT_VERSION) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int currentMajor, currentMinor, currentPatch;
|
||||||
|
int latestMajor, latestMinor, latestPatch;
|
||||||
|
|
||||||
|
const auto currentVersion = CROSSPOINT_VERSION;
|
||||||
|
|
||||||
// semantic version check (only match on 3 segments)
|
// semantic version check (only match on 3 segments)
|
||||||
const auto updateMajor = stoi(latestVersion.substr(0, latestVersion.find('.')));
|
sscanf(latestVersion.c_str(), "%d.%d.%d", &latestMajor, &latestMinor, &latestPatch);
|
||||||
const auto updateMinor = stoi(
|
sscanf(currentVersion, "%d.%d.%d", ¤tMajor, ¤tMinor, ¤tPatch);
|
||||||
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('.')));
|
* Compare major versions.
|
||||||
const auto currentMinor = stoi(currentVersion.substr(
|
* If they differ, return true if latest major version greater than current major version
|
||||||
currentVersion.find('.') + 1, currentVersion.find_last_of('.') - currentVersion.find('.') - 1));
|
* otherwise return false.
|
||||||
const auto currentPatch = stoi(currentVersion.substr(currentVersion.find_last_of('.') + 1));
|
*/
|
||||||
|
if (latestMajor != currentMajor) return latestMajor > currentMajor;
|
||||||
|
|
||||||
if (updateMajor > currentMajor) {
|
/*
|
||||||
return true;
|
* Compare minor versions.
|
||||||
}
|
* If they differ, return true if latest minor version greater than current minor version
|
||||||
if (updateMajor < currentMajor) {
|
* otherwise return false.
|
||||||
return false;
|
*/
|
||||||
}
|
if (latestMinor != currentMinor) return latestMinor > currentMinor;
|
||||||
|
|
||||||
if (updateMinor > currentMinor) {
|
/*
|
||||||
return true;
|
* Check patch versions.
|
||||||
}
|
*/
|
||||||
if (updateMinor < currentMinor) {
|
return latestPatch > currentPatch;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updatePatch > currentPatch) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::string& OtaUpdater::getLatestVersion() { return latestVersion; }
|
const std::string& OtaUpdater::getLatestVersion() const { return latestVersion; }
|
||||||
|
|
||||||
OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(const std::function<void(size_t, size_t)>& onProgress) {
|
OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(const std::function<void(size_t, size_t)>& onProgress) {
|
||||||
if (!isUpdateNewer()) {
|
if (!isUpdateNewer()) {
|
||||||
|
|||||||
@ -23,8 +23,8 @@ class OtaUpdater {
|
|||||||
size_t totalSize = 0;
|
size_t totalSize = 0;
|
||||||
|
|
||||||
OtaUpdater() = default;
|
OtaUpdater() = default;
|
||||||
bool isUpdateNewer();
|
bool isUpdateNewer() const;
|
||||||
const std::string& getLatestVersion();
|
const std::string& getLatestVersion() const;
|
||||||
OtaUpdaterError checkForUpdate();
|
OtaUpdaterError checkForUpdate();
|
||||||
OtaUpdaterError installUpdate(const std::function<void(size_t, size_t)>& onProgress);
|
OtaUpdaterError installUpdate(const std::function<void(size_t, size_t)>& onProgress);
|
||||||
};
|
};
|
||||||
|
|||||||
36
src/util/StringUtils.cpp
Normal file
36
src/util/StringUtils.cpp
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
#include "StringUtils.h"
|
||||||
|
|
||||||
|
namespace StringUtils {
|
||||||
|
|
||||||
|
std::string sanitizeFilename(const std::string& name, size_t maxLength) {
|
||||||
|
std::string result;
|
||||||
|
result.reserve(name.size());
|
||||||
|
|
||||||
|
for (char c : name) {
|
||||||
|
// Replace invalid filename characters with underscore
|
||||||
|
if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|') {
|
||||||
|
result += '_';
|
||||||
|
} else if (c >= 32 && c < 127) {
|
||||||
|
// Keep printable ASCII characters
|
||||||
|
result += c;
|
||||||
|
}
|
||||||
|
// Skip non-printable characters
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim leading/trailing spaces and dots
|
||||||
|
size_t start = result.find_first_not_of(" .");
|
||||||
|
if (start == std::string::npos) {
|
||||||
|
return "book"; // Fallback if name is all invalid characters
|
||||||
|
}
|
||||||
|
size_t end = result.find_last_not_of(" .");
|
||||||
|
result = result.substr(start, end - start + 1);
|
||||||
|
|
||||||
|
// Limit filename length
|
||||||
|
if (result.length() > maxLength) {
|
||||||
|
result.resize(maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.empty() ? "book" : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace StringUtils
|
||||||
13
src/util/StringUtils.h
Normal file
13
src/util/StringUtils.h
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace StringUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a string for use as a filename.
|
||||||
|
* Replaces invalid characters with underscores, trims spaces/dots,
|
||||||
|
* and limits length to maxLength characters.
|
||||||
|
*/
|
||||||
|
std::string sanitizeFilename(const std::string& name, size_t maxLength = 100);
|
||||||
|
|
||||||
|
} // namespace StringUtils
|
||||||
41
src/util/UrlUtils.cpp
Normal file
41
src/util/UrlUtils.cpp
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
#include "UrlUtils.h"
|
||||||
|
|
||||||
|
namespace UrlUtils {
|
||||||
|
|
||||||
|
std::string ensureProtocol(const std::string& url) {
|
||||||
|
if (url.find("://") == std::string::npos) {
|
||||||
|
return "http://" + url;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string extractHost(const std::string& url) {
|
||||||
|
const size_t protocolEnd = url.find("://");
|
||||||
|
if (protocolEnd == std::string::npos) {
|
||||||
|
// No protocol, find first slash
|
||||||
|
const size_t firstSlash = url.find('/');
|
||||||
|
return firstSlash == std::string::npos ? url : url.substr(0, firstSlash);
|
||||||
|
}
|
||||||
|
// Find the first slash after the protocol
|
||||||
|
const size_t hostStart = protocolEnd + 3;
|
||||||
|
const size_t pathStart = url.find('/', hostStart);
|
||||||
|
return pathStart == std::string::npos ? url : url.substr(0, pathStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string buildUrl(const std::string& serverUrl, const std::string& path) {
|
||||||
|
const std::string urlWithProtocol = ensureProtocol(serverUrl);
|
||||||
|
if (path.empty()) {
|
||||||
|
return urlWithProtocol;
|
||||||
|
}
|
||||||
|
if (path[0] == '/') {
|
||||||
|
// Absolute path - use just the host
|
||||||
|
return extractHost(urlWithProtocol) + path;
|
||||||
|
}
|
||||||
|
// Relative path - append to server URL
|
||||||
|
if (urlWithProtocol.back() == '/') {
|
||||||
|
return urlWithProtocol + path;
|
||||||
|
}
|
||||||
|
return urlWithProtocol + "/" + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace UrlUtils
|
||||||
23
src/util/UrlUtils.h
Normal file
23
src/util/UrlUtils.h
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace UrlUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepend http:// if no protocol specified (server will redirect to https if needed)
|
||||||
|
*/
|
||||||
|
std::string ensureProtocol(const std::string& url);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract host with protocol from URL (e.g., "http://example.com" from "http://example.com/path")
|
||||||
|
*/
|
||||||
|
std::string extractHost(const std::string& url);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build full URL from server URL and path.
|
||||||
|
* If path starts with /, it's an absolute path from the host root.
|
||||||
|
* Otherwise, it's relative to the server URL.
|
||||||
|
*/
|
||||||
|
std::string buildUrl(const std::string& serverUrl, const std::string& path);
|
||||||
|
|
||||||
|
} // namespace UrlUtils
|
||||||
Loading…
Reference in New Issue
Block a user