Compare commits

...

10 Commits

Author SHA1 Message Date
Justin Mitchell
cb21668cb7 Turn wifi off when exiting browser and wireless activities 2026-01-04 00:46:02 -05:00
Justin Mitchell
1a462f0506 Merge remote-tracking branch 'upstream/master' into feature/calibre-web-and-margins
# Conflicts:
#	src/CrossPointSettings.cpp
#	src/CrossPointSettings.h
#	src/activities/settings/SettingsActivity.cpp
2026-01-04 00:03:33 -05:00
Justin Mitchell
8e1ba0019b addresses comments from maintainer 2026-01-03 23:52:32 -05:00
Jonas Diemer
c8f4870d7c
Improved battery symbol (#228)
Some checks are pending
CI / build (push) Waiting to run
Rounded corners, smaller positive terminal thingy.
2026-01-04 15:27:34 +11:00
Dave Allie
5fdf23f1d2
Cut release 0.12.0
Some checks are pending
CI / build (push) Waiting to run
2026-01-03 19:42:14 +11:00
Justinian
2fb417ee90
Feat/sleep and refresh settings (#209)
## Summary

* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module, Implements the new feature for
  file uploading.)
* **What changes are included?**

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).

---------

Co-authored-by: ratedcounsel <hello@ratedcounsel.com>
Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-03 19:33:42 +11:00
V
c8f6160fbc
Refactor semantic version comparison for OTA updates (#216)
## Summary

* **What is the goal of this PR?** (e.g., Fixes a bug in the user
authentication module, Implements the new feature for
  file uploading.)

This PR refactors the semantic version comparison logic used during OTA
update checks.

Memory stats before : 
RAM:   [===       ]  30.8% (used 101068 bytes from 327680 bytes)
Flash: [========= ]  85.7% (used **5617830** bytes from 6553600 bytes)

Memory stats before : 
RAM:   [===       ]  30.8% (used 101068 bytes from 327680 bytes)
Flash: [========= ]  85.7% (used **5616870** bytes from 6553600 bytes)


* **What changes are included?**

Replaced std::string::substr() and std::stoi() based parsing with a
lightweight, heap-free approach.
Version parsing is now done in a single pass without creating temporary
std::string objects.
Behavior remains identical: versions are still compared as
MAJOR.MINOR.PATCH.

## Additional Context

`std::string::substr() ` creates a new string and performs heap
allocation

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks, specific areas to
focus on).
2026-01-03 19:19:53 +11:00
Brendan O'Leary
8e4484cd22
Add buton hints to keyboard screen (#205)
## Summary

This adds the correctly styled button hints to the keyboard screen as
well as the ability to add hints to the side buttons (and up/down hints
to that screen)

## Additional Context

N/A
2026-01-03 19:17:53 +11:00
Pavel Liashkov
0332e1103a
Add EPUB 3 nav.xhtml TOC support (#197)
## Summary

* **What is the goal of this PR?** Add EPUB 3 support by implementing
native navigation document (nav.xhtml) parsing with NCX fallback,
addressing issue Fixes: #143.

  * **What changes are included?**
- New `TocNavParser` for parsing EPUB 3 HTML5 navigation documents
(`<nav epub:type="toc">`)
- Detection of nav documents via `properties="nav"` attribute in OPF
manifest
- Fallback logic: try EPUB 3 nav first, fall back to NCX (EPUB 2) if
unavailable
- Graceful degradation: books without any TOC now load with a warning
instead of failing

  ## Additional Context

* The implementation follows the existing streaming XML parser pattern
using Expat to minimize RAM usage on the ESP32-C3
* EPUB 3 books that include both nav.xhtml and toc.ncx will prefer the
nav document (per EPUB 3 spec recommendation)
* No breaking changes - existing EPUB 2 books continue to work as before
* Tested on examples from
https://idpf.github.io/epub3-samples/30/samples.html
2026-01-03 19:10:35 +11:00
Jonas Diemer
5790d6f5dc
Subtract time it took reaching the evaluation from the press duration. (#208)
Adresses #206
2026-01-03 18:54:23 +11:00
32 changed files with 918 additions and 328 deletions

View File

@ -25,7 +25,7 @@ This project is **not affiliated with Xteink**; it's built as a community projec
## Features & Usage
- [x] EPUB parsing and rendering
- [x] EPUB parsing and rendering (EPUB 2 and EPUB 3)
- [ ] Image support within EPUB
- [x] Saved reading position
- [x] File explorer with file picker

View File

@ -8,6 +8,7 @@
#include "Epub/parsers/ContainerParser.h"
#include "Epub/parsers/ContentOpfParser.h"
#include "Epub/parsers/TocNavParser.h"
#include "Epub/parsers/TocNcxParser.h"
bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
@ -80,6 +81,10 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
tocNcxItem = opfParser.tocNcxPath;
}
if (!opfParser.tocNavPath.empty()) {
tocNavItem = opfParser.tocNavPath;
}
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis());
return true;
}
@ -141,6 +146,60 @@ bool Epub::parseTocNcxFile() const {
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
bool Epub::load(const bool buildIfMissing) {
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str());
@ -184,15 +243,31 @@ bool Epub::load(const bool buildIfMissing) {
return false;
}
// TOC Pass
// TOC Pass - try EPUB 3 nav first, fall back to NCX
if (!bookMetadataCache->beginTocPass()) {
Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis());
return false;
}
if (!parseTocNcxFile()) {
Serial.printf("[%lu] [EBP] Could not parse toc\n", millis());
return false;
bool tocParsed = 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()) {
Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis());
return false;

