From 8114899bef6e40decc1d15d92e5767c46780be6b Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Fri, 16 Jan 2026 17:41:55 -0500 Subject: [PATCH] Renames Calibre Browser to a more generic OPDS Browser and adds basic auth --- .../crosspoint_reader/plugin/Archive.zip | Bin 0 -> 7816 bytes .../crosspoint_reader/plugin/ws_client.py | 38 +- src/CrossPointSettings.cpp | 21 +- src/CrossPointSettings.h | 2 + .../browser/OpdsBookBrowserActivity.cpp | 5 +- src/activities/home/HomeActivity.cpp | 4 +- .../network/CalibreConnectActivity.cpp | 272 ++++++ .../network/CalibreConnectActivity.h | 54 ++ .../network/CalibreWirelessActivity.cpp | 856 ------------------ .../network/CalibreWirelessActivity.h | 117 --- .../network/CrossPointWebServerActivity.cpp | 23 +- .../network/CrossPointWebServerActivity.h | 2 +- .../network/NetworkModeSelectionActivity.cpp | 18 +- .../network/NetworkModeSelectionActivity.h | 3 +- .../settings/CalibreSettingsActivity.cpp | 85 +- .../settings/CalibreSettingsActivity.h | 4 +- src/activities/settings/SettingsActivity.cpp | 4 +- src/network/CrossPointWebServer.cpp | 19 + src/network/CrossPointWebServer.h | 14 + src/network/HttpDownloader.cpp | 12 + 20 files changed, 516 insertions(+), 1037 deletions(-) create mode 100644 calibre-plugin/crosspoint_reader/plugin/Archive.zip 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/calibre-plugin/crosspoint_reader/plugin/Archive.zip b/calibre-plugin/crosspoint_reader/plugin/Archive.zip new file mode 100644 index 0000000000000000000000000000000000000000..008b35503fdb3acaa4462c8d3081e9003bc38b89 GIT binary patch literal 7816 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 literal 0 HcmV?d00001 diff --git a/calibre-plugin/crosspoint_reader/plugin/ws_client.py b/calibre-plugin/crosspoint_reader/plugin/ws_client.py index 99bc890d..d87fa0b2 100644 --- a/calibre-plugin/crosspoint_reader/plugin/ws_client.py +++ b/calibre-plugin/crosspoint_reader/plugin/ws_client.py @@ -98,18 +98,30 @@ class WebSocketClient: self.sock.sendall(header + masked) def read_text(self): - opcode, payload = self._read_frame() - if opcode == 0x8: - code = None - reason = '' - if len(payload) >= 2: - code = struct.unpack('!H', payload[:2])[0] - reason = payload[2:].decode('utf-8', 'ignore') - self._log('Server closed connection', code, reason) - raise WebSocketError('Connection closed') - if opcode != 0x1: - return '' - return payload.decode('utf-8', 'ignore') + deadline = time.time() + self.timeout + while True: + if time.time() > deadline: + raise WebSocketError('Timed out waiting for text frame') + opcode, payload = self._read_frame() + if opcode == 0x8: + code = None + reason = '' + if len(payload) >= 2: + code = struct.unpack('!H', payload[:2])[0] + reason = payload[2:].decode('utf-8', 'ignore') + self._log('Server closed connection', code, reason) + raise WebSocketError('Connection closed') + if opcode == 0x9: + # Ping -> respond with Pong + self._send_frame(0xA, payload) + continue + if opcode == 0xA: + # Pong -> ignore + continue + if opcode != 0x1: + self._log('Ignoring non-text opcode', opcode, len(payload)) + continue + return payload.decode('utf-8', 'ignore') def _read_frame(self): if self.sock is None: @@ -251,6 +263,8 @@ def upload_file(host, port, upload_path, filename, filepath, chunk_size=16384, d msg = client.read_text() client._log('Received', msg) + if not msg: + raise WebSocketError('Unexpected response: ') if msg.startswith('ERROR'): raise WebSocketError(msg) if msg != 'READY': diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 17b5d053..8d599b48 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 = 18; +constexpr uint8_t SETTINGS_COUNT = 20; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -48,6 +48,9 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, textAntiAliasing); serialization::writePod(outputFile, hideBatteryPercentage); serialization::writePod(outputFile, longPressChapterSkip); + // 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()); @@ -110,12 +113,28 @@ bool CrossPointSettings::loadFromFile() { strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1); opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0'; } + if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, textAntiAliasing); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, hideBatteryPercentage); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, longPressChapterSkip); 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 a5641aad..b07346e9 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -88,6 +88,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 4e0a08d2..dadbde51 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -16,7 +16,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) { @@ -31,7 +30,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..."; @@ -170,7 +169,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 3a97e132..b1488328 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -498,8 +498,8 @@ void HomeActivity::render() { // Build menu items dynamically std::vector menuItems = {"Browse Files", "File Transfer", "Settings"}; if (hasOpdsUrl) { - // Insert Calibre Library after Browse Files - menuItems.insert(menuItems.begin() + 1, "Calibre Library"); + // Insert OPDS Browser after Browse Files + 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 00000000..3c7ef8f9 --- /dev/null +++ b/src/activities/network/CalibreConnectActivity.cpp @@ -0,0 +1,272 @@ +#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; + + 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)) { + onComplete(); + return; + } + + 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 = 500; + for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) { + webServer->handleClient(); + if ((i & 0x1F) == 0x1F) { + esp_task_wdt_reset(); + } + if ((i & 0x3F) == 0x3F) { + yield(); + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onComplete(); + return; + } + } + } + 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; + } + } +} + +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 TOP_PADDING = 18; + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Connect to Calibre", true, EpdFontFamily::BOLD); + + int y = 60 + TOP_PADDING; + 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; + renderer.drawCenteredText(SMALL_FONT_ID, y, "Install the CrossPoint Reader"); + renderer.drawCenteredText(SMALL_FONT_ID, y + LINE_SPACING, "device plugin in Calibre."); + + y += LINE_SPACING * 2; + renderer.drawCenteredText(SMALL_FONT_ID, y, "Make sure your computer is"); + renderer.drawCenteredText(SMALL_FONT_ID, y + LINE_SPACING, "on the same WiFi network."); + + y += LINE_SPACING * 2; + renderer.drawCenteredText(SMALL_FONT_ID, y, "Then in Calibre, click"); + renderer.drawCenteredText(SMALL_FONT_ID, y + LINE_SPACING, "\"Send to device\"."); + + y += LINE_SPACING * 2; + renderer.drawCenteredText(SMALL_FONT_ID, y, "Leave this screen open while sending."); + + y += LINE_SPACING * 2; + 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 + 28, barWidth, barHeight, lastProgressReceived, + lastProgressTotal); + y += 46; + } + + 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 00000000..60d5a70b --- /dev/null +++ b/src/activities/network/CalibreConnectActivity.h @@ -0,0 +1,54 @@ +#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; + + 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 c62d8d58..00000000 --- a/src/activities/network/CalibreWirelessActivity.cpp +++ /dev/null @@ -1,856 +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; -} // namespace - -void CalibreWirelessActivity::displayTaskTrampoline(void* param) { - static_cast(param)->displayTaskLoop(); -} - -void CalibreWirelessActivity::networkTaskTrampoline(void* param) { - static_cast(param)->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(); - inSkipMode = false; - skipBytesRemaining = 0; - skipOpcode = -1; - skipExtractedLpath.clear(); - skipExtractedLength = 0; - shouldExit = false; - - updateRequired = true; - - udp.begin(LOCAL_UDP_PORT); - - xTaskCreate(&CalibreWirelessActivity::displayTaskTrampoline, "CalDisplayTask", 2048, this, 1, &displayTaskHandle); - xTaskCreate(&CalibreWirelessActivity::networkTaskTrampoline, "CalNetworkTask", 12288, this, 2, &networkTaskHandle); -} - -void CalibreWirelessActivity::onExit() { - Activity::onExit(); - - shouldExit = true; - vTaskDelay(50 / portTICK_PERIOD_MS); - - if (tcpClient.connected()) { - tcpClient.stop(); - } - udp.stop(); - - vTaskDelay(250 / portTICK_PERIOD_MS); - - networkTaskHandle = nullptr; - displayTaskHandle = nullptr; - - WiFi.mode(WIFI_OFF); - - if (currentFile) { - currentFile.close(); - } - - recvBuffer.clear(); - recvBuffer.shrink_to_fit(); - skipExtractedLpath.clear(); - skipExtractedLpath.shrink_to_fit(); - - if (renderingMutex) { - vSemaphoreDelete(renderingMutex); - renderingMutex = nullptr; - } - - if (stateMutex) { - vSemaphoreDelete(stateMutex); - stateMutex = nullptr; - } -} - -void CalibreWirelessActivity::loop() { - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { - onCompleteCallback(); - return; - } -} - -void CalibreWirelessActivity::displayTaskLoop() { - while (!shouldExit) { - if (updateRequired) { - updateRequired = false; - xSemaphoreTake(renderingMutex, portMAX_DELAY); - if (!shouldExit) { - render(); - } - xSemaphoreGive(renderingMutex); - } - vTaskDelay(50 / portTICK_PERIOD_MS); - } - vTaskDelete(nullptr); -} - -void CalibreWirelessActivity::networkTaskLoop() { - while (!shouldExit) { - xSemaphoreTake(stateMutex, portMAX_DELAY); - const auto currentState = state; - xSemaphoreGive(stateMutex); - - if (shouldExit) break; - - 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: - vTaskDelay(100 / portTICK_PERIOD_MS); - break; - } - - vTaskDelay(10 / portTICK_PERIOD_MS); - } - vTaskDelete(nullptr); -} - -void CalibreWirelessActivity::listenForDiscovery() { - for (const uint16_t port : UDP_PORTS) { - udp.beginPacket("255.255.255.255", port); - udp.write(reinterpret_cast("hello"), 5); - udp.endPacket(); - } - - vTaskDelay(500 / portTICK_PERIOD_MS); - - 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'; - std::string response(buffer); - - 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) { - std::string portStr; - if (commaPos != std::string::npos && commaPos > semiPos) { - portStr = response.substr(semiPos + 1, commaPos - semiPos - 1); - uint16_t altPort = 0; - for (size_t i = commaPos + 1; i < response.size(); i++) { - char c = response[i]; - if (c >= '0' && c <= '9') { - altPort = altPort * 10 + (c - '0'); - } else { - break; - } - } - calibreAltPort = altPort; - } else { - portStr = response.substr(semiPos + 1); - } - - uint16_t mainPort = 0; - for (char c : portStr) { - if (c >= '0' && c <= '9') { - mainPort = mainPort * 10 + (c - '0'); - } else if (c != ' ' && c != '\t') { - break; - } - } - calibrePort = mainPort; - - if (onPos != std::string::npos && closePos != std::string::npos && closePos > onPos + 4) { - calibreHostname = response.substr(onPos + 4, closePos - onPos - 4); - } - } - - calibreHost = udp.remoteIP().toString().c_str(); - if (calibreHostname.empty()) { - calibreHostname = calibreHost; - } - - if (calibrePort > 0) { - setState(WirelessState::CONNECTING); - setStatus("Connecting to " + calibreHostname + "..."); - - vTaskDelay(100 / portTICK_PERIOD_MS); - - Serial.printf("[%lu] [CAL] Connecting to %s:%d\n", millis(), calibreHost.c_str(), calibrePort); - if (tcpClient.connect(calibreHost.c_str(), calibrePort, 5000)) { - Serial.printf("[%lu] [CAL] Connected!\n", millis()); - setState(WirelessState::WAITING); - setStatus("Connected to " + calibreHostname + "\nWaiting for commands..."); - } else if (calibreAltPort > 0 && tcpClient.connect(calibreHost.c_str(), calibreAltPort, 5000)) { - Serial.printf("[%lu] [CAL] Connected on alt port!\n", millis()); - setState(WirelessState::WAITING); - setStatus("Connected to " + calibreHostname + "\nWaiting for commands..."); - } else { - Serial.printf("[%lu] [CAL] Connection failed\n", millis()); - setState(WirelessState::DISCOVERING); - setStatus("Discovering Calibre...\n(Connection failed, retrying)"); - calibrePort = 0; - calibreAltPort = 0; - } - } - } - } -} - -void CalibreWirelessActivity::handleTcpClient() { - // In binary mode, keep reading even if connection closed - data may still be buffered - if (inBinaryMode) { - // Check if there's still data to read, even if connection is closing - if (tcpClient.available() > 0 || tcpClient.connected()) { - receiveBinaryData(); - return; - } - // Connection closed and no more data - check if transfer was complete - if (binaryBytesRemaining > 0) { - Serial.printf("[%lu] [CAL] Connection lost with %zu bytes remaining\n", millis(), binaryBytesRemaining); - currentFile.close(); - inBinaryMode = false; - setError("Transfer incomplete - connection lost"); - return; - } - } - - if (!tcpClient.connected()) { - setState(WirelessState::DISCONNECTED); - setStatus("Calibre disconnected"); - return; - } - - std::string message; - if (readJsonMessage(message)) { - size_t start = message.find('['); - if (start != std::string::npos) { - start++; - size_t end = message.find(',', start); - if (end != std::string::npos) { - int opcodeInt = 0; - for (size_t i = start; i < end; i++) { - char c = message[i]; - if (c >= '0' && c <= '9') { - opcodeInt = opcodeInt * 10 + (c - '0'); - } else if (c != ' ' && c != '\t') { - break; - } - } - - if (opcodeInt >= 0 && opcodeInt <= OpCode::ERROR) { - auto opcode = static_cast(opcodeInt); - 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) { - constexpr size_t MAX_BUFFERED_MSG_SIZE = 32768; - - // Handle skip mode for large messages - if (inSkipMode) { - while (skipBytesRemaining > 0 && tcpClient.available() > 0) { - uint8_t discardBuf[1024]; - size_t toRead = std::min({static_cast(tcpClient.available()), sizeof(discardBuf), skipBytesRemaining}); - int bytesRead = tcpClient.read(discardBuf, toRead); - if (bytesRead > 0) { - skipBytesRemaining -= bytesRead; - } else { - break; - } - } - - if (skipBytesRemaining == 0) { - inSkipMode = false; - if (skipOpcode == OpCode::SEND_BOOK && !skipExtractedLpath.empty() && skipExtractedLength > 0) { - message = "[" + std::to_string(skipOpcode) + ",{\"lpath\":\"" + skipExtractedLpath + - "\",\"length\":" + std::to_string(skipExtractedLength) + "}]"; - skipOpcode = -1; - skipExtractedLpath.clear(); - skipExtractedLength = 0; - return true; - } - if (skipOpcode >= 0) { - message = "[" + std::to_string(skipOpcode) + ",{}]"; - skipOpcode = -1; - return true; - } - } - return false; - } - - // Read available data into buffer - int available = tcpClient.available(); - if (available > 0) { - size_t maxBuffer = MAX_BUFFERED_MSG_SIZE + 20; - if (recvBuffer.size() < maxBuffer) { - char buf[1024]; - size_t spaceLeft = maxBuffer - recvBuffer.size(); - while (available > 0 && spaceLeft > 0) { - int toRead = std::min({available, static_cast(sizeof(buf)), static_cast(spaceLeft)}); - int bytesRead = tcpClient.read(reinterpret_cast(buf), toRead); - if (bytesRead > 0) { - recvBuffer.append(buf, bytesRead); - available -= bytesRead; - spaceLeft -= bytesRead; - } else { - break; - } - } - } - } - - if (recvBuffer.empty()) { - return false; - } - - size_t bracketPos = recvBuffer.find('['); - if (bracketPos == std::string::npos) { - if (recvBuffer.size() > 1000) { - recvBuffer.clear(); - } - return false; - } - - size_t msgLen = 0; - bool validPrefix = false; - - if (bracketPos > 0 && bracketPos <= 12) { - bool allDigits = true; - size_t parsedLen = 0; - for (size_t i = 0; i < bracketPos; i++) { - char c = recvBuffer[i]; - if (c >= '0' && c <= '9') { - parsedLen = parsedLen * 10 + (c - '0'); - } else { - allDigits = false; - break; - } - } - if (allDigits) { - msgLen = parsedLen; - validPrefix = true; - } - } - - if (!validPrefix) { - if (bracketPos > 0) { - recvBuffer = recvBuffer.substr(bracketPos); - } - return false; - } - - if (msgLen > 10000000) { - recvBuffer.clear(); - return false; - } - - // Handle large messages - if (msgLen > MAX_BUFFERED_MSG_SIZE) { - Serial.printf("[%lu] [CAL] Large message (%zu bytes), streaming\n", millis(), msgLen); - - int opcodeInt = -1; - size_t opcodeStart = bracketPos + 1; - size_t commaPos = recvBuffer.find(',', opcodeStart); - if (commaPos != std::string::npos) { - opcodeInt = 0; - for (size_t i = opcodeStart; i < commaPos; i++) { - char c = recvBuffer[i]; - if (c >= '0' && c <= '9') { - opcodeInt = opcodeInt * 10 + (c - '0'); - } else if (c != ' ' && c != '\t') { - break; - } - } - } - - skipOpcode = opcodeInt; - skipExtractedLpath.clear(); - skipExtractedLength = 0; - - if (opcodeInt == OpCode::SEND_BOOK) { - size_t lpathPos = recvBuffer.find("\"lpath\""); - if (lpathPos != std::string::npos) { - size_t colonPos = recvBuffer.find(':', lpathPos + 7); - if (colonPos != std::string::npos) { - size_t quoteStart = recvBuffer.find('"', colonPos + 1); - if (quoteStart != std::string::npos) { - size_t quoteEnd = recvBuffer.find('"', quoteStart + 1); - if (quoteEnd != std::string::npos) { - skipExtractedLpath = recvBuffer.substr(quoteStart + 1, quoteEnd - quoteStart - 1); - } - } - } - } - - int depth = 0; - const char* lengthKey = "\"length\""; - for (size_t i = bracketPos; i < recvBuffer.size() && i < bracketPos + 2000; i++) { - char c = recvBuffer[i]; - if (c == '{' || c == '[') - depth++; - else if (c == '}' || c == ']') - depth--; - else if (depth == 2 && c == '"' && i + 8 <= recvBuffer.size()) { - bool match = true; - for (size_t j = 0; j < 8 && match; j++) { - if (recvBuffer[i + j] != lengthKey[j]) match = false; - } - if (match) { - size_t numStart = i + 8; - while (numStart < recvBuffer.size() && (recvBuffer[numStart] == ':' || recvBuffer[numStart] == ' ')) { - numStart++; - } - while (numStart < recvBuffer.size() && recvBuffer[numStart] >= '0' && recvBuffer[numStart] <= '9') { - skipExtractedLength = skipExtractedLength * 10 + (recvBuffer[numStart] - '0'); - numStart++; - } - break; - } - } - } - } - - size_t totalMsgBytes = bracketPos + msgLen; - if (recvBuffer.size() >= totalMsgBytes) { - recvBuffer = recvBuffer.substr(totalMsgBytes); - if (skipOpcode == OpCode::SEND_BOOK && !skipExtractedLpath.empty() && skipExtractedLength > 0) { - message = "[" + std::to_string(skipOpcode) + ",{\"lpath\":\"" + skipExtractedLpath + - "\",\"length\":" + std::to_string(skipExtractedLength) + "}]"; - skipOpcode = -1; - skipExtractedLpath.clear(); - skipExtractedLength = 0; - return true; - } - if (skipOpcode >= 0) { - message = "[" + std::to_string(skipOpcode) + ",{}]"; - skipOpcode = -1; - return true; - } - } else { - skipBytesRemaining = totalMsgBytes - recvBuffer.size(); - recvBuffer.clear(); - inSkipMode = true; - } - return false; - } - - size_t totalNeeded = bracketPos + msgLen; - if (recvBuffer.size() < totalNeeded) { - return false; - } - - message = recvBuffer.substr(bracketPos, msgLen); - recvBuffer = recvBuffer.size() > totalNeeded ? recvBuffer.substr(totalNeeded) : ""; - - return true; -} - -void CalibreWirelessActivity::sendJsonResponse(const OpCode opcode, const std::string& data) { - std::string json = "[" + std::to_string(opcode) + "," + data + "]"; - std::string msg = std::to_string(json.length()) + json; - tcpClient.write(reinterpret_cast(msg.c_str()), msg.length()); - tcpClient.flush(); -} - -void CalibreWirelessActivity::handleCommand(const OpCode opcode, const std::string& data) { - Serial.printf("[%lu] [CAL] Command: %d, data size: %zu\n", millis(), opcode, data.size()); - - switch (opcode) { - case OpCode::GET_INITIALIZATION_INFO: - handleGetInitializationInfo(data); - break; - case OpCode::GET_DEVICE_INFORMATION: - handleGetDeviceInformation(); - break; - case OpCode::FREE_SPACE: - case OpCode::TOTAL_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: - case OpCode::SET_LIBRARY_INFO: - case OpCode::SEND_BOOKLISTS: - sendJsonResponse(OpCode::OK, "{}"); - break; - default: - sendJsonResponse(OpCode::OK, "{}"); - break; - } -} - -void CalibreWirelessActivity::receiveBinaryData() { - // KOReader-style: read all available data, write only what we need to file, - // put excess (next JSON message) back into buffer. - - int available = tcpClient.available(); - if (available <= 0) { - // Wait longer for data - TCP buffers may not be immediately available - // especially near end of transfer when connection is closing - vTaskDelay(10 / portTICK_PERIOD_MS); - return; - } - - uint8_t buffer[4096]; - int bytesRead = tcpClient.read(buffer, std::min(sizeof(buffer), static_cast(available))); - - if (bytesRead <= 0) { - return; - } - - // Write only what we need (like KOReader's data:sub(1, to_write_bytes)) - size_t toWrite = std::min(static_cast(bytesRead), binaryBytesRemaining); - - if (toWrite > 0) { - currentFile.write(buffer, toWrite); - bytesReceived += toWrite; - binaryBytesRemaining -= toWrite; - updateRequired = true; - } - - // If we read more than needed, it's the next JSON message (like KOReader's buffer handling) - if (static_cast(bytesRead) > toWrite) { - size_t excess = bytesRead - toWrite; - recvBuffer.assign(reinterpret_cast(buffer + toWrite), excess); - Serial.printf("[%lu] [CAL] Binary done, %zu excess bytes -> buffer\n", millis(), excess); - } - - // Progress logging - static unsigned long lastLog = 0; - unsigned long now = millis(); - if (now - lastLog > 500) { - Serial.printf("[%lu] [CAL] Binary: %zu/%zu (%.1f%%)\n", now, bytesReceived, currentFileSize, - currentFileSize > 0 ? (100.0 * bytesReceived / currentFileSize) : 0.0); - lastLog = now; - } - - // Check completion - if (binaryBytesRemaining == 0) { - currentFile.flush(); - currentFile.close(); - inBinaryMode = false; - - Serial.printf("[%lu] [CAL] File complete: %zu bytes\n", millis(), bytesReceived); - setState(WirelessState::WAITING); - setStatus("Received: " + currentFilename + "\nWaiting for more..."); - } -} - -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."); - - 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,"; - response += "\"ccVersionNumber\":212,"; - response += "\"coverHeight\":0,"; - response += "\"deviceKind\":\"CrossPoint\","; - response += "\"deviceName\":\"CrossPoint\","; - response += "\"extensionPathLengths\":{\"epub\":37},"; - response += "\"maxBookContentPacketLen\":1024,"; - 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() { - sendJsonResponse(OpCode::OK, "{\"free_space_on_device\":10737418240}"); -} - -void CalibreWirelessActivity::handleGetBookCount() { - sendJsonResponse(OpCode::OK, "{\"count\":0,\"willStream\":true,\"willScan\":false}"); -} - -void CalibreWirelessActivity::handleSendBook(const std::string& data) { - Serial.printf("[%lu] [CAL] SEND_BOOK (first 500): %.500s\n", millis(), data.c_str()); - - // Extract lpath - 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 - size_t length = 0; - int depth = 0; - const char* lengthKey = "\"length\""; - - 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 == '"' && i + 8 <= data.size()) { - bool match = true; - for (size_t j = 0; j < 8 && match; j++) { - if (data[i + j] != lengthKey[j]) match = false; - } - if (match) { - size_t colonPos = i + 8; - while (colonPos < data.size() && data[colonPos] != ':') colonPos++; - if (colonPos < data.size()) { - size_t numStart = colonPos + 1; - while (numStart < data.size() && (data[numStart] == ' ' || data[numStart] == '\t')) numStart++; - while (numStart < data.size() && data[numStart] >= '0' && data[numStart] <= '9') { - length = length * 10 + (data[numStart] - '0'); - numStart++; - } - if (length > 0) { - Serial.printf("[%lu] [CAL] Extracted length=%zu\n", millis(), length); - break; - } - } - } - } - } - - if (lpath.empty() || length == 0) { - sendJsonResponse(OpCode::ERROR, "{\"message\":\"Invalid book data\"}"); - return; - } - - std::string filename = lpath; - size_t lastSlash = filename.rfind('/'); - if (lastSlash != std::string::npos) { - filename = filename.substr(lastSlash + 1); - } - - currentFilename = "/" + StringUtils::sanitizeFilename(filename); - if (!StringUtils::checkFileExtension(currentFilename, ".epub")) { - currentFilename += ".epub"; - } - currentFileSize = length; - bytesReceived = 0; - binaryBytesRemaining = length; - - Serial.printf("[%lu] [CAL] File: %s, size: %zu, buffer: %zu\n", millis(), currentFilename.c_str(), length, - recvBuffer.size()); - - setState(WirelessState::RECEIVING); - setStatus("Receiving: " + filename); - - if (!SdMan.openFileForWrite("CAL", currentFilename.c_str(), currentFile)) { - setError("Failed to create file"); - sendJsonResponse(OpCode::ERROR, "{\"message\":\"Failed to create file\"}"); - return; - } - - // Send OK - Calibre will start sending binary - sendJsonResponse(OpCode::OK, "{}"); - - // Switch to binary mode - inBinaryMode = true; - - // Process any data already in buffer (like KOReader) - if (!recvBuffer.empty()) { - size_t toWrite = std::min(recvBuffer.size(), binaryBytesRemaining); - Serial.printf("[%lu] [CAL] Writing %zu from buffer\n", millis(), toWrite); - currentFile.write(reinterpret_cast(recvBuffer.data()), toWrite); - bytesReceived += toWrite; - binaryBytesRemaining -= toWrite; - - if (recvBuffer.size() > toWrite) { - recvBuffer = recvBuffer.substr(toWrite); - } else { - recvBuffer.clear(); - } - updateRequired = true; - - if (binaryBytesRemaining == 0) { - currentFile.flush(); - currentFile.close(); - inBinaryMode = false; - Serial.printf("[%lu] [CAL] File complete: %zu bytes\n", millis(), bytesReceived); - setState(WirelessState::WAITING); - setStatus("Received: " + currentFilename + "\nWaiting for more..."); - } - } -} - -void CalibreWirelessActivity::handleSendBookMetadata(const std::string& data) { - Serial.printf("[%lu] [CAL] SEND_BOOK_METADATA\n", millis()); - sendJsonResponse(OpCode::OK, "{}"); -} - -void CalibreWirelessActivity::handleDisplayMessage(const std::string& data) { - if (data.find("\"messageKind\":1") != std::string::npos) { - setError("Password required"); - } - sendJsonResponse(OpCode::OK, "{}"); -} - -void CalibreWirelessActivity::handleNoop(const std::string& data) { - if (data.find("\"ejecting\":true") != std::string::npos) { - setState(WirelessState::DISCONNECTED); - setStatus("Calibre disconnected"); - } - sendJsonResponse(OpCode::NOOP, "{}"); -} - -void CalibreWirelessActivity::render() const { - renderer.clearScreen(); - - const auto pageWidth = renderer.getScreenWidth(); - const auto pageHeight = renderer.getScreenHeight(); - - renderer.drawCenteredText(UI_12_FONT_ID, 30, "Calibre Wireless", true, EpdFontFamily::BOLD); - - const std::string ipAddr = WiFi.localIP().toString().c_str(); - renderer.drawCenteredText(UI_10_FONT_ID, 60, ("IP: " + ipAddr).c_str()); - - int statusY = pageHeight / 2 - 40; - 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; - } - - 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); - } - - if (!errorMessage.empty()) { - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight - 120, errorMessage.c_str()); - } - - 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 { - 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 4af89525..00000000 --- a/src/activities/network/CalibreWirelessActivity.h +++ /dev/null @@ -1,117 +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 - */ -class CalibreWirelessActivity final : public Activity { - enum class WirelessState { DISCOVERING, CONNECTING, WAITING, RECEIVING, COMPLETE, DISCONNECTED, ERROR }; - - 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, - 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; - volatile bool shouldExit = false; - - WirelessState state = WirelessState::DISCOVERING; - const std::function onCompleteCallback; - - WiFiUDP udp; - WiFiClient tcpClient; - std::string calibreHost; - uint16_t calibrePort = 0; - uint16_t calibreAltPort = 0; - std::string calibreHostname; - - std::string currentFilename; - size_t currentFileSize = 0; - size_t bytesReceived = 0; - std::string statusMessage; - std::string errorMessage; - - bool inBinaryMode = false; - size_t binaryBytesRemaining = 0; - FsFile currentFile; - std::string recvBuffer; - - bool inSkipMode = false; - size_t skipBytesRemaining = 0; - int skipOpcode = -1; - std::string skipExtractedLpath; - size_t skipExtractedLength = 0; - - static void displayTaskTrampoline(void* param); - static void networkTaskTrampoline(void* param); - void displayTaskLoop(); - void networkTaskLoop(); - void render() const; - - 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(); - - 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); - - 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), onCompleteCallback(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 35ad58ba..4d78739c 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()); @@ -179,6 +197,7 @@ void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) } } + void CrossPointWebServerActivity::startAccessPoint() { Serial.printf("[%lu] [WEBACT] Starting Access Point mode...\n", millis()); Serial.printf("[%lu] [WEBACT] [MEM] Free heap before AP start: %d bytes\n", millis(), ESP.getFreeHeap()); diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index 775a2474..a1189a57 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 ad05f5b8..50767084 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 b9f2e1ee..1b93b825 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 4f614ffc..055b9cd1 100644 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ b/src/activities/settings/CalibreSettingsActivity.cpp @@ -1,20 +1,16 @@ #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 +76,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 +94,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 + true, // 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 +152,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 77b9218c..49695c62 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/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index efa0b9e1..a04038d3 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -41,7 +41,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("OPDS Browser"), SettingInfo::Action("Check for updates")}; } // namespace @@ -139,7 +139,7 @@ void SettingsActivity::toggleCurrentSetting() { SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; } } else if (setting.type == SettingType::ACTION) { - if (strcmp(setting.name, "Calibre Settings") == 0) { + if (strcmp(setting.name, "OPDS Browser") == 0) { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 1a5fd972..b4796d97 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -30,6 +30,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; } // namespace // File listing page template - now using generated headers: @@ -223,6 +226,18 @@ void CrossPointWebServer::handleClient() { } } +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 { server->send(200, "text/html", HomePageHtml); Serial.printf("[%lu] [WEB] Served root page\n", millis()); @@ -813,6 +828,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 f985c8c3..cff3e05f 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -4,6 +4,8 @@ #include #include +#include +#include #include // Structure to hold file information @@ -16,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(); @@ -31,6 +43,8 @@ class CrossPointWebServer { // Check if server is running bool isRunning() const { return running; } + WsUploadStatus getWsUploadStatus() const; + // Get the port number uint16_t getPort() const { return port; } diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index c4de3a05..d05eeda3 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -5,8 +5,10 @@ #include #include +#include #include +#include "CrossPointSettings.h" #include "util/UrlUtils.h" bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { @@ -27,6 +29,11 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& 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) { + http.setAuthorization(SETTINGS.opdsUsername, SETTINGS.opdsPassword); + } + const int httpCode = http.GET(); if (httpCode != HTTP_CODE_OK) { Serial.printf("[%lu] [HTTP] Fetch failed: %d\n", millis(), httpCode); @@ -61,6 +68,11 @@ 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) { + http.setAuthorization(SETTINGS.opdsUsername, SETTINGS.opdsPassword); + } + const int httpCode = http.GET(); if (httpCode != HTTP_CODE_OK) { Serial.printf("[%lu] [HTTP] Download failed: %d\n", millis(), httpCode);