From 3a761b18af6d5f287a43b1c9477714dbb7fc8198 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Tue, 27 Jan 2026 06:02:38 -0500 Subject: [PATCH] Refactors Calibre Wireless Device & Calibre Library (#404) Our esp32 consistently dropped the last few packets of the TCP transfer in the old implementation. Only about 1/5 transfers would complete. I've refactored that entire system into an actual Calibre Device Plugin that basically uses the exact same system as the web server's file transfer protocol. I kept them separate so that we don't muddy up the existing file transfer stuff even if it's basically the same at the end of the day I didn't want to limit our ability to change it later. I've also added basic auth to OPDS and renamed that feature to OPDS Browser to just disassociate it from Calibre. --------- Co-authored-by: Arthur Tazhitdinov Co-authored-by: Dave Allie --- USER_GUIDE.md | 14 +- src/CrossPointSettings.cpp | 20 +- src/CrossPointSettings.h | 2 + .../browser/OpdsBookBrowserActivity.cpp | 5 +- src/activities/home/HomeActivity.cpp | 4 +- .../network/CalibreConnectActivity.cpp | 276 +++++++ .../network/CalibreConnectActivity.h | 55 ++ .../network/CalibreWirelessActivity.cpp | 756 ------------------ .../network/CalibreWirelessActivity.h | 135 ---- .../network/CrossPointWebServerActivity.cpp | 22 +- .../network/CrossPointWebServerActivity.h | 2 +- .../network/NetworkModeSelectionActivity.cpp | 18 +- .../network/NetworkModeSelectionActivity.h | 3 +- .../settings/CalibreSettingsActivity.cpp | 84 +- .../settings/CalibreSettingsActivity.h | 4 +- .../settings/CategorySettingsActivity.cpp | 2 +- src/activities/settings/SettingsActivity.cpp | 2 +- src/network/CrossPointWebServer.cpp | 118 ++- src/network/CrossPointWebServer.h | 20 +- src/network/HttpDownloader.cpp | 17 + 20 files changed, 614 insertions(+), 945 deletions(-) create mode 100644 src/activities/network/CalibreConnectActivity.cpp create mode 100644 src/activities/network/CalibreConnectActivity.h delete mode 100644 src/activities/network/CalibreWirelessActivity.cpp delete mode 100644 src/activities/network/CalibreWirelessActivity.h diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 67dee48..f160af7 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -81,6 +81,18 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con > [!TIP] > Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details. +### 3.4.1 Calibre Wireless Transfers + +CrossPoint supports sending books from Calibre using the CrossPoint Reader device plugin. + +1. Install the plugin in Calibre: + - Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin. + - Download the zip file. + - Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file. +2. On the device: File Transfer → Connect to Calibre → Join a network. +3. Make sure your computer is on the same WiFi network. +4. In Calibre, click "Send to device" to transfer books. + ### 3.5 Settings The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: @@ -132,7 +144,7 @@ The Settings screen allows you to configure the device's behavior. There are a f - **Reader Paragraph Alignment**: Set the alignment of paragraphs; options are "Justified" (default), "Left", "Center", or "Right". - **Time to Sleep**: Set the duration of inactivity before the device automatically goes to sleep. - **Refresh Frequency**: Set how often the screen does a full refresh while reading to reduce ghosting. -- **Calibre Settings**: Set up integration for accessing a Calibre web library or connecting to Calibre as a wireless device. +- **OPDS Browser**: Configure OPDS server settings for browsing and downloading books. Set the server URL (for Calibre Content Server, add `/opds` to the end), and optionally configure username and password for servers requiring authentication. Note: Only HTTP Basic authentication is supported. If using Calibre Content Server with authentication enabled, you must set it to use Basic authentication instead of the default Digest authentication. - **Check for updates**: Check for firmware updates over WiFi. ### 3.6 Sleep Screen diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index f5e8ded..ea26ad9 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -14,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 = 20; +constexpr uint8_t SETTINGS_COUNT = 22; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -49,6 +49,9 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, hideBatteryPercentage); serialization::writePod(outputFile, longPressChapterSkip); serialization::writePod(outputFile, hyphenationEnabled); + // New fields added at end for backward compatibility + serialization::writeString(outputFile, std::string(opdsUsername)); + serialization::writeString(outputFile, std::string(opdsPassword)); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -120,6 +123,21 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, hyphenationEnabled); if (++settingsRead >= fileSettingsCount) break; + // New fields added at end for backward compatibility + { + std::string usernameStr; + serialization::readString(inputFile, usernameStr); + strncpy(opdsUsername, usernameStr.c_str(), sizeof(opdsUsername) - 1); + opdsUsername[sizeof(opdsUsername) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; + { + std::string passwordStr; + serialization::readString(inputFile, passwordStr); + strncpy(opdsPassword, passwordStr.c_str(), sizeof(opdsPassword) - 1); + opdsPassword[sizeof(opdsPassword) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 2c33beb..f8892be 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -95,6 +95,8 @@ class CrossPointSettings { uint8_t screenMargin = 5; // OPDS browser settings char opdsServerUrl[128] = ""; + char opdsUsername[64] = ""; + char opdsPassword[64] = ""; // Hide battery percentage uint8_t hideBatteryPercentage = HIDE_NEVER; // Long-press chapter skip on side buttons diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 555cba9..2bde74d 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -18,7 +18,6 @@ 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) { @@ -33,7 +32,7 @@ void OpdsBookBrowserActivity::onEnter() { state = BrowserState::CHECK_WIFI; entries.clear(); navigationHistory.clear(); - currentPath = OPDS_ROOT_PATH; + currentPath = ""; // Root path - user provides full URL in settings selectorIndex = 0; errorMessage.clear(); statusMessage = "Checking WiFi..."; @@ -172,7 +171,7 @@ void OpdsBookBrowserActivity::render() const { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre Library", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, 15, "OPDS Browser", true, EpdFontFamily::BOLD); if (state == BrowserState::CHECK_WIFI) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index eb11ba9..3389e80 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -502,8 +502,8 @@ void HomeActivity::render() { // Build menu items dynamically std::vector menuItems = {"My Library", "File Transfer", "Settings"}; if (hasOpdsUrl) { - // Insert Calibre Library after My Library - menuItems.insert(menuItems.begin() + 1, "Calibre Library"); + // Insert OPDS Browser after My Library + menuItems.insert(menuItems.begin() + 1, "OPDS Browser"); } const int menuTileWidth = pageWidth - 2 * margin; diff --git a/src/activities/network/CalibreConnectActivity.cpp b/src/activities/network/CalibreConnectActivity.cpp new file mode 100644 index 0000000..8aa60c4 --- /dev/null +++ b/src/activities/network/CalibreConnectActivity.cpp @@ -0,0 +1,276 @@ +#include "CalibreConnectActivity.h" + +#include +#include +#include +#include + +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "WifiSelectionActivity.h" +#include "fontIds.h" + +namespace { +constexpr const char* HOSTNAME = "crosspoint"; +} // namespace + +void CalibreConnectActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void CalibreConnectActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + updateRequired = true; + state = CalibreConnectState::WIFI_SELECTION; + connectedIP.clear(); + connectedSSID.clear(); + lastHandleClientTime = 0; + lastProgressReceived = 0; + lastProgressTotal = 0; + currentUploadName.clear(); + lastCompleteName.clear(); + lastCompleteAt = 0; + exitRequested = false; + + xTaskCreate(&CalibreConnectActivity::taskTrampoline, "CalibreConnectTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); + + if (WiFi.status() != WL_CONNECTED) { + enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, + [this](const bool connected) { onWifiSelectionComplete(connected); })); + } else { + connectedIP = WiFi.localIP().toString().c_str(); + connectedSSID = WiFi.SSID().c_str(); + startWebServer(); + } +} + +void CalibreConnectActivity::onExit() { + ActivityWithSubactivity::onExit(); + + stopWebServer(); + MDNS.end(); + + delay(50); + WiFi.disconnect(false); + delay(30); + WiFi.mode(WIFI_OFF); + delay(30); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void CalibreConnectActivity::onWifiSelectionComplete(const bool connected) { + if (!connected) { + exitActivity(); + onComplete(); + return; + } + + if (subActivity) { + connectedIP = static_cast(subActivity.get())->getConnectedIP(); + } else { + connectedIP = WiFi.localIP().toString().c_str(); + } + connectedSSID = WiFi.SSID().c_str(); + exitActivity(); + startWebServer(); +} + +void CalibreConnectActivity::startWebServer() { + state = CalibreConnectState::SERVER_STARTING; + updateRequired = true; + + if (MDNS.begin(HOSTNAME)) { + // mDNS is optional for the Calibre plugin but still helpful for users. + Serial.printf("[%lu] [CAL] mDNS started: http://%s.local/\n", millis(), HOSTNAME); + } + + webServer.reset(new CrossPointWebServer()); + webServer->begin(); + + if (webServer->isRunning()) { + state = CalibreConnectState::SERVER_RUNNING; + updateRequired = true; + } else { + state = CalibreConnectState::ERROR; + updateRequired = true; + } +} + +void CalibreConnectActivity::stopWebServer() { + if (webServer) { + webServer->stop(); + webServer.reset(); + } +} + +void CalibreConnectActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + exitRequested = true; + } + + if (webServer && webServer->isRunning()) { + const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; + if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) { + Serial.printf("[%lu] [CAL] WARNING: %lu ms gap since last handleClient\n", millis(), timeSinceLastHandleClient); + } + + esp_task_wdt_reset(); + constexpr int MAX_ITERATIONS = 80; + for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) { + webServer->handleClient(); + if ((i & 0x07) == 0x07) { + esp_task_wdt_reset(); + } + if ((i & 0x0F) == 0x0F) { + yield(); + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + exitRequested = true; + break; + } + } + } + lastHandleClientTime = millis(); + + const auto status = webServer->getWsUploadStatus(); + bool changed = false; + if (status.inProgress) { + if (status.received != lastProgressReceived || status.total != lastProgressTotal || + status.filename != currentUploadName) { + lastProgressReceived = status.received; + lastProgressTotal = status.total; + currentUploadName = status.filename; + changed = true; + } + } else if (lastProgressReceived != 0 || lastProgressTotal != 0) { + lastProgressReceived = 0; + lastProgressTotal = 0; + currentUploadName.clear(); + changed = true; + } + if (status.lastCompleteAt != 0 && status.lastCompleteAt != lastCompleteAt) { + lastCompleteAt = status.lastCompleteAt; + lastCompleteName = status.lastCompleteName; + changed = true; + } + if (lastCompleteAt > 0 && (millis() - lastCompleteAt) >= 6000) { + lastCompleteAt = 0; + lastCompleteName.clear(); + changed = true; + } + if (changed) { + updateRequired = true; + } + } + + if (exitRequested) { + onComplete(); + return; + } +} + +void CalibreConnectActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void CalibreConnectActivity::render() const { + if (state == CalibreConnectState::SERVER_RUNNING) { + renderer.clearScreen(); + renderServerRunning(); + renderer.displayBuffer(); + return; + } + + renderer.clearScreen(); + const auto pageHeight = renderer.getScreenHeight(); + if (state == CalibreConnectState::SERVER_STARTING) { + renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Starting Calibre...", true, EpdFontFamily::BOLD); + } else if (state == CalibreConnectState::ERROR) { + renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Calibre setup failed", true, EpdFontFamily::BOLD); + } + renderer.displayBuffer(); +} + +void CalibreConnectActivity::renderServerRunning() const { + constexpr int LINE_SPACING = 24; + constexpr int SMALL_SPACING = 20; + constexpr int SECTION_SPACING = 40; + constexpr int TOP_PADDING = 14; + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Connect to Calibre", true, EpdFontFamily::BOLD); + + int y = 55 + TOP_PADDING; + renderer.drawCenteredText(UI_10_FONT_ID, y, "Network", true, EpdFontFamily::BOLD); + y += LINE_SPACING; + std::string ssidInfo = "Network: " + connectedSSID; + if (ssidInfo.length() > 28) { + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); + } + renderer.drawCenteredText(UI_10_FONT_ID, y, ssidInfo.c_str()); + renderer.drawCenteredText(UI_10_FONT_ID, y + LINE_SPACING, ("IP: " + connectedIP).c_str()); + + y += LINE_SPACING * 2 + SECTION_SPACING; + renderer.drawCenteredText(UI_10_FONT_ID, y, "Setup", true, EpdFontFamily::BOLD); + y += LINE_SPACING; + renderer.drawCenteredText(SMALL_FONT_ID, y, "1) Install CrossPoint Reader plugin"); + renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING, "2) Be on the same WiFi network"); + renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING * 2, "3) In Calibre: \"Send to device\""); + renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING * 3, "Keep this screen open while sending"); + + y += SMALL_SPACING * 3 + SECTION_SPACING; + renderer.drawCenteredText(UI_10_FONT_ID, y, "Status", true, EpdFontFamily::BOLD); + y += LINE_SPACING; + if (lastProgressTotal > 0 && lastProgressReceived <= lastProgressTotal) { + std::string label = "Receiving"; + if (!currentUploadName.empty()) { + label += ": " + currentUploadName; + if (label.length() > 34) { + label.replace(31, label.length() - 31, "..."); + } + } + renderer.drawCenteredText(SMALL_FONT_ID, y, label.c_str()); + constexpr int barWidth = 300; + constexpr int barHeight = 16; + constexpr int barX = (480 - barWidth) / 2; + ScreenComponents::drawProgressBar(renderer, barX, y + 22, barWidth, barHeight, lastProgressReceived, + lastProgressTotal); + y += 40; + } + + if (lastCompleteAt > 0 && (millis() - lastCompleteAt) < 6000) { + std::string msg = "Received: " + lastCompleteName; + if (msg.length() > 36) { + msg.replace(33, msg.length() - 33, "..."); + } + renderer.drawCenteredText(SMALL_FONT_ID, y, msg.c_str()); + } + + const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} diff --git a/src/activities/network/CalibreConnectActivity.h b/src/activities/network/CalibreConnectActivity.h new file mode 100644 index 0000000..08cf4bb --- /dev/null +++ b/src/activities/network/CalibreConnectActivity.h @@ -0,0 +1,55 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "activities/ActivityWithSubactivity.h" +#include "network/CrossPointWebServer.h" + +enum class CalibreConnectState { WIFI_SELECTION, SERVER_STARTING, SERVER_RUNNING, ERROR }; + +/** + * CalibreConnectActivity starts the file transfer server in STA mode, + * but renders Calibre-specific instructions instead of the web transfer UI. + */ +class CalibreConnectActivity final : public ActivityWithSubactivity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + CalibreConnectState state = CalibreConnectState::WIFI_SELECTION; + const std::function onComplete; + + std::unique_ptr webServer; + std::string connectedIP; + std::string connectedSSID; + unsigned long lastHandleClientTime = 0; + size_t lastProgressReceived = 0; + size_t lastProgressTotal = 0; + std::string currentUploadName; + std::string lastCompleteName; + unsigned long lastCompleteAt = 0; + bool exitRequested = false; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + void renderServerRunning() const; + + void onWifiSelectionComplete(bool connected); + void startWebServer(); + void stopWebServer(); + + public: + explicit CalibreConnectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onComplete) + : ActivityWithSubactivity("CalibreConnect", renderer, mappedInput), onComplete(onComplete) {} + void onEnter() override; + void onExit() override; + void loop() override; + bool skipLoopDelay() override { return webServer && webServer->isRunning(); } + bool preventAutoSleep() override { return webServer && webServer->isRunning(); } +}; diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp deleted file mode 100644 index 0ad9094..0000000 --- a/src/activities/network/CalibreWirelessActivity.cpp +++ /dev/null @@ -1,756 +0,0 @@ -#include "CalibreWirelessActivity.h" - -#include -#include -#include -#include - -#include - -#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(param); - self->displayTaskLoop(); -} - -void CalibreWirelessActivity::networkTaskTrampoline(void* param) { - auto* self = static_cast(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("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(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(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(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(sizeof(buf))); - int bytesRead = tcpClient.read(reinterpret_cast(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(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 (!StringUtils::checkFileExtension(currentFilename, ".epub")) { - 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(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); -} diff --git a/src/activities/network/CalibreWirelessActivity.h b/src/activities/network/CalibreWirelessActivity.h deleted file mode 100644 index ae2b176..0000000 --- a/src/activities/network/CalibreWirelessActivity.h +++ /dev/null @@ -1,135 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include -#include - -#include -#include - -#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 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& 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; } -}; diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 35ad58b..c6af149 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -12,6 +12,7 @@ #include "MappedInputManager.h" #include "NetworkModeSelectionActivity.h" #include "WifiSelectionActivity.h" +#include "activities/network/CalibreConnectActivity.h" #include "fontIds.h" namespace { @@ -125,8 +126,13 @@ void CrossPointWebServerActivity::onExit() { } void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) { - Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), - mode == NetworkMode::JOIN_NETWORK ? "Join Network" : "Create Hotspot"); + const char* modeName = "Join Network"; + if (mode == NetworkMode::CONNECT_CALIBRE) { + modeName = "Connect to Calibre"; + } else if (mode == NetworkMode::CREATE_HOTSPOT) { + modeName = "Create Hotspot"; + } + Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), modeName); networkMode = mode; isApMode = (mode == NetworkMode::CREATE_HOTSPOT); @@ -134,6 +140,18 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) // Exit mode selection subactivity exitActivity(); + if (mode == NetworkMode::CONNECT_CALIBRE) { + exitActivity(); + enterNewActivity(new CalibreConnectActivity(renderer, mappedInput, [this] { + exitActivity(); + state = WebServerActivityState::MODE_SELECTION; + enterNewActivity(new NetworkModeSelectionActivity( + renderer, mappedInput, [this](const NetworkMode nextMode) { onNetworkModeSelected(nextMode); }, + [this]() { onGoBack(); })); + })); + return; + } + if (mode == NetworkMode::JOIN_NETWORK) { // STA mode - launch WiFi selection Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis()); diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index 775a247..a1189a5 100644 --- a/src/activities/network/CrossPointWebServerActivity.h +++ b/src/activities/network/CrossPointWebServerActivity.h @@ -23,7 +23,7 @@ enum class WebServerActivityState { /** * CrossPointWebServerActivity is the entry point for file transfer functionality. * It: - * - First presents a choice between "Join a Network" (STA) and "Create Hotspot" (AP) + * - First presents a choice between "Join a Network" (STA), "Connect to Calibre", and "Create Hotspot" (AP) * - For STA mode: Launches WifiSelectionActivity to connect to an existing network * - For AP mode: Creates an Access Point that clients can connect to * - Starts the CrossPointWebServer when connected diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp index ad05f5b..5076708 100644 --- a/src/activities/network/NetworkModeSelectionActivity.cpp +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -6,10 +6,13 @@ #include "fontIds.h" namespace { -constexpr int MENU_ITEM_COUNT = 2; -const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Create Hotspot"}; -const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {"Connect to an existing WiFi network", - "Create a WiFi network others can join"}; +constexpr int MENU_ITEM_COUNT = 3; +const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Connect to Calibre", "Create Hotspot"}; +const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = { + "Connect to an existing WiFi network", + "Use Calibre wireless device transfers", + "Create a WiFi network others can join", +}; } // namespace void NetworkModeSelectionActivity::taskTrampoline(void* param) { @@ -58,7 +61,12 @@ void NetworkModeSelectionActivity::loop() { // Handle confirm button - select current option if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - const NetworkMode mode = (selectedIndex == 0) ? NetworkMode::JOIN_NETWORK : NetworkMode::CREATE_HOTSPOT; + NetworkMode mode = NetworkMode::JOIN_NETWORK; + if (selectedIndex == 1) { + mode = NetworkMode::CONNECT_CALIBRE; + } else if (selectedIndex == 2) { + mode = NetworkMode::CREATE_HOTSPOT; + } onModeSelected(mode); return; } diff --git a/src/activities/network/NetworkModeSelectionActivity.h b/src/activities/network/NetworkModeSelectionActivity.h index b9f2e1e..1b93b82 100644 --- a/src/activities/network/NetworkModeSelectionActivity.h +++ b/src/activities/network/NetworkModeSelectionActivity.h @@ -8,11 +8,12 @@ #include "../Activity.h" // Enum for network mode selection -enum class NetworkMode { JOIN_NETWORK, CREATE_HOTSPOT }; +enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT }; /** * NetworkModeSelectionActivity presents the user with a choice: * - "Join a Network" - Connect to an existing WiFi network (STA mode) + * - "Connect to Calibre" - Use Calibre wireless device transfers * - "Create Hotspot" - Create an Access Point that others can connect to (AP mode) * * The onModeSelected callback is called with the user's choice. diff --git a/src/activities/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp index 4f614ff..d1df9d0 100644 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ b/src/activities/settings/CalibreSettingsActivity.cpp @@ -1,20 +1,17 @@ #include "CalibreSettingsActivity.h" #include -#include #include #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"}; +constexpr int MENU_ITEMS = 3; +const char* menuNames[MENU_ITEMS] = {"OPDS Server URL", "Username", "Password"}; } // namespace void CalibreSettingsActivity::taskTrampoline(void* param) { @@ -80,10 +77,10 @@ void CalibreSettingsActivity::handleSelection() { xSemaphoreTake(renderingMutex, portMAX_DELAY); if (selectedIndex == 0) { - // Calibre Web URL + // OPDS Server URL exitActivity(); enterNewActivity(new KeyboardEntryActivity( - renderer, mappedInput, "Calibre Web URL", SETTINGS.opdsServerUrl, 10, + renderer, mappedInput, "OPDS Server URL", SETTINGS.opdsServerUrl, 10, 127, // maxLength false, // not password [this](const std::string& url) { @@ -98,26 +95,41 @@ void CalibreSettingsActivity::handleSelection() { updateRequired = true; })); } else if (selectedIndex == 1) { - // Wireless Device - launch the activity (handles WiFi connection internally) + // Username exitActivity(); - if (WiFi.status() != WL_CONNECTED) { - enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](bool connected) { - exitActivity(); - if (connected) { - enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] { - exitActivity(); - updateRequired = true; - })); - } else { + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Username", SETTINGS.opdsUsername, 10, + 63, // maxLength + false, // not password + [this](const std::string& username) { + strncpy(SETTINGS.opdsUsername, username.c_str(), sizeof(SETTINGS.opdsUsername) - 1); + SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0'; + SETTINGS.saveToFile(); + exitActivity(); updateRequired = true; - } - })); - } else { - enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] { - exitActivity(); - updateRequired = true; - })); - } + }, + [this]() { + exitActivity(); + updateRequired = true; + })); + } else if (selectedIndex == 2) { + // Password + exitActivity(); + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Password", SETTINGS.opdsPassword, 10, + 63, // maxLength + false, // not password mode + [this](const std::string& password) { + strncpy(SETTINGS.opdsPassword, password.c_str(), sizeof(SETTINGS.opdsPassword) - 1); + SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0'; + SETTINGS.saveToFile(); + exitActivity(); + updateRequired = true; + }, + [this]() { + exitActivity(); + updateRequired = true; + })); } xSemaphoreGive(renderingMutex); @@ -141,24 +153,32 @@ void CalibreSettingsActivity::render() { const auto pageWidth = renderer.getScreenWidth(); // Draw header - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, 15, "OPDS Browser", true, EpdFontFamily::BOLD); + + // Draw info text about Calibre + renderer.drawCenteredText(UI_10_FONT_ID, 40, "For Calibre, add /opds to your URL"); // Draw selection highlight - renderer.fillRect(0, 60 + selectedIndex * 30 - 2, pageWidth - 1, 30); + renderer.fillRect(0, 70 + selectedIndex * 30 - 2, pageWidth - 1, 30); // Draw menu items for (int i = 0; i < MENU_ITEMS; i++) { - const int settingY = 60 + i * 30; + const int settingY = 70 + i * 30; const bool isSelected = (i == selectedIndex); renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected); - // Draw status for URL setting + // Draw status for each setting + const char* status = "[Not Set]"; 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); + status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]"; + } else if (i == 1) { + status = (strlen(SETTINGS.opdsUsername) > 0) ? "[Set]" : "[Not Set]"; + } else if (i == 2) { + status = (strlen(SETTINGS.opdsPassword) > 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 diff --git a/src/activities/settings/CalibreSettingsActivity.h b/src/activities/settings/CalibreSettingsActivity.h index 77b9218..49695c6 100644 --- a/src/activities/settings/CalibreSettingsActivity.h +++ b/src/activities/settings/CalibreSettingsActivity.h @@ -8,8 +8,8 @@ #include "activities/ActivityWithSubactivity.h" /** - * Submenu for Calibre settings. - * Shows Calibre Web URL and Calibre Wireless Device options. + * Submenu for OPDS Browser settings. + * Shows OPDS Server URL and HTTP authentication options. */ class CalibreSettingsActivity final : public ActivityWithSubactivity { public: diff --git a/src/activities/settings/CategorySettingsActivity.cpp b/src/activities/settings/CategorySettingsActivity.cpp index a6182b5..7fd5ef5 100644 --- a/src/activities/settings/CategorySettingsActivity.cpp +++ b/src/activities/settings/CategorySettingsActivity.cpp @@ -103,7 +103,7 @@ void CategorySettingsActivity::toggleCurrentSetting() { updateRequired = true; })); xSemaphoreGive(renderingMutex); - } else if (strcmp(setting.name, "Calibre Settings") == 0) { + } else if (strcmp(setting.name, "OPDS Browser") == 0) { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 45b7a12..819115a 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -49,7 +49,7 @@ constexpr int systemSettingsCount = 5; const SettingInfo systemSettings[systemSettingsCount] = { SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout, {"1 min", "5 min", "10 min", "15 min", "30 min"}), - SettingInfo::Action("KOReader Sync"), SettingInfo::Action("Calibre Settings"), SettingInfo::Action("Clear Cache"), + SettingInfo::Action("KOReader Sync"), SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Clear Cache"), SettingInfo::Action("Check for updates")}; } // namespace diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 90dfed7..a135c9f 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -18,6 +18,8 @@ namespace { // Note: Items starting with "." are automatically hidden const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); +constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678}; +constexpr uint16_t LOCAL_UDP_PORT = 8134; // Static pointer for WebSocket callback (WebSocketsServer requires C-style callback) CrossPointWebServer* wsInstance = nullptr; @@ -30,6 +32,9 @@ size_t wsUploadSize = 0; size_t wsUploadReceived = 0; unsigned long wsUploadStartTime = 0; bool wsUploadInProgress = false; +String wsLastCompleteName; +size_t wsLastCompleteSize = 0; +unsigned long wsLastCompleteAt = 0; // Helper function to clear epub cache after upload void clearEpubCacheIfNeeded(const String& filePath) { @@ -96,6 +101,7 @@ void CrossPointWebServer::begin() { server->on("/api/status", HTTP_GET, [this] { handleStatus(); }); server->on("/api/files", HTTP_GET, [this] { handleFileListData(); }); + server->on("/download", HTTP_GET, [this] { handleDownload(); }); // Upload endpoint with special handling for multipart form data server->on("/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); }); @@ -119,6 +125,10 @@ void CrossPointWebServer::begin() { wsServer->onEvent(wsEventCallback); Serial.printf("[%lu] [WEB] WebSocket server started\n", millis()); + udpActive = udp.begin(LOCAL_UDP_PORT); + Serial.printf("[%lu] [WEB] Discovery UDP %s on port %d\n", millis(), udpActive ? "enabled" : "failed", + LOCAL_UDP_PORT); + running = true; Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); @@ -156,6 +166,11 @@ void CrossPointWebServer::stop() { Serial.printf("[%lu] [WEB] WebSocket server stopped\n", millis()); } + if (udpActive) { + udp.stop(); + udpActive = false; + } + // Brief delay to allow any in-flight handleClient() calls to complete delay(20); @@ -174,7 +189,7 @@ void CrossPointWebServer::stop() { Serial.printf("[%lu] [WEB] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap()); } -void CrossPointWebServer::handleClient() const { +void CrossPointWebServer::handleClient() { static unsigned long lastDebugPrint = 0; // Check running flag FIRST before accessing server @@ -200,6 +215,40 @@ void CrossPointWebServer::handleClient() const { if (wsServer) { wsServer->loop(); } + + // Respond to discovery broadcasts + if (udpActive) { + int packetSize = udp.parsePacket(); + if (packetSize > 0) { + char buffer[16]; + int len = udp.read(buffer, sizeof(buffer) - 1); + if (len > 0) { + buffer[len] = '\0'; + if (strcmp(buffer, "hello") == 0) { + String hostname = WiFi.getHostname(); + if (hostname.isEmpty()) { + hostname = "crosspoint"; + } + String message = "crosspoint (on " + hostname + ");" + String(wsPort); + udp.beginPacket(udp.remoteIP(), udp.remotePort()); + udp.write(reinterpret_cast(message.c_str()), message.length()); + udp.endPacket(); + } + } + } + } +} + +CrossPointWebServer::WsUploadStatus CrossPointWebServer::getWsUploadStatus() const { + WsUploadStatus status; + status.inProgress = wsUploadInProgress; + status.received = wsUploadReceived; + status.total = wsUploadSize; + status.filename = wsUploadFileName.c_str(); + status.lastCompleteName = wsLastCompleteName.c_str(); + status.lastCompleteSize = wsLastCompleteSize; + status.lastCompleteAt = wsLastCompleteAt; + return status; } void CrossPointWebServer::handleRoot() const { @@ -346,6 +395,69 @@ void CrossPointWebServer::handleFileListData() const { Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str()); } +void CrossPointWebServer::handleDownload() const { + if (!server->hasArg("path")) { + server->send(400, "text/plain", "Missing path"); + return; + } + + String itemPath = server->arg("path"); + if (itemPath.isEmpty() || itemPath == "/") { + server->send(400, "text/plain", "Invalid path"); + return; + } + if (!itemPath.startsWith("/")) { + itemPath = "/" + itemPath; + } + + const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); + if (itemName.startsWith(".")) { + server->send(403, "text/plain", "Cannot access system files"); + return; + } + for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { + if (itemName.equals(HIDDEN_ITEMS[i])) { + server->send(403, "text/plain", "Cannot access protected items"); + return; + } + } + + if (!SdMan.exists(itemPath.c_str())) { + server->send(404, "text/plain", "Item not found"); + return; + } + + FsFile file = SdMan.open(itemPath.c_str()); + if (!file) { + server->send(500, "text/plain", "Failed to open file"); + return; + } + if (file.isDirectory()) { + file.close(); + server->send(400, "text/plain", "Path is a directory"); + return; + } + + String contentType = "application/octet-stream"; + if (isEpubFile(itemPath)) { + contentType = "application/epub+zip"; + } + + char nameBuf[128] = {0}; + String filename = "download"; + if (file.getName(nameBuf, sizeof(nameBuf))) { + filename = nameBuf; + } + + server->setContentLength(file.size()); + server->sendHeader("Content-Disposition", "attachment; filename=\"" + filename + "\""); + server->send(200, contentType.c_str(), ""); + + WiFiClient client = server->client(); + client.write(file); + file.close(); +} + // Static variables for upload handling static FsFile uploadFile; static String uploadFileName; @@ -798,6 +910,10 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* wsUploadFile.close(); wsUploadInProgress = false; + wsLastCompleteName = wsUploadFileName; + wsLastCompleteSize = wsUploadSize; + wsLastCompleteAt = millis(); + unsigned long elapsed = millis() - wsUploadStartTime; float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0; diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index ecc2d3d..3603029 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -2,7 +2,10 @@ #include #include +#include +#include +#include #include // Structure to hold file information @@ -15,6 +18,16 @@ struct FileInfo { class CrossPointWebServer { public: + struct WsUploadStatus { + bool inProgress = false; + size_t received = 0; + size_t total = 0; + std::string filename; + std::string lastCompleteName; + size_t lastCompleteSize = 0; + unsigned long lastCompleteAt = 0; + }; + CrossPointWebServer(); ~CrossPointWebServer(); @@ -25,11 +38,13 @@ class CrossPointWebServer { void stop(); // Call this periodically to handle client requests - void handleClient() const; + void handleClient(); // Check if server is running bool isRunning() const { return running; } + WsUploadStatus getWsUploadStatus() const; + // Get the port number uint16_t getPort() const { return port; } @@ -40,6 +55,8 @@ class CrossPointWebServer { bool apMode = false; // true when running in AP mode, false for STA mode uint16_t port = 80; uint16_t wsPort = 81; // WebSocket port + WiFiUDP udp; + bool udpActive = false; // WebSocket upload state void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length); @@ -56,6 +73,7 @@ class CrossPointWebServer { void handleStatus() const; void handleFileList() const; void handleFileListData() const; + void handleDownload() const; void handleUpload() const; void handleUploadPost() const; void handleCreateFolder() const; diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index fe65ea6..b7718c2 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -5,9 +5,12 @@ #include #include #include +#include +#include #include +#include "CrossPointSettings.h" #include "util/UrlUtils.h" bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { @@ -28,6 +31,13 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + // Add Basic HTTP auth if credentials are configured + if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { + std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword; + String encoded = base64::encode(credentials.c_str()); + http.addHeader("Authorization", "Basic " + encoded); + } + const int httpCode = http.GET(); if (httpCode != HTTP_CODE_OK) { Serial.printf("[%lu] [HTTP] Fetch failed: %d\n", millis(), httpCode); @@ -72,6 +82,13 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + // Add Basic HTTP auth if credentials are configured + if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { + std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword; + String encoded = base64::encode(credentials.c_str()); + http.addHeader("Authorization", "Basic " + encoded); + } + const int httpCode = http.GET(); if (httpCode != HTTP_CODE_OK) { Serial.printf("[%lu] [HTTP] Download failed: %d\n", millis(), httpCode);