View File

@ -12,8 +12,10 @@
class ZipFile;
class Epub {
// the ncx file
// the ncx file (EPUB 2)
std::string tocNcxItem;
// the nav file (EPUB 3)
std::string tocNavItem;
// where is the EPUBfile?
std::string filepath;
// the base path for items in the EPUB file
@ -26,6 +28,7 @@ class Epub {
bool findContentOpfFile(std::string* contentOpfFile) const;
bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata);
bool parseTocNcxFile() const;
bool parseTocNavFile() const;
public:
explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) {

View File

@ -161,6 +161,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
std::string itemId;
std::string href;
std::string mediaType;
std::string properties;
for (int i = 0; atts[i]; i += 2) {
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];
} else if (strcmp(atts[i], "media-type") == 0) {
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());
}
}
// 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;
}

View File

@ -35,6 +35,7 @@ class ContentOpfParser final : public Print {
std::string title;
std::string author;
std::string tocNcxPath;
std::string tocNavPath; // EPUB 3 nav document path
std::string coverItemHref;
std::string textReferenceHref;

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

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

View File

@ -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(); }
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }

View File

@ -82,7 +82,15 @@ class GfxRenderer {
// UI Components
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
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
void copyGrayscaleLsbBuffers() const;

View File

@ -1,5 +1,5 @@
[platformio]
crosspoint_version = 0.11.2
crosspoint_version = 0.12.0
default_envs = default
[base]

View File

@ -40,9 +40,9 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, fontSize);
serialization::writePod(outputFile, lineSpacing);
serialization::writePod(outputFile, paragraphAlignment);
serialization::writePod(outputFile, sideMargin);
serialization::writePod(outputFile, sleepTimeout);
serialization::writePod(outputFile, refreshFrequency);
serialization::writeString(outputFile, std::string(opdsServerUrl));
serialization::writePod(outputFile, calibreWirelessEnabled);
outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
@ -91,7 +91,9 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, paragraphAlignment);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, sideMargin);
serialization::readPod(inputFile, sleepTimeout);
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, refreshFrequency);
if (++settingsRead >= fileSettingsCount) break;
{
std::string urlStr;
@ -99,9 +101,6 @@ bool CrossPointSettings::loadFromFile() {
strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1);
opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0';
}
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, calibreWirelessEnabled);
if (++settingsRead >= fileSettingsCount) break;
} while (false);
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 {
switch (fontFamily) {
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;
}
}

View File

