Calibre Web Epub Downloading + Calibre Wireless Device Syncing (#219)
## Summary Adds support for browsing and downloading books from a Calibre-web server via OPDS. How it works 1. Configure server URL in Settings → Calibre Web URL (e.g., https://myserver.com:port I use Cloudflare tunnel to make my server accessible anywhere fwiw) 2. "Calibre Library" will now show on the the home screen 3. Browse the catalog - navigate through categories like "By Newest", "By Author", "By Series", etc. 4. Download books - select a book and press Confirm to download the EPUB to your device Navigation - Up/Down - Move through entries - Confirm - Open folder or download book - Back - Go to parent catalog, or exit to home if at root - Navigation entries show with > prefix, books show title and author - Button hints update dynamically ("Open" for folders, "Download" for books) Technical details - Fetches OPDS catalog from {server_url}/opds - Parses both navigation feeds (catalog links) and acquisition feeds (downloadable books) - Maintains navigation history stack for back navigation - Handles absolute paths in OPDS links correctly (e.g., /books/opds/navcatalog/...) - Downloads EPUBs directly to the SD card root Note The server URL should be typed to include https:// if the server requires it - HTTP→HTTPS redirects may cause SSL errors on ESP32. ## Additional Context * I also changed the home titles to use uppercase for each word and added a setting to change the size of the side margins --------- Co-authored-by: Dave Allie <dave@daveallie.com>
This commit is contained in:
parent
afe9672156
commit
b792b792bf
219
lib/OpdsParser/OpdsParser.cpp
Normal file
219
lib/OpdsParser/OpdsParser.cpp
Normal file
@ -0,0 +1,219 @@
|
||||
#include "OpdsParser.h"
|
||||
|
||||
#include <HardwareSerial.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
OpdsParser::~OpdsParser() {
|
||||
if (parser) {
|
||||
XML_StopParser(parser, XML_FALSE);
|
||||
XML_SetElementHandler(parser, nullptr, nullptr);
|
||||
XML_SetCharacterDataHandler(parser, nullptr);
|
||||
XML_ParserFree(parser);
|
||||
parser = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool OpdsParser::parse(const char* xmlData, const size_t length) {
|
||||
clear();
|
||||
|
||||
parser = XML_ParserCreate(nullptr);
|
||||
if (!parser) {
|
||||
Serial.printf("[%lu] [OPDS] Couldn't allocate memory for parser\n", millis());
|
||||
return false;
|
||||
}
|
||||
|
||||
XML_SetUserData(parser, this);
|
||||
XML_SetElementHandler(parser, startElement, endElement);
|
||||
XML_SetCharacterDataHandler(parser, characterData);
|
||||
|
||||
// Parse in chunks to avoid large buffer allocations
|
||||
const char* currentPos = xmlData;
|
||||
size_t remaining = length;
|
||||
constexpr size_t chunkSize = 1024;
|
||||
|
||||
while (remaining > 0) {
|
||||
void* const buf = XML_GetBuffer(parser, chunkSize);
|
||||
if (!buf) {
|
||||
Serial.printf("[%lu] [OPDS] Couldn't allocate memory for buffer\n", millis());
|
||||
XML_ParserFree(parser);
|
||||
parser = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
const size_t toRead = remaining < chunkSize ? remaining : chunkSize;
|
||||
memcpy(buf, currentPos, toRead);
|
||||
|
||||
const bool isFinal = (remaining == toRead);
|
||||
if (XML_ParseBuffer(parser, static_cast<int>(toRead), isFinal) == XML_STATUS_ERROR) {
|
||||
Serial.printf("[%lu] [OPDS] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
|
||||
XML_ErrorString(XML_GetErrorCode(parser)));
|
||||
XML_ParserFree(parser);
|
||||
parser = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
currentPos += toRead;
|
||||
remaining -= toRead;
|
||||
}
|
||||
|
||||
// Clean up parser
|
||||
XML_ParserFree(parser);
|
||||
parser = nullptr;
|
||||
|
||||
Serial.printf("[%lu] [OPDS] Parsed %zu entries\n", millis(), entries.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
void OpdsParser::clear() {
|
||||
entries.clear();
|
||||
currentEntry = OpdsEntry{};
|
||||
currentText.clear();
|
||||
inEntry = false;
|
||||
inTitle = false;
|
||||
inAuthor = false;
|
||||
inAuthorName = false;
|
||||
inId = false;
|
||||
}
|
||||
|
||||
std::vector<OpdsEntry> OpdsParser::getBooks() const {
|
||||
std::vector<OpdsEntry> books;
|
||||
for (const auto& entry : entries) {
|
||||
if (entry.type == OpdsEntryType::BOOK) {
|
||||
books.push_back(entry);
|
||||
}
|
||||
}
|
||||
return books;
|
||||
}
|
||||
|
||||
const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) {
|
||||
for (int i = 0; atts[i]; i += 2) {
|
||||
if (strcmp(atts[i], name) == 0) {
|
||||
return atts[i + 1];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
|
||||
auto* self = static_cast<OpdsParser*>(userData);
|
||||
|
||||
// Check for entry element (with or without namespace prefix)
|
||||
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
|
||||
self->inEntry = true;
|
||||
self->currentEntry = OpdsEntry{};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self->inEntry) return;
|
||||
|
||||
// Check for title element
|
||||
if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) {
|
||||
self->inTitle = true;
|
||||
self->currentText.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for author element
|
||||
if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) {
|
||||
self->inAuthor = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for author name element
|
||||
if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) {
|
||||
self->inAuthorName = true;
|
||||
self->currentText.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for id element
|
||||
if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) {
|
||||
self->inId = true;
|
||||
self->currentText.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for link element
|
||||
if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) {
|
||||
const char* rel = findAttribute(atts, "rel");
|
||||
const char* type = findAttribute(atts, "type");
|
||||
const char* href = findAttribute(atts, "href");
|
||||
|
||||
if (href) {
|
||||
// Check for acquisition link with epub type (this is a downloadable book)
|
||||
if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr &&
|
||||
strcmp(type, "application/epub+zip") == 0) {
|
||||
self->currentEntry.type = OpdsEntryType::BOOK;
|
||||
self->currentEntry.href = href;
|
||||
}
|
||||
// Check for navigation link (subsection or no rel specified with atom+xml type)
|
||||
else if (type && strstr(type, "application/atom+xml") != nullptr) {
|
||||
// Only set navigation link if we don't already have an epub link
|
||||
if (self->currentEntry.type != OpdsEntryType::BOOK) {
|
||||
self->currentEntry.type = OpdsEntryType::NAVIGATION;
|
||||
self->currentEntry.href = href;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) {
|
||||
auto* self = static_cast<OpdsParser*>(userData);
|
||||
|
||||
// Check for entry end
|
||||
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
|
||||
// Only add entry if it has required fields (title and href)
|
||||
if (!self->currentEntry.title.empty() && !self->currentEntry.href.empty()) {
|
||||
self->entries.push_back(self->currentEntry);
|
||||
}
|
||||
self->inEntry = false;
|
||||
self->currentEntry = OpdsEntry{};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self->inEntry) return;
|
||||
|
||||
// Check for title end
|
||||
if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) {
|
||||
if (self->inTitle) {
|
||||
self->currentEntry.title = self->currentText;
|
||||
}
|
||||
self->inTitle = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for author end
|
||||
if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) {
|
||||
self->inAuthor = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for author name end
|
||||
if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) {
|
||||
if (self->inAuthorName) {
|
||||
self->currentEntry.author = self->currentText;
|
||||
}
|
||||
self->inAuthorName = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for id end
|
||||
if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) {
|
||||
if (self->inId) {
|
||||
self->currentEntry.id = self->currentText;
|
||||
}
|
||||
self->inId = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void XMLCALL OpdsParser::characterData(void* userData, const XML_Char* s, const int len) {
|
||||
auto* self = static_cast<OpdsParser*>(userData);
|
||||
|
||||
// Only accumulate text when in a text element
|
||||
if (self->inTitle || self->inAuthorName || self->inId) {
|
||||
self->currentText.append(s, len);
|
||||
}
|
||||
}
|
||||
99
lib/OpdsParser/OpdsParser.h
Normal file
99
lib/OpdsParser/OpdsParser.h
Normal file
@ -0,0 +1,99 @@
|
||||
#pragma once
|
||||
#include <expat.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* Type of OPDS entry.
|
||||
*/
|
||||
enum class OpdsEntryType {
|
||||
NAVIGATION, // Link to another catalog
|
||||
BOOK // Downloadable book
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents an entry from an OPDS feed (either a navigation link or a book).
|
||||
*/
|
||||
struct OpdsEntry {
|
||||
OpdsEntryType type = OpdsEntryType::NAVIGATION;
|
||||
std::string title;
|
||||
std::string author; // Only for books
|
||||
std::string href; // Navigation URL or epub download URL
|
||||
std::string id;
|
||||
};
|
||||
|
||||
// Legacy alias for backward compatibility
|
||||
using OpdsBook = OpdsEntry;
|
||||
|
||||
/**
|
||||
* Parser for OPDS (Open Publication Distribution System) Atom feeds.
|
||||
* Uses the Expat XML parser to parse OPDS catalog entries.
|
||||
*
|
||||
* Usage:
|
||||
* OpdsParser parser;
|
||||
* if (parser.parse(xmlData, xmlLength)) {
|
||||
* for (const auto& entry : parser.getEntries()) {
|
||||
* if (entry.type == OpdsEntryType::BOOK) {
|
||||
* // Downloadable book
|
||||
* } else {
|
||||
* // Navigation link to another catalog
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
class OpdsParser {
|
||||
public:
|
||||
OpdsParser() = default;
|
||||
~OpdsParser();
|
||||
|
||||
// Disable copy
|
||||
OpdsParser(const OpdsParser&) = delete;
|
||||
OpdsParser& operator=(const OpdsParser&) = delete;
|
||||
|
||||
/**
|
||||
* Parse an OPDS XML feed.
|
||||
* @param xmlData Pointer to the XML data
|
||||
* @param length Length of the XML data
|
||||
* @return true if parsing succeeded, false on error
|
||||
*/
|
||||
bool parse(const char* xmlData, size_t length);
|
||||
|
||||
/**
|
||||
* Get the parsed entries (both navigation and book entries).
|
||||
* @return Vector of OpdsEntry entries
|
||||
*/
|
||||
const std::vector<OpdsEntry>& getEntries() const { return entries; }
|
||||
|
||||
/**
|
||||
* Get only book entries (legacy compatibility).
|
||||
* @return Vector of book entries
|
||||
*/
|
||||
std::vector<OpdsEntry> getBooks() const;
|
||||
|
||||
/**
|
||||
* Clear all parsed entries.
|
||||
*/
|
||||
void clear();
|
||||
|
||||
private:
|
||||
// Expat callbacks
|
||||
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
||||
static void XMLCALL endElement(void* userData, const XML_Char* name);
|
||||
static void XMLCALL characterData(void* userData, const XML_Char* s, int len);
|
||||
|
||||
// Helper to find attribute value
|
||||
static const char* findAttribute(const XML_Char** atts, const char* name);
|
||||
|
||||
XML_Parser parser = nullptr;
|
||||
std::vector<OpdsEntry> entries;
|
||||
OpdsEntry currentEntry;
|
||||
std::string currentText;
|
||||
|
||||
// Parser state
|
||||
bool inEntry = false;
|
||||
bool inTitle = false;
|
||||
bool inAuthor = false;
|
||||
bool inAuthorName = false;
|
||||
bool inId = false;
|
||||
};
|
||||
@ -4,6 +4,8 @@
|
||||
#include <SDCardManager.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "fontIds.h"
|
||||
|
||||
// Initialize the static instance
|
||||
@ -12,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance;
|
||||
namespace {
|
||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
||||
// Increment this when adding new persisted settings fields
|
||||
constexpr uint8_t SETTINGS_COUNT = 15;
|
||||
constexpr uint8_t SETTINGS_COUNT = 16;
|
||||
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
||||
} // namespace
|
||||
|
||||
@ -40,6 +42,7 @@ bool CrossPointSettings::saveToFile() const {
|
||||
serialization::writePod(outputFile, paragraphAlignment);
|
||||
serialization::writePod(outputFile, sleepTimeout);
|
||||
serialization::writePod(outputFile, refreshFrequency);
|
||||
serialization::writeString(outputFile, std::string(opdsServerUrl));
|
||||
serialization::writePod(outputFile, screenMargin);
|
||||
serialization::writePod(outputFile, sleepScreenCoverMode);
|
||||
outputFile.close();
|
||||
@ -94,10 +97,16 @@ bool CrossPointSettings::loadFromFile() {
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, refreshFrequency);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
{
|
||||
std::string urlStr;
|
||||
serialization::readString(inputFile, urlStr);
|
||||
strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1);
|
||||
opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0';
|
||||
}
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, screenMargin);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
serialization::readPod(inputFile, sleepScreenCoverMode);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
} while (false);
|
||||
|
||||
inputFile.close();
|
||||
|
||||
@ -77,6 +77,8 @@ class CrossPointSettings {
|
||||
uint8_t sleepTimeout = SLEEP_10_MIN;
|
||||
// E-ink refresh frequency (default 15 pages)
|
||||
uint8_t refreshFrequency = REFRESH_15;
|
||||
// OPDS browser settings
|
||||
char opdsServerUrl[128] = "";
|
||||
|
||||
// Reader screen margin settings
|
||||
uint8_t screenMargin = 5;
|
||||
@ -95,7 +97,6 @@ class CrossPointSettings {
|
||||
float getReaderLineCompression() const;
|
||||
unsigned long getSleepTimeoutMs() const;
|
||||
int getRefreshFrequency() const;
|
||||
int getReaderScreenMargin() const;
|
||||
};
|
||||
|
||||
// Helper macro to access settings
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "Battery.h"
|
||||
@ -39,3 +40,26 @@ void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left,
|
||||
|
||||
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
|
||||
|
||||
#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);
|
||||
};
|
||||
|
||||
398
src/activities/browser/OpdsBookBrowserActivity.cpp
Normal file
398
src/activities/browser/OpdsBookBrowserActivity.cpp
Normal file
@ -0,0 +1,398 @@
|
||||
#include "OpdsBookBrowserActivity.h"
|
||||
|
||||
#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
|
||||
} // namespace
|
||||
|
||||
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
|
||||
auto* self = static_cast<OpdsBookBrowserActivity*>(param);
|
||||
self->displayTaskLoop();
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
state = BrowserState::CHECK_WIFI;
|
||||
entries.clear();
|
||||
navigationHistory.clear();
|
||||
currentPath = OPDS_ROOT_PATH;
|
||||
selectorIndex = 0;
|
||||
errorMessage.clear();
|
||||
statusMessage = "Checking WiFi...";
|
||||
updateRequired = true;
|
||||
|
||||
xTaskCreate(&OpdsBookBrowserActivity::taskTrampoline, "OpdsBookBrowserTask",
|
||||
4096, // Stack size (larger for HTTP operations)
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&displayTaskHandle // Task handle
|
||||
);
|
||||
|
||||
// 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);
|
||||
displayTaskHandle = nullptr;
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
entries.clear();
|
||||
navigationHistory.clear();
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::loop() {
|
||||
// Handle error state - Confirm retries, Back goes back or home
|
||||
if (state == BrowserState::ERROR) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
state = BrowserState::LOADING;
|
||||
statusMessage = "Loading...";
|
||||
updateRequired = true;
|
||||
fetchFeed(currentPath);
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
navigateBack();
|
||||
}
|
||||
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)) {
|
||||
navigateBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle downloading state - no input allowed
|
||||
if (state == BrowserState::DOWNLOADING) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle browsing state
|
||||
if (state == BrowserState::BROWSING) {
|
||||
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (!entries.empty()) {
|
||||
const auto& entry = entries[selectorIndex];
|
||||
if (entry.type == OpdsEntryType::BOOK) {
|
||||
downloadBook(entry);
|
||||
} else {
|
||||
navigateToEntry(entry);
|
||||
}
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
navigateBack();
|
||||
} else if (prevReleased && !entries.empty()) {
|
||||
if (skipPage) {
|
||||
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + entries.size()) % entries.size();
|
||||
} else {
|
||||
selectorIndex = (selectorIndex + entries.size() - 1) % entries.size();
|
||||
}
|
||||
updateRequired = true;
|
||||
} else if (nextReleased && !entries.empty()) {
|
||||
if (skipPage) {
|
||||
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % entries.size();
|
||||
} else {
|
||||
selectorIndex = (selectorIndex + 1) % entries.size();
|
||||
}
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::displayTaskLoop() {
|
||||
while (true) {
|
||||
if (updateRequired) {
|
||||
updateRequired = false;
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
render();
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::render() const {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
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", "", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == BrowserState::ERROR) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Error:");
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str());
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Retry", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == BrowserState::DOWNLOADING) {
|
||||
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 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;
|
||||
}
|
||||
|
||||
// Browsing state
|
||||
// Show appropriate button hint based on selected entry type
|
||||
const char* confirmLabel = "Open";
|
||||
if (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) {
|
||||
confirmLabel = "Download";
|
||||
}
|
||||
const auto labels = mappedInput.mapLabels("« Back", confirmLabel, "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
if (entries.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No entries found");
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
|
||||
renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30);
|
||||
|
||||
for (size_t i = pageStartIndex; i < entries.size() && i < static_cast<size_t>(pageStartIndex + PAGE_ITEMS); i++) {
|
||||
const auto& entry = entries[i];
|
||||
|
||||
// Format display text with type indicator
|
||||
std::string displayText;
|
||||
if (entry.type == OpdsEntryType::NAVIGATION) {
|
||||
displayText = "> " + entry.title; // Folder/navigation indicator
|
||||
} else {
|
||||
// Book: "Title - Author" or just "Title"
|
||||
displayText = entry.title;
|
||||
if (!entry.author.empty()) {
|
||||
displayText += " - " + entry.author;
|
||||
}
|
||||
}
|
||||
|
||||
auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40);
|
||||
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(),
|
||||
i != static_cast<size_t>(selectorIndex));
|
||||
}
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
|
||||
const char* serverUrl = SETTINGS.opdsServerUrl;
|
||||
if (strlen(serverUrl) == 0) {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = "No server URL configured";
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
std::string url = UrlUtils::buildUrl(serverUrl, path);
|
||||
Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str());
|
||||
|
||||
std::string content;
|
||||
if (!HttpDownloader::fetchUrl(url, content)) {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = "Failed to fetch feed";
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
OpdsParser parser;
|
||||
if (!parser.parse(content.c_str(), content.size())) {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = "Failed to parse feed";
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
entries = parser.getEntries();
|
||||
selectorIndex = 0;
|
||||
|
||||
if (entries.empty()) {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = "No entries found";
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
state = BrowserState::BROWSING;
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) {
|
||||
// Push current path to history before navigating
|
||||
navigationHistory.push_back(currentPath);
|
||||
currentPath = entry.href;
|
||||
|
||||
state = BrowserState::LOADING;
|
||||
statusMessage = "Loading...";
|
||||
entries.clear();
|
||||
selectorIndex = 0;
|
||||
updateRequired = true;
|
||||
|
||||
fetchFeed(currentPath);
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::navigateBack() {
|
||||
if (navigationHistory.empty()) {
|
||||
// At root, go home
|
||||
onGoHome();
|
||||
} else {
|
||||
// Go back to previous catalog
|
||||
currentPath = navigationHistory.back();
|
||||
navigationHistory.pop_back();
|
||||
|
||||
state = BrowserState::LOADING;
|
||||
statusMessage = "Loading...";
|
||||
entries.clear();
|
||||
selectorIndex = 0;
|
||||
updateRequired = true;
|
||||
|
||||
fetchFeed(currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
||||
state = BrowserState::DOWNLOADING;
|
||||
statusMessage = book.title;
|
||||
downloadProgress = 0;
|
||||
downloadTotal = 0;
|
||||
updateRequired = true;
|
||||
|
||||
// Build full download URL
|
||||
std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href);
|
||||
|
||||
// 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());
|
||||
|
||||
const auto result =
|
||||
HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) {
|
||||
downloadProgress = downloaded;
|
||||
downloadTotal = total;
|
||||
updateRequired = true;
|
||||
});
|
||||
|
||||
if (result == HttpDownloader::OK) {
|
||||
Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str());
|
||||
state = BrowserState::BROWSING;
|
||||
updateRequired = true;
|
||||
} else {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = "Download failed";
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::checkAndConnectWifi() {
|
||||
// Already connected?
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
state = BrowserState::LOADING;
|
||||
statusMessage = "Loading...";
|
||||
updateRequired = true;
|
||||
fetchFeed(currentPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to connect using saved credentials
|
||||
statusMessage = "Connecting to WiFi...";
|
||||
updateRequired = true;
|
||||
|
||||
WIFI_STORE.loadFromFile();
|
||||
const auto& credentials = WIFI_STORE.getCredentials();
|
||||
if (credentials.empty()) {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = "No WiFi credentials saved";
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
61
src/activities/browser/OpdsBookBrowserActivity.h
Normal file
61
src/activities/browser/OpdsBookBrowserActivity.h
Normal file
@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
#include <OpdsParser.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
|
||||
/**
|
||||
* Activity for browsing and downloading books from an OPDS server.
|
||||
* Supports navigation through catalog hierarchy and downloading EPUBs.
|
||||
*/
|
||||
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
|
||||
ERROR // Error state with message
|
||||
};
|
||||
|
||||
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onGoHome)
|
||||
: Activity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
|
||||
private:
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
bool updateRequired = false;
|
||||
|
||||
BrowserState state = BrowserState::LOADING;
|
||||
std::vector<OpdsEntry> entries;
|
||||
std::vector<std::string> navigationHistory; // Stack of previous feed paths for back navigation
|
||||
std::string currentPath; // Current feed path being displayed
|
||||
int selectorIndex = 0;
|
||||
std::string errorMessage;
|
||||
std::string statusMessage;
|
||||
size_t downloadProgress = 0;
|
||||
size_t downloadTotal = 0;
|
||||
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
static void taskTrampoline(void* param);
|
||||
[[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);
|
||||
};
|
||||
@ -4,6 +4,10 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <SDCardManager.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "ScreenComponents.h"
|
||||
@ -14,7 +18,12 @@ void HomeActivity::taskTrampoline(void* param) {
|
||||
self->displayTaskLoop();
|
||||
}
|
||||
|
||||
int HomeActivity::getMenuItemCount() const { return hasContinueReading ? 4 : 3; }
|
||||
int HomeActivity::getMenuItemCount() const {
|
||||
int count = 3; // Browse files, File transfer, Settings
|
||||
if (hasContinueReading) count++;
|
||||
if (hasOpdsUrl) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
void HomeActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
@ -24,6 +33,9 @@ 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 OPDS browser URL is configured
|
||||
hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0;
|
||||
|
||||
if (hasContinueReading) {
|
||||
// Extract filename from path for display
|
||||
lastBookTitle = APP_STATE.openEpubPath;
|
||||
@ -86,27 +98,25 @@ void HomeActivity::loop() {
|
||||
const int menuCount = getMenuItemCount();
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (hasContinueReading) {
|
||||
// Menu: Continue Reading, Browse, File transfer, Settings
|
||||
if (selectorIndex == 0) {
|
||||
// Calculate dynamic indices based on which options are available
|
||||
int idx = 0;
|
||||
const int continueIdx = hasContinueReading ? idx++ : -1;
|
||||
const int browseFilesIdx = idx++;
|
||||
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
|
||||
const int fileTransferIdx = idx++;
|
||||
const int settingsIdx = idx;
|
||||
|
||||
if (selectorIndex == continueIdx) {
|
||||
onContinueReading();
|
||||
} else if (selectorIndex == 1) {
|
||||
} else if (selectorIndex == browseFilesIdx) {
|
||||
onReaderOpen();
|
||||
} else if (selectorIndex == 2) {
|
||||
} else if (selectorIndex == opdsLibraryIdx) {
|
||||
onOpdsBrowserOpen();
|
||||
} else if (selectorIndex == fileTransferIdx) {
|
||||
onFileTransferOpen();
|
||||
} else if (selectorIndex == 3) {
|
||||
} else if (selectorIndex == settingsIdx) {
|
||||
onSettingsOpen();
|
||||
}
|
||||
} else {
|
||||
// Menu: Browse, File transfer, Settings
|
||||
if (selectorIndex == 0) {
|
||||
onReaderOpen();
|
||||
} else if (selectorIndex == 1) {
|
||||
onFileTransferOpen();
|
||||
} else if (selectorIndex == 2) {
|
||||
onSettingsOpen();
|
||||
}
|
||||
}
|
||||
} else if (prevPressed) {
|
||||
selectorIndex = (selectorIndex + menuCount - 1) % menuCount;
|
||||
updateRequired = true;
|
||||
@ -277,24 +287,31 @@ void HomeActivity::render() const {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below");
|
||||
}
|
||||
|
||||
// --- Bottom menu tiles (indices 1-3) ---
|
||||
const int menuTileWidth = pageWidth - 2 * margin;
|
||||
constexpr int menuTileHeight = 50;
|
||||
constexpr int menuSpacing = 10;
|
||||
constexpr int totalMenuHeight = 3 * menuTileHeight + 2 * menuSpacing;
|
||||
// --- Bottom menu tiles ---
|
||||
// Build menu items dynamically
|
||||
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");
|
||||
}
|
||||
|
||||
int menuStartY = bookY + bookHeight + 20;
|
||||
const int menuTileWidth = pageWidth - 2 * margin;
|
||||
constexpr int menuTileHeight = 45;
|
||||
constexpr int menuSpacing = 8;
|
||||
const int totalMenuHeight =
|
||||
static_cast<int>(menuItems.size()) * menuTileHeight + (static_cast<int>(menuItems.size()) - 1) * menuSpacing;
|
||||
|
||||
int menuStartY = bookY + bookHeight + 15;
|
||||
// Ensure we don't collide with the bottom button legend
|
||||
const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin;
|
||||
if (menuStartY > maxMenuStartY) {
|
||||
menuStartY = maxMenuStartY;
|
||||
}
|
||||
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
constexpr const char* items[3] = {"Browse files", "File transfer", "Settings"};
|
||||
const int overallIndex = i + (getMenuItemCount() - 3);
|
||||
for (size_t i = 0; i < menuItems.size(); ++i) {
|
||||
const int overallIndex = static_cast<int>(i) + (hasContinueReading ? 1 : 0);
|
||||
constexpr int tileX = margin;
|
||||
const int tileY = menuStartY + i * (menuTileHeight + menuSpacing);
|
||||
const int tileY = menuStartY + static_cast<int>(i) * (menuTileHeight + menuSpacing);
|
||||
const bool selected = selectorIndex == overallIndex;
|
||||
|
||||
if (selected) {
|
||||
@ -303,7 +320,7 @@ void HomeActivity::render() const {
|
||||
renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight);
|
||||
}
|
||||
|
||||
const char* label = items[i];
|
||||
const char* label = menuItems[i];
|
||||
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label);
|
||||
const int textX = tileX + (menuTileWidth - textWidth) / 2;
|
||||
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
|
||||
@ -13,12 +13,14 @@ class HomeActivity final : public Activity {
|
||||
int selectorIndex = 0;
|
||||
bool updateRequired = false;
|
||||
bool hasContinueReading = 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()> onOpdsBrowserOpen;
|
||||
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
@ -28,12 +30,14 @@ class HomeActivity final : public Activity {
|
||||
public:
|
||||
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()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen,
|
||||
const std::function<void()>& onOpdsBrowserOpen)
|
||||
: Activity("Home", renderer, mappedInput),
|
||||
onContinueReading(onContinueReading),
|
||||
onReaderOpen(onReaderOpen),
|
||||
onSettingsOpen(onSettingsOpen),
|
||||
onFileTransferOpen(onFileTransferOpen) {}
|
||||
onFileTransferOpen(onFileTransferOpen),
|
||||
onOpdsBrowserOpen(onOpdsBrowserOpen) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
|
||||
756
src/activities/network/CalibreWirelessActivity.cpp
Normal file
756
src/activities/network/CalibreWirelessActivity.cpp
Normal file
@ -0,0 +1,756 @@
|
||||
#include "CalibreWirelessActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "ScreenComponents.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
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);
|
||||
self->displayTaskLoop();
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::networkTaskTrampoline(void* param) {
|
||||
auto* self = static_cast<CalibreWirelessActivity*>(param);
|
||||
self->networkTaskLoop();
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
stateMutex = xSemaphoreCreateMutex();
|
||||
|
||||
state = WirelessState::DISCOVERING;
|
||||
statusMessage = "Discovering Calibre...";
|
||||
errorMessage.clear();
|
||||
calibreHostname.clear();
|
||||
calibreHost.clear();
|
||||
calibrePort = 0;
|
||||
calibreAltPort = 0;
|
||||
currentFilename.clear();
|
||||
currentFileSize = 0;
|
||||
bytesReceived = 0;
|
||||
inBinaryMode = false;
|
||||
recvBuffer.clear();
|
||||
|
||||
updateRequired = true;
|
||||
|
||||
// Start UDP listener for Calibre responses
|
||||
udp.begin(LOCAL_UDP_PORT);
|
||||
|
||||
// Create display task
|
||||
xTaskCreate(&CalibreWirelessActivity::displayTaskTrampoline, "CalDisplayTask", 2048, this, 1, &displayTaskHandle);
|
||||
|
||||
// Create network task with larger stack for JSON parsing
|
||||
xTaskCreate(&CalibreWirelessActivity::networkTaskTrampoline, "CalNetworkTask", 12288, this, 2, &networkTaskHandle);
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
// Turn off WiFi when exiting
|
||||
WiFi.mode(WIFI_OFF);
|
||||
|
||||
// Stop UDP listening
|
||||
udp.stop();
|
||||
|
||||
// Close TCP client if connected
|
||||
if (tcpClient.connected()) {
|
||||
tcpClient.stop();
|
||||
}
|
||||
|
||||
// Close any open file
|
||||
if (currentFile) {
|
||||
currentFile.close();
|
||||
}
|
||||
|
||||
// Acquire stateMutex before deleting network task to avoid race condition
|
||||
xSemaphoreTake(stateMutex, portMAX_DELAY);
|
||||
if (networkTaskHandle) {
|
||||
vTaskDelete(networkTaskHandle);
|
||||
networkTaskHandle = nullptr;
|
||||
}
|
||||
xSemaphoreGive(stateMutex);
|
||||
|
||||
// Acquire renderingMutex before deleting display task
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
displayTaskHandle = nullptr;
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
|
||||
vSemaphoreDelete(stateMutex);
|
||||
stateMutex = nullptr;
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::displayTaskLoop() {
|
||||
while (true) {
|
||||
if (updateRequired) {
|
||||
updateRequired = false;
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
render();
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
vTaskDelay(50 / portTICK_PERIOD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::networkTaskLoop() {
|
||||
while (true) {
|
||||
xSemaphoreTake(stateMutex, portMAX_DELAY);
|
||||
const auto currentState = state;
|
||||
xSemaphoreGive(stateMutex);
|
||||
|
||||
switch (currentState) {
|
||||
case WirelessState::DISCOVERING:
|
||||
listenForDiscovery();
|
||||
break;
|
||||
|
||||
case WirelessState::CONNECTING:
|
||||
case WirelessState::WAITING:
|
||||
case WirelessState::RECEIVING:
|
||||
handleTcpClient();
|
||||
break;
|
||||
|
||||
case WirelessState::COMPLETE:
|
||||
case WirelessState::DISCONNECTED:
|
||||
case WirelessState::ERROR:
|
||||
// Just wait, user will exit
|
||||
vTaskDelay(100 / portTICK_PERIOD_MS);
|
||||
break;
|
||||
}
|
||||
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::listenForDiscovery() {
|
||||
// Broadcast "hello" on all UDP discovery ports to find Calibre
|
||||
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();
|
||||
}
|
||||
|
||||
// Wait for Calibre's response
|
||||
vTaskDelay(500 / portTICK_PERIOD_MS);
|
||||
|
||||
// Check for response
|
||||
const int packetSize = udp.parsePacket();
|
||||
if (packetSize > 0) {
|
||||
char buffer[256];
|
||||
const int len = udp.read(buffer, sizeof(buffer) - 1);
|
||||
if (len > 0) {
|
||||
buffer[len] = '\0';
|
||||
|
||||
// Parse Calibre's response format:
|
||||
// "calibre wireless device client (on hostname);port,content_server_port"
|
||||
// or just the hostname and port info
|
||||
std::string response(buffer);
|
||||
|
||||
// Try to extract host and port
|
||||
// Format: "calibre wireless device client (on HOSTNAME);PORT,..."
|
||||
size_t onPos = response.find("(on ");
|
||||
size_t closePos = response.find(')');
|
||||
size_t semiPos = response.find(';');
|
||||
size_t commaPos = response.find(',', semiPos);
|
||||
|
||||
if (semiPos != std::string::npos) {
|
||||
// Get ports after semicolon (format: "port1,port2")
|
||||
std::string portStr;
|
||||
if (commaPos != std::string::npos && commaPos > semiPos) {
|
||||
portStr = response.substr(semiPos + 1, commaPos - semiPos - 1);
|
||||
// Get alternative port after comma
|
||||
std::string altPortStr = response.substr(commaPos + 1);
|
||||
// Trim whitespace and non-digits from alt port
|
||||
size_t altEnd = 0;
|
||||
while (altEnd < altPortStr.size() && altPortStr[altEnd] >= '0' && altPortStr[altEnd] <= '9') {
|
||||
altEnd++;
|
||||
}
|
||||
if (altEnd > 0) {
|
||||
calibreAltPort = static_cast<uint16_t>(std::stoi(altPortStr.substr(0, altEnd)));
|
||||
}
|
||||
} else {
|
||||
portStr = response.substr(semiPos + 1);
|
||||
}
|
||||
|
||||
// Trim whitespace from main port
|
||||
while (!portStr.empty() && (portStr[0] == ' ' || portStr[0] == '\t')) {
|
||||
portStr = portStr.substr(1);
|
||||
}
|
||||
|
||||
if (!portStr.empty()) {
|
||||
calibrePort = static_cast<uint16_t>(std::stoi(portStr));
|
||||
}
|
||||
|
||||
// Get hostname if present, otherwise use sender IP
|
||||
if (onPos != std::string::npos && closePos != std::string::npos && closePos > onPos + 4) {
|
||||
calibreHostname = response.substr(onPos + 4, closePos - onPos - 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the sender's IP as the host to connect to
|
||||
calibreHost = udp.remoteIP().toString().c_str();
|
||||
if (calibreHostname.empty()) {
|
||||
calibreHostname = calibreHost;
|
||||
}
|
||||
|
||||
if (calibrePort > 0) {
|
||||
// Connect to Calibre's TCP server - try main port first, then alt port
|
||||
setState(WirelessState::CONNECTING);
|
||||
setStatus("Connecting to " + calibreHostname + "...");
|
||||
|
||||
// Small delay before connecting
|
||||
vTaskDelay(100 / portTICK_PERIOD_MS);
|
||||
|
||||
bool connected = false;
|
||||
|
||||
// Try main port first
|
||||
if (tcpClient.connect(calibreHost.c_str(), calibrePort, 5000)) {
|
||||
connected = true;
|
||||
}
|
||||
|
||||
// Try alternative port if main failed
|
||||
if (!connected && calibreAltPort > 0) {
|
||||
vTaskDelay(200 / portTICK_PERIOD_MS);
|
||||
if (tcpClient.connect(calibreHost.c_str(), calibreAltPort, 5000)) {
|
||||
connected = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (connected) {
|
||||
setState(WirelessState::WAITING);
|
||||
setStatus("Connected to " + calibreHostname + "\nWaiting for commands...");
|
||||
} else {
|
||||
// Don't set error yet, keep trying discovery
|
||||
setState(WirelessState::DISCOVERING);
|
||||
setStatus("Discovering Calibre...\n(Connection failed, retrying)");
|
||||
calibrePort = 0;
|
||||
calibreAltPort = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleTcpClient() {
|
||||
if (!tcpClient.connected()) {
|
||||
setState(WirelessState::DISCONNECTED);
|
||||
setStatus("Calibre disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (inBinaryMode) {
|
||||
receiveBinaryData();
|
||||
return;
|
||||
}
|
||||
|
||||
std::string message;
|
||||
if (readJsonMessage(message)) {
|
||||
// Parse opcode from JSON array format: [opcode, {...}]
|
||||
// Find the opcode (first number after '[')
|
||||
size_t start = message.find('[');
|
||||
if (start != std::string::npos) {
|
||||
start++;
|
||||
size_t end = message.find(',', start);
|
||||
if (end != std::string::npos) {
|
||||
const int opcodeInt = std::stoi(message.substr(start, end - start));
|
||||
if (opcodeInt < 0 || opcodeInt >= OpCode::ERROR) {
|
||||
Serial.printf("[%lu] [CAL] Invalid opcode: %d\n", millis(), opcodeInt);
|
||||
sendJsonResponse(OpCode::OK, "{}");
|
||||
return;
|
||||
}
|
||||
const auto opcode = static_cast<OpCode>(opcodeInt);
|
||||
|
||||
// Extract data object (everything after the comma until the last ']')
|
||||
size_t dataStart = end + 1;
|
||||
size_t dataEnd = message.rfind(']');
|
||||
std::string data = "";
|
||||
if (dataEnd != std::string::npos && dataEnd > dataStart) {
|
||||
data = message.substr(dataStart, dataEnd - dataStart);
|
||||
}
|
||||
|
||||
handleCommand(opcode, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool CalibreWirelessActivity::readJsonMessage(std::string& message) {
|
||||
// Read available data into buffer
|
||||
int available = tcpClient.available();
|
||||
if (available > 0) {
|
||||
// Limit buffer growth to prevent memory issues
|
||||
if (recvBuffer.size() > 100000) {
|
||||
recvBuffer.clear();
|
||||
return false;
|
||||
}
|
||||
// Read in chunks
|
||||
char buf[1024];
|
||||
while (available > 0) {
|
||||
int toRead = std::min(available, static_cast<int>(sizeof(buf)));
|
||||
int bytesRead = tcpClient.read(reinterpret_cast<uint8_t*>(buf), toRead);
|
||||
if (bytesRead > 0) {
|
||||
recvBuffer.append(buf, bytesRead);
|
||||
available -= bytesRead;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (recvBuffer.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find '[' which marks the start of JSON
|
||||
size_t bracketPos = recvBuffer.find('[');
|
||||
if (bracketPos == std::string::npos) {
|
||||
// No '[' found - if buffer is getting large, something is wrong
|
||||
if (recvBuffer.size() > 1000) {
|
||||
recvBuffer.clear();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to extract length from digits before '['
|
||||
// Calibre ALWAYS sends a length prefix, so if it's not valid digits, it's garbage
|
||||
size_t msgLen = 0;
|
||||
bool validPrefix = false;
|
||||
|
||||
if (bracketPos > 0 && bracketPos <= 12) {
|
||||
// Check if prefix is all digits
|
||||
bool allDigits = true;
|
||||
for (size_t i = 0; i < bracketPos; i++) {
|
||||
char c = recvBuffer[i];
|
||||
if (c < '0' || c > '9') {
|
||||
allDigits = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allDigits) {
|
||||
msgLen = std::stoul(recvBuffer.substr(0, bracketPos));
|
||||
validPrefix = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!validPrefix) {
|
||||
// Not a valid length prefix - discard everything up to '[' and treat '[' as start
|
||||
if (bracketPos > 0) {
|
||||
recvBuffer = recvBuffer.substr(bracketPos);
|
||||
}
|
||||
// Without length prefix, we can't reliably parse - wait for more data
|
||||
// that hopefully starts with a proper length prefix
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sanity check the message length
|
||||
if (msgLen > 1000000) {
|
||||
recvBuffer = recvBuffer.substr(bracketPos + 1); // Skip past this '[' and try again
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we have the complete message
|
||||
size_t totalNeeded = bracketPos + msgLen;
|
||||
if (recvBuffer.size() < totalNeeded) {
|
||||
// Not enough data yet - wait for more
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the message
|
||||
message = recvBuffer.substr(bracketPos, msgLen);
|
||||
|
||||
// Keep the rest in buffer (may contain binary data or next message)
|
||||
if (recvBuffer.size() > totalNeeded) {
|
||||
recvBuffer = recvBuffer.substr(totalNeeded);
|
||||
} else {
|
||||
recvBuffer.clear();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::sendJsonResponse(const OpCode opcode, const std::string& data) {
|
||||
// Format: length + [opcode, {data}]
|
||||
std::string json = "[" + std::to_string(opcode) + "," + data + "]";
|
||||
const std::string lengthPrefix = std::to_string(json.length());
|
||||
json.insert(0, lengthPrefix);
|
||||
|
||||
tcpClient.write(reinterpret_cast<const uint8_t*>(json.c_str()), json.length());
|
||||
tcpClient.flush();
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleCommand(const OpCode opcode, const std::string& data) {
|
||||
switch (opcode) {
|
||||
case OpCode::GET_INITIALIZATION_INFO:
|
||||
handleGetInitializationInfo(data);
|
||||
break;
|
||||
case OpCode::GET_DEVICE_INFORMATION:
|
||||
handleGetDeviceInformation();
|
||||
break;
|
||||
case OpCode::FREE_SPACE:
|
||||
handleFreeSpace();
|
||||
break;
|
||||
case OpCode::GET_BOOK_COUNT:
|
||||
handleGetBookCount();
|
||||
break;
|
||||
case OpCode::SEND_BOOK:
|
||||
handleSendBook(data);
|
||||
break;
|
||||
case OpCode::SEND_BOOK_METADATA:
|
||||
handleSendBookMetadata(data);
|
||||
break;
|
||||
case OpCode::DISPLAY_MESSAGE:
|
||||
handleDisplayMessage(data);
|
||||
break;
|
||||
case OpCode::NOOP:
|
||||
handleNoop(data);
|
||||
break;
|
||||
case OpCode::SET_CALIBRE_DEVICE_INFO:
|
||||
case OpCode::SET_CALIBRE_DEVICE_NAME:
|
||||
// These set metadata about the connected Calibre instance.
|
||||
// We don't need this info, just acknowledge receipt.
|
||||
sendJsonResponse(OpCode::OK, "{}");
|
||||
break;
|
||||
case OpCode::SET_LIBRARY_INFO:
|
||||
// Library metadata (name, UUID) - not needed for receiving books
|
||||
sendJsonResponse(OpCode::OK, "{}");
|
||||
break;
|
||||
case OpCode::SEND_BOOKLISTS:
|
||||
// Calibre asking us to send our book list. We report 0 books in
|
||||
// handleGetBookCount, so this is effectively a no-op.
|
||||
sendJsonResponse(OpCode::OK, "{}");
|
||||
break;
|
||||
case OpCode::TOTAL_SPACE:
|
||||
handleFreeSpace();
|
||||
break;
|
||||
default:
|
||||
Serial.printf("[%lu] [CAL] Unknown opcode: %d\n", millis(), opcode);
|
||||
sendJsonResponse(OpCode::OK, "{}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleGetInitializationInfo(const std::string& data) {
|
||||
setState(WirelessState::WAITING);
|
||||
setStatus("Connected to " + calibreHostname +
|
||||
"\nWaiting for transfer...\n\nIf transfer fails, enable\n'Ignore free space' in Calibre's\nSmartDevice "
|
||||
"plugin settings.");
|
||||
|
||||
// Build response with device capabilities
|
||||
// Format must match what Calibre expects from a smart device
|
||||
std::string response = "{";
|
||||
response += "\"appName\":\"CrossPoint\",";
|
||||
response += "\"acceptedExtensions\":[\"epub\"],";
|
||||
response += "\"cacheUsesLpaths\":true,";
|
||||
response += "\"canAcceptLibraryInfo\":true,";
|
||||
response += "\"canDeleteMultipleBooks\":true,";
|
||||
response += "\"canReceiveBookBinary\":true,";
|
||||
response += "\"canSendOkToSendbook\":true,";
|
||||
response += "\"canStreamBooks\":true,";
|
||||
response += "\"canStreamMetadata\":true,";
|
||||
response += "\"canUseCachedMetadata\":true,";
|
||||
// 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},";
|
||||
response += "\"maxBookContentPacketLen\":4096,";
|
||||
response += "\"passwordHash\":\"\",";
|
||||
response += "\"useUuidFileNames\":false,";
|
||||
response += "\"versionOK\":true";
|
||||
response += "}";
|
||||
|
||||
sendJsonResponse(OpCode::OK, response);
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleGetDeviceInformation() {
|
||||
std::string response = "{";
|
||||
response += "\"device_info\":{";
|
||||
response += "\"device_store_uuid\":\"" + getDeviceUuid() + "\",";
|
||||
response += "\"device_name\":\"CrossPoint Reader\",";
|
||||
response += "\"device_version\":\"" CROSSPOINT_VERSION "\"";
|
||||
response += "},";
|
||||
response += "\"version\":1,";
|
||||
response += "\"device_version\":\"" CROSSPOINT_VERSION "\"";
|
||||
response += "}";
|
||||
|
||||
sendJsonResponse(OpCode::OK, response);
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleFreeSpace() {
|
||||
// TODO: Report actual SD card free space instead of hardcoded value
|
||||
// Report 10GB free space for now
|
||||
sendJsonResponse(OpCode::OK, "{\"free_space_on_device\":10737418240}");
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleGetBookCount() {
|
||||
// We report 0 books - Calibre will send books without checking for duplicates
|
||||
std::string response = "{\"count\":0,\"willStream\":true,\"willScan\":false}";
|
||||
sendJsonResponse(OpCode::OK, response);
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleSendBook(const std::string& data) {
|
||||
// Manually extract lpath and length from SEND_BOOK data
|
||||
// Full JSON parsing crashes on large metadata, so we just extract what we need
|
||||
|
||||
// Extract "lpath" field - format: "lpath": "value"
|
||||
std::string lpath;
|
||||
size_t lpathPos = data.find("\"lpath\"");
|
||||
if (lpathPos != std::string::npos) {
|
||||
size_t colonPos = data.find(':', lpathPos + 7);
|
||||
if (colonPos != std::string::npos) {
|
||||
size_t quoteStart = data.find('"', colonPos + 1);
|
||||
if (quoteStart != std::string::npos) {
|
||||
size_t quoteEnd = data.find('"', quoteStart + 1);
|
||||
if (quoteEnd != std::string::npos) {
|
||||
lpath = data.substr(quoteStart + 1, quoteEnd - quoteStart - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract top-level "length" field - must track depth to skip nested objects
|
||||
// The metadata contains nested "length" fields (e.g., cover image length)
|
||||
size_t length = 0;
|
||||
int depth = 0;
|
||||
for (size_t i = 0; i < data.size(); i++) {
|
||||
char c = data[i];
|
||||
if (c == '{' || c == '[') {
|
||||
depth++;
|
||||
} else if (c == '}' || c == ']') {
|
||||
depth--;
|
||||
} else if (depth == 1 && c == '"') {
|
||||
// At top level, check if this is "length"
|
||||
if (i + 9 < data.size() && data.substr(i, 8) == "\"length\"") {
|
||||
// Found top-level "length" - extract the number after ':'
|
||||
size_t colonPos = data.find(':', i + 8);
|
||||
if (colonPos != std::string::npos) {
|
||||
size_t numStart = colonPos + 1;
|
||||
while (numStart < data.size() && (data[numStart] == ' ' || data[numStart] == '\t')) {
|
||||
numStart++;
|
||||
}
|
||||
size_t numEnd = numStart;
|
||||
while (numEnd < data.size() && data[numEnd] >= '0' && data[numEnd] <= '9') {
|
||||
numEnd++;
|
||||
}
|
||||
if (numEnd > numStart) {
|
||||
length = std::stoul(data.substr(numStart, numEnd - numStart));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lpath.empty() || length == 0) {
|
||||
sendJsonResponse(OpCode::ERROR, "{\"message\":\"Invalid book data\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract filename from lpath
|
||||
std::string filename = lpath;
|
||||
const size_t lastSlash = filename.rfind('/');
|
||||
if (lastSlash != std::string::npos) {
|
||||
filename = filename.substr(lastSlash + 1);
|
||||
}
|
||||
|
||||
// Sanitize and create full path
|
||||
currentFilename = "/" + StringUtils::sanitizeFilename(filename);
|
||||
if (currentFilename.find(".epub") == std::string::npos) {
|
||||
currentFilename += ".epub";
|
||||
}
|
||||
currentFileSize = length;
|
||||
bytesReceived = 0;
|
||||
|
||||
setState(WirelessState::RECEIVING);
|
||||
setStatus("Receiving: " + filename);
|
||||
|
||||
// Open file for writing
|
||||
if (!SdMan.openFileForWrite("CAL", currentFilename.c_str(), currentFile)) {
|
||||
setError("Failed to create file");
|
||||
sendJsonResponse(OpCode::ERROR, "{\"message\":\"Failed to create file\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send OK to start receiving binary data
|
||||
sendJsonResponse(OpCode::OK, "{}");
|
||||
|
||||
// Switch to binary mode
|
||||
inBinaryMode = true;
|
||||
binaryBytesRemaining = length;
|
||||
|
||||
// Check if recvBuffer has leftover data (binary file data that arrived with the JSON)
|
||||
if (!recvBuffer.empty()) {
|
||||
size_t toWrite = std::min(recvBuffer.size(), binaryBytesRemaining);
|
||||
size_t written = currentFile.write(reinterpret_cast<const uint8_t*>(recvBuffer.data()), toWrite);
|
||||
bytesReceived += written;
|
||||
binaryBytesRemaining -= written;
|
||||
recvBuffer = recvBuffer.substr(toWrite);
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleSendBookMetadata(const std::string& data) {
|
||||
// We receive metadata after the book - just acknowledge
|
||||
sendJsonResponse(OpCode::OK, "{}");
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleDisplayMessage(const std::string& data) {
|
||||
// Calibre may send messages to display
|
||||
// Check messageKind - 1 means password error
|
||||
if (data.find("\"messageKind\":1") != std::string::npos) {
|
||||
setError("Password required");
|
||||
}
|
||||
sendJsonResponse(OpCode::OK, "{}");
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::handleNoop(const std::string& data) {
|
||||
// Check for ejecting flag
|
||||
if (data.find("\"ejecting\":true") != std::string::npos) {
|
||||
setState(WirelessState::DISCONNECTED);
|
||||
setStatus("Calibre disconnected");
|
||||
}
|
||||
sendJsonResponse(OpCode::NOOP, "{}");
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::receiveBinaryData() {
|
||||
const int available = tcpClient.available();
|
||||
if (available == 0) {
|
||||
// Check if connection is still alive
|
||||
if (!tcpClient.connected()) {
|
||||
currentFile.close();
|
||||
inBinaryMode = false;
|
||||
setError("Transfer interrupted");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t buffer[1024];
|
||||
const size_t toRead = std::min(sizeof(buffer), binaryBytesRemaining);
|
||||
const size_t bytesRead = tcpClient.read(buffer, toRead);
|
||||
|
||||
if (bytesRead > 0) {
|
||||
currentFile.write(buffer, bytesRead);
|
||||
bytesReceived += bytesRead;
|
||||
binaryBytesRemaining -= bytesRead;
|
||||
updateRequired = true;
|
||||
|
||||
if (binaryBytesRemaining == 0) {
|
||||
// Transfer complete
|
||||
currentFile.flush();
|
||||
currentFile.close();
|
||||
inBinaryMode = false;
|
||||
|
||||
setState(WirelessState::WAITING);
|
||||
setStatus("Received: " + currentFilename + "\nWaiting for more...");
|
||||
|
||||
// Send OK to acknowledge completion
|
||||
sendJsonResponse(OpCode::OK, "{}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::render() const {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Draw header
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 30, "Calibre Wireless", true, EpdFontFamily::BOLD);
|
||||
|
||||
// Draw IP address
|
||||
const std::string ipAddr = WiFi.localIP().toString().c_str();
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 60, ("IP: " + ipAddr).c_str());
|
||||
|
||||
// Draw status message
|
||||
int statusY = pageHeight / 2 - 40;
|
||||
|
||||
// Split status message by newlines and draw each line
|
||||
std::string status = statusMessage;
|
||||
size_t pos = 0;
|
||||
while ((pos = status.find('\n')) != std::string::npos) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, statusY, status.substr(0, pos).c_str());
|
||||
statusY += 25;
|
||||
status = status.substr(pos + 1);
|
||||
}
|
||||
if (!status.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, statusY, status.c_str());
|
||||
statusY += 25;
|
||||
}
|
||||
|
||||
// Draw progress if receiving
|
||||
if (state == WirelessState::RECEIVING && currentFileSize > 0) {
|
||||
const int barWidth = pageWidth - 100;
|
||||
constexpr int barHeight = 20;
|
||||
constexpr int barX = 50;
|
||||
const int barY = statusY + 20;
|
||||
ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, bytesReceived, currentFileSize);
|
||||
}
|
||||
|
||||
// Draw error if present
|
||||
if (!errorMessage.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight - 120, errorMessage.c_str());
|
||||
}
|
||||
|
||||
// Draw button hints
|
||||
const auto labels = mappedInput.mapLabels("Back", "", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
std::string CalibreWirelessActivity::getDeviceUuid() const {
|
||||
// Generate a consistent UUID based on MAC address
|
||||
uint8_t mac[6];
|
||||
WiFi.macAddress(mac);
|
||||
|
||||
char uuid[37];
|
||||
snprintf(uuid, sizeof(uuid), "%02x%02x%02x%02x-%02x%02x-4000-8000-%02x%02x%02x%02x%02x%02x", mac[0], mac[1], mac[2],
|
||||
mac[3], mac[4], mac[5], mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||
|
||||
return std::string(uuid);
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::setState(WirelessState newState) {
|
||||
xSemaphoreTake(stateMutex, portMAX_DELAY);
|
||||
state = newState;
|
||||
xSemaphoreGive(stateMutex);
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::setStatus(const std::string& message) {
|
||||
statusMessage = message;
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
void CalibreWirelessActivity::setError(const std::string& message) {
|
||||
errorMessage = message;
|
||||
setState(WirelessState::ERROR);
|
||||
}
|
||||
135
src/activities/network/CalibreWirelessActivity.h
Normal file
135
src/activities/network/CalibreWirelessActivity.h
Normal file
@ -0,0 +1,135 @@
|
||||
#pragma once
|
||||
#include <SDCardManager.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <WiFiUdp.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
/**
|
||||
* CalibreWirelessActivity implements Calibre's "wireless device" protocol.
|
||||
* This allows Calibre desktop to send books directly to the device over WiFi.
|
||||
*
|
||||
* 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 {
|
||||
// Calibre wireless device states
|
||||
enum class WirelessState {
|
||||
DISCOVERING, // Listening for Calibre server broadcasts
|
||||
CONNECTING, // Establishing TCP connection
|
||||
WAITING, // Connected, waiting for commands
|
||||
RECEIVING, // Receiving a book file
|
||||
COMPLETE, // Transfer complete
|
||||
DISCONNECTED, // Calibre disconnected
|
||||
ERROR // Connection/transfer error
|
||||
};
|
||||
|
||||
// Calibre protocol opcodes (from calibre/devices/smart_device_app/driver.py)
|
||||
enum OpCode : uint8_t {
|
||||
OK = 0,
|
||||
SET_CALIBRE_DEVICE_INFO = 1,
|
||||
SET_CALIBRE_DEVICE_NAME = 2,
|
||||
GET_DEVICE_INFORMATION = 3,
|
||||
TOTAL_SPACE = 4,
|
||||
FREE_SPACE = 5,
|
||||
GET_BOOK_COUNT = 6,
|
||||
SEND_BOOKLISTS = 7,
|
||||
SEND_BOOK = 8,
|
||||
GET_INITIALIZATION_INFO = 9,
|
||||
BOOK_DONE = 11,
|
||||
NOOP = 12, // Was incorrectly 18
|
||||
DELETE_BOOK = 13,
|
||||
GET_BOOK_FILE_SEGMENT = 14,
|
||||
GET_BOOK_METADATA = 15,
|
||||
SEND_BOOK_METADATA = 16,
|
||||
DISPLAY_MESSAGE = 17,
|
||||
CALIBRE_BUSY = 18,
|
||||
SET_LIBRARY_INFO = 19,
|
||||
ERROR = 20,
|
||||
};
|
||||
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
TaskHandle_t networkTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
SemaphoreHandle_t stateMutex = nullptr;
|
||||
bool updateRequired = false;
|
||||
|
||||
WirelessState state = WirelessState::DISCOVERING;
|
||||
const std::function<void()> onComplete;
|
||||
|
||||
// UDP discovery
|
||||
WiFiUDP udp;
|
||||
|
||||
// TCP connection (we connect to Calibre)
|
||||
WiFiClient tcpClient;
|
||||
std::string calibreHost;
|
||||
uint16_t calibrePort = 0;
|
||||
uint16_t calibreAltPort = 0; // Alternative port (content server)
|
||||
std::string calibreHostname;
|
||||
|
||||
// Transfer state
|
||||
std::string currentFilename;
|
||||
size_t currentFileSize = 0;
|
||||
size_t bytesReceived = 0;
|
||||
std::string statusMessage;
|
||||
std::string errorMessage;
|
||||
|
||||
// Protocol state
|
||||
bool inBinaryMode = false;
|
||||
size_t binaryBytesRemaining = 0;
|
||||
FsFile currentFile;
|
||||
std::string recvBuffer; // Buffer for incoming data (like KOReader)
|
||||
|
||||
static void displayTaskTrampoline(void* param);
|
||||
static void networkTaskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
[[noreturn]] void networkTaskLoop();
|
||||
void render() const;
|
||||
|
||||
// Network operations
|
||||
void listenForDiscovery();
|
||||
void handleTcpClient();
|
||||
bool readJsonMessage(std::string& message);
|
||||
void sendJsonResponse(OpCode opcode, const std::string& data);
|
||||
void handleCommand(OpCode opcode, const std::string& data);
|
||||
void receiveBinaryData();
|
||||
|
||||
// Protocol handlers
|
||||
void handleGetInitializationInfo(const std::string& data);
|
||||
void handleGetDeviceInformation();
|
||||
void handleFreeSpace();
|
||||
void handleGetBookCount();
|
||||
void handleSendBook(const std::string& data);
|
||||
void handleSendBookMetadata(const std::string& data);
|
||||
void handleDisplayMessage(const std::string& data);
|
||||
void handleNoop(const std::string& data);
|
||||
|
||||
// Utility
|
||||
std::string getDeviceUuid() const;
|
||||
void setState(WirelessState newState);
|
||||
void setStatus(const std::string& message);
|
||||
void setError(const std::string& message);
|
||||
|
||||
public:
|
||||
explicit CalibreWirelessActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onComplete)
|
||||
: Activity("CalibreWireless", renderer, mappedInput), onComplete(onComplete) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
bool preventAutoSleep() override { return true; }
|
||||
bool skipLoopDelay() override { return true; }
|
||||
};
|
||||
169
src/activities/settings/CalibreSettingsActivity.cpp
Normal file
169
src/activities/settings/CalibreSettingsActivity.cpp
Normal file
@ -0,0 +1,169 @@
|
||||
#include "CalibreSettingsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/network/CalibreWirelessActivity.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr int MENU_ITEMS = 2;
|
||||
const char* menuNames[MENU_ITEMS] = {"Calibre Web URL", "Connect as Wireless Device"};
|
||||
} // namespace
|
||||
|
||||
void CalibreSettingsActivity::taskTrampoline(void* param) {
|
||||
auto* self = static_cast<CalibreSettingsActivity*>(param);
|
||||
self->displayTaskLoop();
|
||||
}
|
||||
|
||||
void CalibreSettingsActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
selectedIndex = 0;
|
||||
updateRequired = true;
|
||||
|
||||
xTaskCreate(&CalibreSettingsActivity::taskTrampoline, "CalibreSettingsTask",
|
||||
4096, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&displayTaskHandle // Task handle
|
||||
);
|
||||
}
|
||||
|
||||
void CalibreSettingsActivity::onExit() {
|
||||
ActivityWithSubactivity::onExit();
|
||||
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
displayTaskHandle = nullptr;
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
}
|
||||
|
||||
void CalibreSettingsActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
||||
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
|
||||
updateRequired = true;
|
||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
|
||||
selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreSettingsActivity::handleSelection() {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
|
||||
if (selectedIndex == 0) {
|
||||
// Calibre Web URL
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
renderer, mappedInput, "Calibre Web URL", SETTINGS.opdsServerUrl, 10,
|
||||
127, // maxLength
|
||||
false, // not password
|
||||
[this](const std::string& url) {
|
||||
strncpy(SETTINGS.opdsServerUrl, url.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1);
|
||||
SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
},
|
||||
[this]() {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
} else if (selectedIndex == 1) {
|
||||
// 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 {
|
||||
updateRequired = true;
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
|
||||
void CalibreSettingsActivity::displayTaskLoop() {
|
||||
while (true) {
|
||||
if (updateRequired && !subActivity) {
|
||||
updateRequired = false;
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
render();
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void CalibreSettingsActivity::render() {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
|
||||
// Draw header
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre", true, EpdFontFamily::BOLD);
|
||||
|
||||
// Draw selection highlight
|
||||
renderer.fillRect(0, 60 + selectedIndex * 30 - 2, pageWidth - 1, 30);
|
||||
|
||||
// Draw menu items
|
||||
for (int i = 0; i < MENU_ITEMS; i++) {
|
||||
const int settingY = 60 + i * 30;
|
||||
const bool isSelected = (i == selectedIndex);
|
||||
|
||||
renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected);
|
||||
|
||||
// Draw status for URL setting
|
||||
if (i == 0) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw button hints
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
36
src/activities/settings/CalibreSettingsActivity.h
Normal file
36
src/activities/settings/CalibreSettingsActivity.h
Normal file
@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "activities/ActivityWithSubactivity.h"
|
||||
|
||||
/**
|
||||
* Submenu for Calibre settings.
|
||||
* Shows Calibre Web URL and Calibre Wireless Device options.
|
||||
*/
|
||||
class CalibreSettingsActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
explicit CalibreSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onBack)
|
||||
: ActivityWithSubactivity("CalibreSettings", renderer, mappedInput), onBack(onBack) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
|
||||
private:
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
bool updateRequired = false;
|
||||
|
||||
int selectedIndex = 0;
|
||||
const std::function<void()> onBack;
|
||||
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
void render();
|
||||
void handleSelection();
|
||||
};
|
||||
@ -3,6 +3,9 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <HardwareSerial.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "CalibreSettingsActivity.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OtaUpdateActivity.h"
|
||||
@ -10,7 +13,7 @@
|
||||
|
||||
// Define the static settings list
|
||||
namespace {
|
||||
constexpr int settingsCount = 16;
|
||||
constexpr int settingsCount = 17;
|
||||
const SettingInfo settingsList[settingsCount] = {
|
||||
// Should match with SLEEP_SCREEN_MODE
|
||||
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
|
||||
@ -35,6 +38,7 @@ const SettingInfo settingsList[settingsCount] = {
|
||||
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
|
||||
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
|
||||
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
|
||||
SettingInfo::Action("Calibre Settings"),
|
||||
SettingInfo::Action("Check for updates")};
|
||||
} // namespace
|
||||
|
||||
@ -132,7 +136,15 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step;
|
||||
}
|
||||
} else if (setting.type == SettingType::ACTION) {
|
||||
if (std::string(setting.name) == "Check for updates") {
|
||||
if (strcmp(setting.name, "Calibre Settings") == 0) {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
exitActivity();
|
||||
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
xSemaphoreGive(renderingMutex);
|
||||
} else if (strcmp(setting.name, "Check for updates") == 0) {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
exitActivity();
|
||||
enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] {
|
||||
|
||||
10
src/main.cpp
10
src/main.cpp
@ -7,12 +7,15 @@
|
||||
#include <SPI.h>
|
||||
#include <builtinFonts/all.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/boot_sleep/BootActivity.h"
|
||||
#include "activities/boot_sleep/SleepActivity.h"
|
||||
#include "activities/browser/OpdsBookBrowserActivity.h"
|
||||
#include "activities/home/HomeActivity.h"
|
||||
#include "activities/network/CrossPointWebServerActivity.h"
|
||||
#include "activities/reader/ReaderActivity.h"
|
||||
@ -222,10 +225,15 @@ void onGoToSettings() {
|
||||
enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome));
|
||||
}
|
||||
|
||||
void onGoToBrowser() {
|
||||
exitActivity();
|
||||
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome));
|
||||
}
|
||||
|
||||
void onGoHome() {
|
||||
exitActivity();
|
||||
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToReaderHome, onGoToSettings,
|
||||
onGoToFileTransfer));
|
||||
onGoToFileTransfer, onGoToBrowser));
|
||||
}
|
||||
|
||||
void setupDisplayAndFonts() {
|
||||
|
||||
128
src/network/HttpDownloader.cpp
Normal file
128
src/network/HttpDownloader.cpp
Normal file
@ -0,0 +1,128 @@
|
||||
#include "HttpDownloader.h"
|
||||
|
||||
#include <HTTPClient.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
|
||||
const std::unique_ptr<WiFiClientSecure> client(new WiFiClientSecure());
|
||||
client->setInsecure();
|
||||
HTTPClient http;
|
||||
|
||||
Serial.printf("[%lu] [HTTP] Fetching: %s\n", millis(), url.c_str());
|
||||
|
||||
http.begin(*client, url.c_str());
|
||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
|
||||
|
||||
const int httpCode = http.GET();
|
||||
if (httpCode != HTTP_CODE_OK) {
|
||||
Serial.printf("[%lu] [HTTP] Fetch failed: %d\n", millis(), httpCode);
|
||||
http.end();
|
||||
return false;
|
||||
}
|
||||
|
||||
outContent = http.getString().c_str();
|
||||
http.end();
|
||||
|
||||
Serial.printf("[%lu] [HTTP] Fetched %zu bytes\n", millis(), outContent.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath,
|
||||
ProgressCallback progress) {
|
||||
const std::unique_ptr<WiFiClientSecure> client(new WiFiClientSecure());
|
||||
client->setInsecure();
|
||||
HTTPClient http;
|
||||
|
||||
Serial.printf("[%lu] [HTTP] Downloading: %s\n", millis(), url.c_str());
|
||||
Serial.printf("[%lu] [HTTP] Destination: %s\n", millis(), destPath.c_str());
|
||||
|
||||
http.begin(*client, url.c_str());
|
||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
|
||||
|
||||
const int httpCode = http.GET();
|
||||
if (httpCode != HTTP_CODE_OK) {
|
||||
Serial.printf("[%lu] [HTTP] Download failed: %d\n", millis(), httpCode);
|
||||
http.end();
|
||||
return HTTP_ERROR;
|
||||
}
|
||||
|
||||
const size_t contentLength = http.getSize();
|
||||
Serial.printf("[%lu] [HTTP] Content-Length: %zu\n", millis(), contentLength);
|
||||
|
||||
// Remove existing file if present
|
||||
if (SdMan.exists(destPath.c_str())) {
|
||||
SdMan.remove(destPath.c_str());
|
||||
}
|
||||
|
||||
// Open file for writing
|
||||
FsFile file;
|
||||
if (!SdMan.openFileForWrite("HTTP", destPath.c_str(), file)) {
|
||||
Serial.printf("[%lu] [HTTP] Failed to open file for writing\n", millis());
|
||||
http.end();
|
||||
return FILE_ERROR;
|
||||
}
|
||||
|
||||
// Get the stream for chunked reading
|
||||
WiFiClient* stream = http.getStreamPtr();
|
||||
if (!stream) {
|
||||
Serial.printf("[%lu] [HTTP] Failed to get stream\n", millis());
|
||||
file.close();
|
||||
SdMan.remove(destPath.c_str());
|
||||
http.end();
|
||||
return HTTP_ERROR;
|
||||
}
|
||||
|
||||
// Download in chunks
|
||||
uint8_t buffer[DOWNLOAD_CHUNK_SIZE];
|
||||
size_t downloaded = 0;
|
||||
const size_t total = contentLength > 0 ? contentLength : 0;
|
||||
|
||||
while (http.connected() && (contentLength == 0 || downloaded < contentLength)) {
|
||||
const size_t available = stream->available();
|
||||
if (available == 0) {
|
||||
delay(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const size_t toRead = available < DOWNLOAD_CHUNK_SIZE ? available : DOWNLOAD_CHUNK_SIZE;
|
||||
const size_t bytesRead = stream->readBytes(buffer, toRead);
|
||||
|
||||
if (bytesRead == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const size_t written = file.write(buffer, bytesRead);
|
||||
if (written != bytesRead) {
|
||||
Serial.printf("[%lu] [HTTP] Write failed: wrote %zu of %zu bytes\n", millis(), written, bytesRead);
|
||||
file.close();
|
||||
SdMan.remove(destPath.c_str());
|
||||
http.end();
|
||||
return FILE_ERROR;
|
||||
}
|
||||
|
||||
downloaded += bytesRead;
|
||||
|
||||
if (progress && total > 0) {
|
||||
progress(downloaded, total);
|
||||
}
|
||||
}
|
||||
|
||||
file.close();
|
||||
http.end();
|
||||
|
||||
Serial.printf("[%lu] [HTTP] Downloaded %zu bytes\n", millis(), downloaded);
|
||||
|
||||
// Verify download size if known
|
||||
if (contentLength > 0 && downloaded != contentLength) {
|
||||
Serial.printf("[%lu] [HTTP] Size mismatch: got %zu, expected %zu\n", millis(), downloaded, contentLength);
|
||||
SdMan.remove(destPath.c_str());
|
||||
return HTTP_ERROR;
|
||||
}
|
||||
|
||||
return OK;
|
||||
}
|
||||
42
src/network/HttpDownloader.h
Normal file
42
src/network/HttpDownloader.h
Normal file
@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
#include <SDCardManager.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* HTTP client utility for fetching content and downloading files.
|
||||
* Wraps WiFiClientSecure and HTTPClient for HTTPS requests.
|
||||
*/
|
||||
class HttpDownloader {
|
||||
public:
|
||||
using ProgressCallback = std::function<void(size_t downloaded, size_t total)>;
|
||||
|
||||
enum DownloadError {
|
||||
OK = 0,
|
||||
HTTP_ERROR,
|
||||
FILE_ERROR,
|
||||
ABORTED,
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch text content from a URL.
|
||||
* @param url The URL to fetch
|
||||
* @param outContent The fetched content (output)
|
||||
* @return true if fetch succeeded, false on error
|
||||
*/
|
||||
static bool fetchUrl(const std::string& url, std::string& outContent);
|
||||
|
||||
/**
|
||||
* Download a file to the SD card.
|
||||
* @param url The URL to download
|
||||
* @param destPath The destination path on SD card
|
||||
* @param progress Optional progress callback
|
||||
* @return DownloadError indicating success or failure type
|
||||
*/
|
||||
static DownloadError downloadToFile(const std::string& url, const std::string& destPath,
|
||||
ProgressCallback progress = nullptr);
|
||||
|
||||
private:
|
||||
static constexpr size_t DOWNLOAD_CHUNK_SIZE = 1024;
|
||||
};
|
||||
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…
x
Reference in New Issue
Block a user