@ -44,7 +44,12 @@ class CrossPointSettings {
enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 };
enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 };
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
uint8_t sleepScreen = DARK;
@ -65,11 +70,12 @@ class CrossPointSettings {
uint8_t fontSize = MEDIUM;
uint8_t lineSpacing = NORMAL;
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
char opdsServerUrl[128] = "";
// Calibre wireless device settings
uint8_t calibreWirelessEnabled = 0;
~CrossPointSettings() = default;
@ -83,7 +89,8 @@ class CrossPointSettings {
bool loadFromFile();
float getReaderLineCompression() const;
int getReaderSideMargin() const;
unsigned long getSleepTimeoutMs() const;
int getRefreshFrequency() const;
};
// Helper macro to access settings

View File

@ -2,6 +2,7 @@
#include <GfxRenderer.h>
#include <cstdint>
#include <string>
#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
constexpr int batteryWidth = 15;
constexpr int batteryHeight = 10;
constexpr int batteryHeight = 12;
const int x = left;
const int y = top + 8;
const int y = top + 6;
// Top line
renderer.drawLine(x, y, x + batteryWidth - 4, y);
renderer.drawLine(x + 1, y, x + batteryWidth - 3, y);
// 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
renderer.drawLine(x, y, x, y + batteryHeight - 1);
renderer.drawLine(x, y + 1, x, y + batteryHeight - 2);
// Battery end
renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1);
renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2);
renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3);
renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3);
renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2);
renderer.drawPixel(x + batteryWidth - 1, y + 3);
renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4);
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
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
}
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());
}

View File

@ -1,8 +1,24 @@
#pragma once
#include <cstddef>
#include <cstdint>
class GfxRenderer;
class ScreenComponents {
public:
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);
};

View File

@ -2,57 +2,21 @@
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <WiFi.h>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "ScreenComponents.h"
#include "WifiCredentialStore.h"
#include "fontIds.h"
#include "network/HttpDownloader.h"
#include "util/StringUtils.h"
#include "util/UrlUtils.h"
namespace {
constexpr int PAGE_ITEMS = 23;
constexpr int SKIP_PAGE_MS = 700;
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
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
@ -64,13 +28,13 @@ void OpdsBookBrowserActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
state = BrowserState::LOADING;
state = BrowserState::CHECK_WIFI;
entries.clear();
navigationHistory.clear();
currentPath = OPDS_ROOT_PATH;
selectorIndex = 0;
errorMessage.clear();
statusMessage = "Loading...";
statusMessage = "Checking WiFi...";
updateRequired = true;
xTaskCreate(&OpdsBookBrowserActivity::taskTrampoline, "OpdsBookBrowserTask",
@ -80,13 +44,16 @@ void OpdsBookBrowserActivity::onEnter() {
&displayTaskHandle // Task handle
);
// Fetch feed after setting up the display task
fetchFeed(currentPath);
// Check WiFi and connect if needed, then fetch feed
checkAndConnectWifi();
}
void OpdsBookBrowserActivity::onExit() {
Activity::onExit();
// Turn off WiFi when exiting
WiFi.mode(WIFI_OFF);
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
@ -112,6 +79,14 @@ void OpdsBookBrowserActivity::loop() {
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
if (state == BrowserState::LOADING) {
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);
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) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
const auto labels = mappedInput.mapLabels("« Back", "", "", "");
@ -200,13 +183,14 @@ void OpdsBookBrowserActivity::render() const {
}
if (state == BrowserState::DOWNLOADING) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Downloading...");
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, statusMessage.c_str());
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, "Downloading...");
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, statusMessage.c_str());
if (downloadTotal > 0) {
const int percent = (downloadProgress * 100) / downloadTotal;
char progressText[32];
snprintf(progressText, sizeof(progressText), "%d%%", percent);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 40, progressText);
const int barWidth = pageWidth - 100;
constexpr int barHeight = 20;
constexpr int barX = 50;
const int barY = pageHeight / 2 + 20;
ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, downloadProgress, downloadTotal);
}
renderer.displayBuffer();
return;
@ -262,7 +246,7 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
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());
std::string content;
@ -336,10 +320,14 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
updateRequired = true;
// Build full download URL
std::string downloadUrl = buildUrl(SETTINGS.opdsServerUrl, book.href);
std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href);
// Create sanitized filename
std::string filename = "/" + sanitizeFilename(book.title) + ".epub";
// Create sanitized filename: "Title - Author.epub" or just "Title.epub" if no author
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());
@ -361,33 +349,51 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
}
}
std::string OpdsBookBrowserActivity::sanitizeFilename(const std::string& title) const {
std::string result;
result.reserve(title.size());
for (char c : title) {
// 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
void OpdsBookBrowserActivity::checkAndConnectWifi() {
// Already connected?
if (WiFi.status() == WL_CONNECTED) {
state = BrowserState::LOADING;
statusMessage = "Loading...";
updateRequired = true;
fetchFeed(currentPath);
return;
}
// Trim leading/trailing spaces and dots
size_t start = result.find_first_not_of(" .");
if (start == std::string::npos) {
return "book"; // Fallback if title is all invalid characters
}
size_t end = result.find_last_not_of(" .");
result = result.substr(start, end - start + 1);
// Try to connect using saved credentials
statusMessage = "Connecting to WiFi...";
updateRequired = true;
// Limit filename length (SD card FAT32 has 255 char limit, but let's be safe)
if (result.length() > 100) {
result.resize(100);
WIFI_STORE.loadFromFile();
const auto& credentials = WIFI_STORE.getCredentials();
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;
}
}

View File

@ -17,6 +17,7 @@
class OpdsBookBrowserActivity final : public Activity {
public:
enum class BrowserState {
CHECK_WIFI, // Checking WiFi connection
LOADING, // Fetching OPDS feed
BROWSING, // Displaying entries (navigation or books)
DOWNLOADING, // Downloading selected EPUB
@ -52,9 +53,9 @@ class OpdsBookBrowserActivity final : public Activity {
[[noreturn]] void displayTaskLoop();
void render() const;
void checkAndConnectWifi();
void fetchFeed(const std::string& path);
void navigateToEntry(const OpdsEntry& entry);
void navigateBack();
void downloadBook(const OpdsEntry& book);
std::string sanitizeFilename(const std::string& title) const;
};

View File

@ -21,7 +21,7 @@ void HomeActivity::taskTrampoline(void* param) {
int HomeActivity::getMenuItemCount() const {
int count = 3; // Browse files, File transfer, Settings
if (hasContinueReading) count++;
if (hasBrowserUrl) count++;
if (hasOpdsUrl) count++;
return count;
}
@ -33,8 +33,8 @@ void HomeActivity::onEnter() {
// Check if we have a book to continue reading
hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str());
// Check if browser URL is configured
hasBrowserUrl = strlen(SETTINGS.opdsServerUrl) > 0;
// Check if OPDS browser URL is configured
hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0;
if (hasContinueReading) {
// Extract filename from path for display
@ -102,7 +102,7 @@ void HomeActivity::loop() {
int idx = 0;
const int continueIdx = hasContinueReading ? idx++ : -1;
const int browseFilesIdx = idx++;
const int browseBookIdx = hasBrowserUrl ? idx++ : -1;
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
const int fileTransferIdx = idx++;
const int settingsIdx = idx;
@ -110,8 +110,8 @@ void HomeActivity::loop() {
onContinueReading();
} else if (selectorIndex == browseFilesIdx) {
onReaderOpen();
} else if (selectorIndex == browseBookIdx) {
onBrowserOpen();
} else if (selectorIndex == opdsLibraryIdx) {
onOpdsBrowserOpen();
} else if (selectorIndex == fileTransferIdx) {
onFileTransferOpen();
} else if (selectorIndex == settingsIdx) {
@ -289,13 +289,11 @@ void HomeActivity::render() const {
// --- Bottom menu tiles ---
// Build menu items dynamically
std::vector<const char*> menuItems;
menuItems.push_back("Browse Files");
if (hasBrowserUrl) {
menuItems.push_back("Calibre Library");
std::vector<const char*> menuItems = {"Browse Files", "File Transfer", "Settings"};
if (hasOpdsUrl) {
// Insert Calibre Library after Browse Files
menuItems.insert(menuItems.begin() + 1, "Calibre Library");
}
menuItems.push_back("File Transfer");
menuItems.push_back("Settings");
const int menuTileWidth = pageWidth - 2 * margin;
constexpr int menuTileHeight = 45;

View File

@ -13,14 +13,14 @@ class HomeActivity final : public Activity {
int selectorIndex = 0;
bool updateRequired = false;
bool hasContinueReading = false;
bool hasBrowserUrl = false;
bool hasOpdsUrl = false;
std::string lastBookTitle;
std::string lastBookAuthor;
const std::function<void()> onContinueReading;
const std::function<void()> onReaderOpen;
const std::function<void()> onSettingsOpen;
const std::function<void()> onFileTransferOpen;
const std::function<void()> onBrowserOpen;
const std::function<void()> onOpdsBrowserOpen;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
@ -31,13 +31,13 @@ class HomeActivity final : public Activity {
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onContinueReading, const std::function<void()>& onReaderOpen,
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen,
const std::function<void()>& onBrowserOpen)
const std::function<void()>& onOpdsBrowserOpen)
: Activity("Home", renderer, mappedInput),
onContinueReading(onContinueReading),
onReaderOpen(onReaderOpen),
onSettingsOpen(onSettingsOpen),
onFileTransferOpen(onFileTransferOpen),
onBrowserOpen(onBrowserOpen) {}
onOpdsBrowserOpen(onOpdsBrowserOpen) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@ -1,17 +1,21 @@
#include "CalibreWirelessActivity.h"
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <WiFi.h>
#include <cstring>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "ScreenComponents.h"
#include "fontIds.h"
#include "util/StringUtils.h"
// Define static constexpr members
constexpr uint16_t CalibreWirelessActivity::UDP_PORTS[];
namespace {
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) {
auto* self = static_cast<CalibreWirelessActivity*>(param);
@ -57,9 +61,8 @@ void CalibreWirelessActivity::onEnter() {
void CalibreWirelessActivity::onExit() {
Activity::onExit();
// Always turn off the setting when exiting so it shows OFF in settings
SETTINGS.calibreWirelessEnabled = 0;
SETTINGS.saveToFile();
// Turn off WiFi when exiting
WiFi.mode(WIFI_OFF);
// Stop UDP listening
udp.stop();
@ -74,13 +77,15 @@ void CalibreWirelessActivity::onExit() {
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) {
vTaskDelete(networkTaskHandle);
networkTaskHandle = nullptr;
}
xSemaphoreGive(stateMutex);
// Acquire mutex before deleting display task
// Acquire renderingMutex before deleting display task
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
@ -143,8 +148,8 @@ void CalibreWirelessActivity::networkTaskLoop() {
void CalibreWirelessActivity::listenForDiscovery() {
// Broadcast "hello" on all UDP discovery ports to find Calibre
for (size_t i = 0; i < UDP_PORT_COUNT; i++) {
udp.beginPacket("255.255.255.255", UDP_PORTS[i]);
for (const uint16_t port : UDP_PORTS) {
udp.beginPacket("255.255.255.255", port);
udp.write(reinterpret_cast<const uint8_t*>("hello"), 5);
udp.endPacket();
}
@ -384,9 +389,10 @@ bool CalibreWirelessActivity::readJsonMessage(std::string& message) {
void CalibreWirelessActivity::sendJsonResponse(int opcode, const std::string& data) {
// Format: length + [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();
}
@ -418,17 +424,24 @@ void CalibreWirelessActivity::handleCommand(int opcode, const std::string& data)
break;
case OP_SET_CALIBRE_DEVICE_INFO:
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, "{}");
break;
case OP_SET_LIBRARY_INFO:
// Library metadata (name, UUID) - not needed for receiving books
sendJsonResponse(OP_OK, "{}");
break;
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, "{}");
break;
case OP_TOTAL_SPACE:
handleFreeSpace();
break;
default:
Serial.printf("[%lu] [CAL] Unknown opcode: %d\n", millis(), opcode);
sendJsonResponse(OP_OK, "{}");
break;
}
@ -453,8 +466,11 @@ void CalibreWirelessActivity::handleGetInitializationInfo(const std::string& dat
response += "\"canStreamBooks\":true,";
response += "\"canStreamMetadata\":true,";
response += "\"canUseCachedMetadata\":true,";
response += "\"ccVersionNumber\":212,"; // Match a known CC version
response += "\"coverHeight\":240,";
// ccVersionNumber: Calibre Companion protocol version. 212 matches CC 5.4.20+.
// 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 += "\"deviceName\":\"CrossPoint\",";
response += "\"extensionPathLengths\":{\"epub\":37},";
@ -472,17 +488,18 @@ void CalibreWirelessActivity::handleGetDeviceInformation() {
response += "\"device_info\":{";
response += "\"device_store_uuid\":\"" + getDeviceUuid() + "\",";
response += "\"device_name\":\"CrossPoint Reader\",";
response += "\"device_version\":\"1.0\"";
response += "\"device_version\":\"" CROSSPOINT_VERSION "\"";
response += "},";
response += "\"version\":1,";
response += "\"device_version\":\"1.0\"";
response += "\"device_version\":\"" CROSSPOINT_VERSION "\"";
response += "}";
sendJsonResponse(OP_OK, response);
}
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}");
}
@ -558,7 +575,7 @@ void CalibreWirelessActivity::handleSendBook(const std::string& data) {
}
// Sanitize and create full path
currentFilename = "/" + sanitizeFilename(filename);
currentFilename = "/" + StringUtils::sanitizeFilename(filename);
if (currentFilename.find(".epub") == std::string::npos) {
currentFilename += ".epub";
}
@ -684,20 +701,11 @@ void CalibreWirelessActivity::render() const {
// Draw progress if receiving
if (state == CalibreWirelessState::RECEIVING && currentFileSize > 0) {
const int percent = static_cast<int>((bytesReceived * 100) / currentFileSize);
// Progress bar
const int barWidth = pageWidth - 100;
const int barHeight = 20;
const int barX = 50;
constexpr int barHeight = 20;
constexpr int barX = 50;
const int barY = statusY + 20;
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());
ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, bytesReceived, currentFileSize);
}
// Draw error if present
@ -712,31 +720,6 @@ void CalibreWirelessActivity::render() const {
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 {
// Generate a consistent UUID based on MAC address
uint8_t mac[6];

View File

@ -28,11 +28,14 @@ enum class CalibreWirelessState {
* CalibreWirelessActivity implements Calibre's "wireless device" protocol.
* This allows Calibre desktop to send books directly to the device over WiFi.
*
* Protocol:
* 1. Device listens on UDP ports 54982, 48123, 39001, 44044, 59678
* 2. Calibre broadcasts discovery messages
* 3. Device responds with its TCP server address
* 4. Calibre connects via TCP and sends JSON commands
* Protocol specification sourced from Calibre's smart device driver:
* https://github.com/kovidgoyal/calibre/blob/master/src/calibre/devices/smart_device_app/driver.py
*
* Protocol overview:
* 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
*/
class CalibreWirelessActivity final : public Activity {
@ -47,9 +50,6 @@ class CalibreWirelessActivity final : public Activity {
// UDP discovery
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)
WiFiClient tcpClient;
@ -118,7 +118,6 @@ class CalibreWirelessActivity final : public Activity {
void handleNoop(const std::string& data);
// Utility
std::string sanitizeFilename(const std::string& title) const;
std::string getDeviceUuid() const;
void setState(CalibreWirelessState newState);
void setStatus(const std::string& message);

View File

@ -13,10 +13,11 @@
#include "fontIds.h"
namespace {
constexpr int pagesPerRefresh = 15;
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
constexpr unsigned long skipChapterMs = 700;
constexpr unsigned long goHomeMs = 1000;
constexpr int topPadding = 5;
constexpr int horizontalPadding = 5;
constexpr int statusBarMargin = 19;
} // namespace
@ -253,8 +254,8 @@ void EpubReaderActivity::renderScreen() {
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
&orientedMarginLeft);
orientedMarginTop += topPadding;
orientedMarginLeft += SETTINGS.getReaderSideMargin();
orientedMarginRight += SETTINGS.getReaderSideMargin();
orientedMarginLeft += horizontalPadding;
orientedMarginRight += horizontalPadding;
orientedMarginBottom += statusBarMargin;
if (!section) {
@ -378,7 +379,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = pagesPerRefresh;
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;

View File

@ -11,13 +11,13 @@
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "XtcReaderChapterSelectionActivity.h"
#include "fontIds.h"
namespace {
constexpr int pagesPerRefresh = 15;
constexpr unsigned long skipPageMs = 700;
constexpr unsigned long goHomeMs = 1000;
} // namespace
@ -266,7 +266,7 @@ void XtcReaderActivity::renderPage() {
// Display BW with conditional refresh based on pagesUntilFullRefresh
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = pagesPerRefresh;
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;
@ -346,7 +346,7 @@ void XtcReaderActivity::renderPage() {
// Display with appropriate refresh
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = pagesPerRefresh;
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;

View File

@ -98,38 +98,25 @@ void CalibreSettingsActivity::handleSelection() {
updateRequired = true;
}));
} else if (selectedIndex == 1) {
// Wireless Device - toggle and launch activity if enabling
const bool wasEnabled = SETTINGS.calibreWirelessEnabled;
SETTINGS.calibreWirelessEnabled = !wasEnabled;
SETTINGS.saveToFile();
if (!wasEnabled) {
// Just enabled - launch the wireless activity
exitActivity();
if (WiFi.status() != WL_CONNECTED) {
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](bool connected) {
exitActivity();
if (connected) {
enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
} else {
// WiFi connection failed/cancelled, turn off the setting
SETTINGS.calibreWirelessEnabled = 0;
SETTINGS.saveToFile();
// Wireless Device - launch the activity (handles WiFi connection internally)
exitActivity();
if (WiFi.status() != WL_CONNECTED) {
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](bool connected) {
exitActivity();
if (connected) {
enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}
}));
} else {
enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] {
exitActivity();
}));
} else {
updateRequired = true;
}));
}
}
}));
} else {
// Just disabled - just update the display
updateRequired = true;
enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
}
}
@ -166,18 +153,12 @@ void CalibreSettingsActivity::render() {
renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected);
// Draw status
const char* status = "";
// Draw status for URL setting
if (i == 0) {
// Calibre Web URL
status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]";
} else if (i == 1) {
// Wireless Device
status = SETTINGS.calibreWirelessEnabled ? "ON" : "OFF";
const char* status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]";
const auto width = renderer.getTextWidth(UI_10_FONT_ID, status);
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected);
}
const auto width = renderer.getTextWidth(UI_10_FONT_ID, status);
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected);
}
// Draw button hints

View File

@ -12,7 +12,7 @@
// Define the static settings list
namespace {
constexpr int settingsCount = 14;
constexpr int settingsCount = 15;
const SettingInfo settingsList[settingsCount] = {
// Should match with SLEEP_SCREEN_MODE
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
@ -41,7 +41,14 @@ const SettingInfo settingsList[settingsCount] = {
SettingType::ENUM,
&CrossPointSettings::paragraphAlignment,
{"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, {}},
{"Check for updates", SettingType::ACTION, nullptr, {}},
};

View File

@ -329,9 +329,13 @@ void KeyboardEntryActivity::render() const {
}
}
// Draw help text at absolute bottom of screen (consistent with other screens)
const auto pageHeight = renderer.getScreenHeight();
renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK");
// Draw help text
const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right");
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();
}

View File

@ -5,7 +5,6 @@
#include <InputManager.h>
#include <SDCardManager.h>
#include <SPI.h>
#include <WiFi.h>
#include <builtinFonts/all.h>
#include <cstring>
@ -23,7 +22,6 @@
#include "activities/reader/ReaderActivity.h"
#include "activities/settings/SettingsActivity.h"
#include "activities/util/FullScreenMessageActivity.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "fontIds.h"
#define SPI_FQ 40000000
@ -132,8 +130,6 @@ EpdFont ui12RegularFont(&ubuntu_12_regular);
EpdFont ui12BoldFont(&ubuntu_12_bold);
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
unsigned long t1 = 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()
const auto start = millis();
bool abort = false;
// It takes us some time to wake up from deep sleep, so we need to subtract that from the duration
constexpr uint16_t calibration = 29;
// Subtract the current time, because inputManager only starts counting the HeldTime from the first update()
// 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 =
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
@ -228,43 +226,9 @@ void onGoToSettings() {
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() {
exitActivity();
// Check WiFi connectivity first
if (WiFi.status() != WL_CONNECTED) {
enterNewActivity(new WifiSelectionActivity(renderer, mappedInputManager, [](bool connected) {
exitActivity();
if (connected) {
launchBrowserWithUrlCheck();
} else {
onGoHome();
}
}));
} else {
launchBrowserWithUrlCheck();
}
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome));
}
void onGoHome() {
@ -368,8 +332,9 @@ void loop() {
lastActivityTime = millis(); // Reset inactivity timer
}
if (millis() - lastActivityTime >= AUTO_SLEEP_TIMEOUT_MS) {
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), AUTO_SLEEP_TIMEOUT_MS);
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
if (millis() - lastActivityTime >= sleepTimeoutMs) {
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), sleepTimeoutMs);
enterDeepSleep();
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
return;

View File

@ -3,7 +3,6 @@
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <Update.h>
#include <WiFiClientSecure.h>
namespace {
constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest";
@ -69,44 +68,41 @@ OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() {
return OK;
}
bool OtaUpdater::isUpdateNewer() {
bool OtaUpdater::isUpdateNewer() const {
if (!updateAvailable || latestVersion.empty() || latestVersion == CROSSPOINT_VERSION) {
return false;
}
int currentMajor, currentMinor, currentPatch;
int latestMajor, latestMinor, latestPatch;
const auto currentVersion = CROSSPOINT_VERSION;
// semantic version check (only match on 3 segments)
const auto updateMajor = stoi(latestVersion.substr(0, latestVersion.find('.')));
const auto updateMinor = stoi(
latestVersion.substr(latestVersion.find('.') + 1, latestVersion.find_last_of('.') - latestVersion.find('.') - 1));
const auto updatePatch = stoi(latestVersion.substr(latestVersion.find_last_of('.') + 1));
sscanf(latestVersion.c_str(), "%d.%d.%d", &latestMajor, &latestMinor, &latestPatch);
sscanf(currentVersion, "%d.%d.%d", &currentMajor, &currentMinor, &currentPatch);
std::string currentVersion = CROSSPOINT_VERSION;
const auto currentMajor = stoi(currentVersion.substr(0, currentVersion.find('.')));
const auto currentMinor = stoi(currentVersion.substr(
currentVersion.find('.') + 1, currentVersion.find_last_of('.') - currentVersion.find('.') - 1));
const auto currentPatch = stoi(currentVersion.substr(currentVersion.find_last_of('.') + 1));
/*
* Compare major versions.
* If they differ, return true if latest major version greater than current major version
* otherwise return false.
*/
if (latestMajor != currentMajor) return latestMajor > currentMajor;
if (updateMajor > currentMajor) {
return true;
}
if (updateMajor < currentMajor) {
return false;
}
/*
* Compare minor versions.
* If they differ, return true if latest minor version greater than current minor version
* otherwise return false.
*/
if (latestMinor != currentMinor) return latestMinor > currentMinor;
if (updateMinor > currentMinor) {
return true;
}
if (updateMinor < currentMinor) {
return false;
}
if (updatePatch > currentPatch) {
return true;
}
return false;
/*
* Check patch versions.
*/
return latestPatch > currentPatch;
}
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) {
if (!isUpdateNewer()) {

View File

@ -23,8 +23,8 @@ class OtaUpdater {
size_t totalSize = 0;
OtaUpdater() = default;
bool isUpdateNewer();
const std::string& getLatestVersion();
bool isUpdateNewer() const;
const std::string& getLatestVersion() const;
OtaUpdaterError checkForUpdate();
OtaUpdaterError installUpdate(const std::function<void(size_t, size_t)>& onProgress);
};

36
src/util/StringUtils.cpp Normal file
View 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
View 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
View 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
View 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