From 0fa57a6bb427e39f47e30a4b0d83462bdf9baf39 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 14 Jan 2026 13:13:57 -0500 Subject: [PATCH 01/13] fixes crash on connection --- .../network/CalibreWirelessActivity.cpp | 450 +++++++++++++----- .../network/CalibreWirelessActivity.h | 7 + 2 files changed, 345 insertions(+), 112 deletions(-) diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp index 0ad9094a..25220a71 100644 --- a/src/activities/network/CalibreWirelessActivity.cpp +++ b/src/activities/network/CalibreWirelessActivity.cpp @@ -45,6 +45,11 @@ void CalibreWirelessActivity::onEnter() { bytesReceived = 0; inBinaryMode = false; recvBuffer.clear(); + inSkipMode = false; + skipBytesRemaining = 0; + skipOpcode = -1; + skipExtractedLpath.clear(); + skipExtractedLength = 0; updateRequired = true; @@ -61,6 +66,20 @@ void CalibreWirelessActivity::onEnter() { void CalibreWirelessActivity::onExit() { Activity::onExit(); + // Stop network task FIRST before touching any shared state + // This prevents the task from accessing members while we clean up + if (networkTaskHandle) { + vTaskDelete(networkTaskHandle); + networkTaskHandle = nullptr; + } + + // Stop display task + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + + // Now safe to clean up - tasks are stopped // Turn off WiFi when exiting WiFi.mode(WIFI_OFF); @@ -77,25 +96,22 @@ void CalibreWirelessActivity::onExit() { currentFile.close(); } - // Acquire stateMutex before deleting network task to avoid race condition - xSemaphoreTake(stateMutex, portMAX_DELAY); - if (networkTaskHandle) { - vTaskDelete(networkTaskHandle); - networkTaskHandle = nullptr; - } - xSemaphoreGive(stateMutex); + // Clear string buffers to free memory + recvBuffer.clear(); + recvBuffer.shrink_to_fit(); + skipExtractedLpath.clear(); + skipExtractedLpath.shrink_to_fit(); - // Acquire renderingMutex before deleting display task - xSemaphoreTake(renderingMutex, portMAX_DELAY); - if (displayTaskHandle) { - vTaskDelete(displayTaskHandle); - displayTaskHandle = nullptr; + // Delete mutexes last + if (renderingMutex) { + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; } - vSemaphoreDelete(renderingMutex); - renderingMutex = nullptr; - vSemaphoreDelete(stateMutex); - stateMutex = nullptr; + if (stateMutex) { + vSemaphoreDelete(stateMutex); + stateMutex = nullptr; + } } void CalibreWirelessActivity::loop() { @@ -182,28 +198,32 @@ void CalibreWirelessActivity::listenForDiscovery() { 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))); + // Get alternative port after comma - parse safely + 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); } - // 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)); + // Parse main port safely + uint16_t mainPort = 0; + for (size_t i = 0; i < portStr.size(); i++) { + char c = portStr[i]; + if (c >= '0' && c <= '9') { + mainPort = mainPort * 10 + (c - '0'); + } else if (c != ' ' && c != '\t') { + break; + } } + calibrePort = mainPort; // Get hostname if present, otherwise use sender IP if (onPos != std::string::npos && closePos != std::string::npos && closePos > onPos + 4) { @@ -276,7 +296,16 @@ void CalibreWirelessActivity::handleTcpClient() { start++; size_t end = message.find(',', start); if (end != std::string::npos) { - const int opcodeInt = std::stoi(message.substr(start, end - start)); + // Parse opcode safely without exceptions + 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; // Invalid character + } + } if (opcodeInt < 0 || opcodeInt >= OpCode::ERROR) { Serial.printf("[%lu] [CAL] Invalid opcode: %d\n", millis(), opcodeInt); sendJsonResponse(OpCode::OK, "{}"); @@ -288,8 +317,11 @@ void CalibreWirelessActivity::handleTcpClient() { 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); + if (dataEnd != std::string::npos && dataEnd > dataStart && dataStart < message.size()) { + size_t len = dataEnd - dataStart; + if (dataStart + len <= message.size()) { + data = message.substr(dataStart, len); + } } handleCommand(opcode, data); @@ -299,26 +331,71 @@ void CalibreWirelessActivity::handleTcpClient() { } 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); + // Maximum message size we'll buffer in memory + // Messages larger than this (typically due to base64 covers) are streamed through + constexpr size_t MAX_BUFFERED_MSG_SIZE = 32768; + + // If in skip mode, consume bytes until we've skipped the full message + if (inSkipMode) { + while (skipBytesRemaining > 0) { + int available = tcpClient.available(); + if (available <= 0) { + return false; // Need more data + } + + // Read and discard in chunks + uint8_t discardBuf[1024]; + size_t toRead = std::min({static_cast(available), sizeof(discardBuf), skipBytesRemaining}); + int bytesRead = tcpClient.read(discardBuf, toRead); if (bytesRead > 0) { - recvBuffer.append(buf, bytesRead); - available -= bytesRead; + skipBytesRemaining -= bytesRead; } else { break; } } + + if (skipBytesRemaining == 0) { + // Skip complete - if this was SEND_BOOK, construct minimal message + inSkipMode = false; + if (skipOpcode == OpCode::SEND_BOOK && !skipExtractedLpath.empty() && skipExtractedLength > 0) { + // Build minimal JSON that handleSendBook can parse + message = "[" + std::to_string(skipOpcode) + ",{\"lpath\":\"" + skipExtractedLpath + + "\",\"length\":" + std::to_string(skipExtractedLength) + "}]"; + skipOpcode = -1; + skipExtractedLpath.clear(); + skipExtractedLength = 0; + return true; + } + // For other opcodes, just acknowledge + if (skipOpcode >= 0) { + message = "[" + std::to_string(skipOpcode) + ",{}]"; + skipOpcode = -1; + return true; + } + } + return false; + } + + // Read available data into buffer (limited to prevent memory issues) + int available = tcpClient.available(); + if (available > 0) { + // Only buffer up to a reasonable amount while looking for length prefix + size_t maxBuffer = MAX_BUFFERED_MSG_SIZE + 20; // +20 for length prefix digits + 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()) { @@ -328,61 +405,174 @@ bool CalibreWirelessActivity::readJsonMessage(std::string& message) { // 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 + // Parse length prefix (digits before '[') size_t msgLen = 0; bool validPrefix = false; if (bracketPos > 0 && bracketPos <= 12) { - // Check if prefix is all digits bool allDigits = true; + size_t parsedLen = 0; for (size_t i = 0; i < bracketPos; i++) { char c = recvBuffer[i]; - if (c < '0' || c > '9') { + if (c >= '0' && c <= '9') { + parsedLen = parsedLen * 10 + (c - '0'); + } else { allDigits = false; break; } } if (allDigits) { - msgLen = std::stoul(recvBuffer.substr(0, bracketPos)); + msgLen = parsedLen; validPrefix = true; } } if (!validPrefix) { - // Not a valid length prefix - discard everything up to '[' and treat '[' as start - if (bracketPos > 0) { + if (bracketPos > 0 && bracketPos < recvBuffer.size()) { 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 + // Sanity check - reject absurdly large messages + if (msgLen > 10000000) { + Serial.printf("[%lu] [CAL] Rejecting message with length %zu (too large)\n", millis(), msgLen); + recvBuffer.clear(); return false; } - // Check if we have the complete message + // For large messages, extract essential fields then skip the rest + if (msgLen > MAX_BUFFERED_MSG_SIZE) { + Serial.printf("[%lu] [CAL] Large message detected (%zu bytes), streaming\n", millis(), msgLen); + + // We need to extract: opcode, and for SEND_BOOK: lpath and length + // These fields appear early in the JSON before the large cover data + + // Parse opcode from what we have buffered + int opcodeInt = -1; + size_t opcodeStart = bracketPos + 1; + size_t commaPos = recvBuffer.find(',', opcodeStart); + if (commaPos != std::string::npos && commaPos < recvBuffer.size()) { + 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; + + // For SEND_BOOK, try to extract lpath and length from buffered data + if (opcodeInt == OpCode::SEND_BOOK) { + // Extract lpath + size_t lpathPos = recvBuffer.find("\"lpath\""); + if (lpathPos != std::string::npos && lpathPos + 7 < recvBuffer.size()) { + size_t colonPos = recvBuffer.find(':', lpathPos + 7); + if (colonPos != std::string::npos && colonPos + 1 < recvBuffer.size()) { + size_t quoteStart = recvBuffer.find('"', colonPos + 1); + if (quoteStart != std::string::npos && quoteStart + 1 < recvBuffer.size()) { + size_t quoteEnd = recvBuffer.find('"', quoteStart + 1); + if (quoteEnd != std::string::npos && quoteEnd > quoteStart + 1) { + skipExtractedLpath = recvBuffer.substr(quoteStart + 1, quoteEnd - quoteStart - 1); + } + } + } + } + + // Extract top-level length (track depth to skip nested length fields in cover metadata) + // Message format is [opcode, {data}], so depth 2 = top level of data object + int depth = 0; + const char* lengthKey = "\"length\""; + const size_t keyLen = 8; + 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 + keyLen <= recvBuffer.size()) { + bool match = true; + for (size_t j = 0; j < keyLen && match; j++) { + if (recvBuffer[i + j] != lengthKey[j]) match = false; + } + if (match) { + size_t numStart = i + keyLen; + 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; + } + } + } + } + + // Calculate how many bytes we still need to skip + size_t totalMsgBytes = bracketPos + msgLen; + size_t alreadyBuffered = recvBuffer.size(); + if (alreadyBuffered >= totalMsgBytes) { + // Entire message is already buffered - just discard it + recvBuffer = recvBuffer.substr(totalMsgBytes); + skipBytesRemaining = 0; + } else { + // Need to skip remaining bytes from network + skipBytesRemaining = totalMsgBytes - alreadyBuffered; + recvBuffer.clear(); + } + + inSkipMode = true; + + // If skip is already complete, return immediately + 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; + } + return false; // Continue skipping in next iteration + } + + // Normal path for small messages size_t totalNeeded = bracketPos + msgLen; if (recvBuffer.size() < totalNeeded) { - // Not enough data yet - wait for more - return false; + return false; // Wait for more data } // Extract the message - message = recvBuffer.substr(bracketPos, msgLen); + if (bracketPos < recvBuffer.size() && bracketPos + msgLen <= recvBuffer.size()) { + message = recvBuffer.substr(bracketPos, msgLen); + } else { + recvBuffer.clear(); + return false; + } - // Keep the rest in buffer (may contain binary data or next message) + // Keep remainder in buffer if (recvBuffer.size() > totalNeeded) { recvBuffer = recvBuffer.substr(totalNeeded); } else { @@ -403,6 +593,8 @@ void CalibreWirelessActivity::sendJsonResponse(const OpCode opcode, const std::s } void CalibreWirelessActivity::handleCommand(const OpCode opcode, const std::string& data) { + Serial.printf("[%lu] [CAL] Received opcode: %d, data size: %zu\n", millis(), opcode, data.size()); + switch (opcode) { case OpCode::GET_INITIALIZATION_INFO: handleGetInitializationInfo(data); @@ -475,8 +667,9 @@ void CalibreWirelessActivity::handleGetInitializationInfo(const std::string& dat // 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,"; + // coverHeight: Max cover image height. Set to 0 to prevent Calibre from embedding + // large base64-encoded covers in SEND_BOOK metadata, which would bloat the JSON. + response += "\"coverHeight\":0,"; response += "\"deviceKind\":\"CrossPoint\","; response += "\"deviceName\":\"CrossPoint\","; response += "\"extensionPathLengths\":{\"epub\":37},"; @@ -522,14 +715,19 @@ void CalibreWirelessActivity::handleSendBook(const std::string& data) { // Extract "lpath" field - format: "lpath": "value" std::string lpath; size_t lpathPos = data.find("\"lpath\""); - if (lpathPos != std::string::npos) { + if (lpathPos != std::string::npos && lpathPos + 7 < data.size()) { size_t colonPos = data.find(':', lpathPos + 7); - if (colonPos != std::string::npos) { + if (colonPos != std::string::npos && colonPos + 1 < data.size()) { size_t quoteStart = data.find('"', colonPos + 1); - if (quoteStart != std::string::npos) { + if (quoteStart != std::string::npos && quoteStart + 1 < data.size()) { size_t quoteEnd = data.find('"', quoteStart + 1); - if (quoteEnd != std::string::npos) { - lpath = data.substr(quoteStart + 1, quoteEnd - quoteStart - 1); + if (quoteEnd != std::string::npos && quoteEnd > quoteStart + 1) { + // Safe bounds check before substr + size_t start = quoteStart + 1; + size_t len = quoteEnd - quoteStart - 1; + if (start < data.size() && start + len <= data.size()) { + lpath = data.substr(start, len); + } } } } @@ -539,6 +737,9 @@ void CalibreWirelessActivity::handleSendBook(const std::string& data) { // The metadata contains nested "length" fields (e.g., cover image length) size_t length = 0; int depth = 0; + const char* lengthKey = "\"length\""; + const size_t keyLen = 8; + for (size_t i = 0; i < data.size(); i++) { char c = data[i]; if (c == '{' || c == '[') { @@ -546,22 +747,35 @@ void CalibreWirelessActivity::handleSendBook(const std::string& data) { } 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++; + // At top level, check if this is "length" by comparing directly + if (i + keyLen <= data.size()) { + bool match = true; + for (size_t j = 0; j < keyLen && match; j++) { + if (data[i + j] != lengthKey[j]) { + match = false; } - size_t numEnd = numStart; - while (numEnd < data.size() && data[numEnd] >= '0' && data[numEnd] <= '9') { - numEnd++; + } + if (match) { + // Found top-level "length" - extract the number after ':' + size_t colonPos = i + keyLen; + while (colonPos < data.size() && data[colonPos] != ':') { + colonPos++; } - if (numEnd > numStart) { - length = std::stoul(data.substr(numStart, numEnd - numStart)); - break; + if (colonPos < data.size()) { + size_t numStart = colonPos + 1; + while (numStart < data.size() && (data[numStart] == ' ' || data[numStart] == '\t')) { + numStart++; + } + // Parse number safely without exceptions + size_t parsedLen = 0; + while (numStart < data.size() && data[numStart] >= '0' && data[numStart] <= '9') { + parsedLen = parsedLen * 10 + (data[numStart] - '0'); + numStart++; + } + if (parsedLen > 0) { + length = parsedLen; + break; + } } } } @@ -588,6 +802,8 @@ void CalibreWirelessActivity::handleSendBook(const std::string& data) { currentFileSize = length; bytesReceived = 0; + Serial.printf("[%lu] [CAL] SEND_BOOK: lpath='%s', length=%zu\n", millis(), lpath.c_str(), length); + setState(WirelessState::RECEIVING); setStatus("Receiving: " + filename); @@ -640,39 +856,49 @@ void CalibreWirelessActivity::handleNoop(const std::string& data) { } 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"); + // Read all available data in a loop to prevent TCP backpressure + // This is important because Calibre sends data continuously + while (binaryBytesRemaining > 0) { + const int available = tcpClient.available(); + if (available == 0) { + // Check if connection is still alive + if (!tcpClient.connected()) { + Serial.printf("[%lu] [CAL] Connection lost during binary transfer. Received %zu/%zu bytes\n", + millis(), bytesReceived, currentFileSize); + currentFile.close(); + inBinaryMode = false; + setError("Transfer interrupted"); + } + return; // No data available right now, will continue next iteration } - return; - } - uint8_t buffer[1024]; - const size_t toRead = std::min(sizeof(buffer), binaryBytesRemaining); - const size_t bytesRead = tcpClient.read(buffer, toRead); + uint8_t buffer[4096]; // Larger buffer for faster transfer + const size_t toRead = std::min({sizeof(buffer), binaryBytesRemaining, static_cast(available)}); + const size_t bytesRead = tcpClient.read(buffer, toRead); + + if (bytesRead == 0) { + break; // No more data to read right now + } - if (bytesRead > 0) { currentFile.write(buffer, bytesRead); bytesReceived += bytesRead; binaryBytesRemaining -= bytesRead; updateRequired = true; + } - if (binaryBytesRemaining == 0) { - // Transfer complete - currentFile.flush(); - currentFile.close(); - inBinaryMode = false; + if (binaryBytesRemaining == 0) { + // Transfer complete - switch back to JSON mode + // Note: Do NOT send OK here. KOReader doesn't, and sending an extra OK + // could be misinterpreted as a response to SEND_BOOK_METADATA before + // we've received it, causing protocol desync. + currentFile.flush(); + currentFile.close(); + inBinaryMode = false; - setState(WirelessState::WAITING); - setStatus("Received: " + currentFilename + "\nWaiting for more..."); + Serial.printf("[%lu] [CAL] Binary transfer complete: %zu bytes received\n", millis(), bytesReceived); - // Send OK to acknowledge completion - sendJsonResponse(OpCode::OK, "{}"); - } + setState(WirelessState::WAITING); + setStatus("Received: " + currentFilename + "\nWaiting for more..."); } } diff --git a/src/activities/network/CalibreWirelessActivity.h b/src/activities/network/CalibreWirelessActivity.h index ae2b1767..28ba2d0d 100644 --- a/src/activities/network/CalibreWirelessActivity.h +++ b/src/activities/network/CalibreWirelessActivity.h @@ -93,6 +93,13 @@ class CalibreWirelessActivity final : public Activity { FsFile currentFile; std::string recvBuffer; // Buffer for incoming data (like KOReader) + // Large message skip state - for streaming past oversized JSON (e.g., large covers) + bool inSkipMode = false; + size_t skipBytesRemaining = 0; + int skipOpcode = -1; // Opcode of message being skipped + std::string skipExtractedLpath; + size_t skipExtractedLength = 0; + static void displayTaskTrampoline(void* param); static void networkTaskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); From 0313259998a0eb7f26cfe0b4565c0bec383281d6 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 14 Jan 2026 13:25:33 -0500 Subject: [PATCH 02/13] fixes crash on back --- .../network/CalibreWirelessActivity.cpp | 72 ++++++++++++------- .../network/CalibreWirelessActivity.h | 1 + 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp index 25220a71..79934465 100644 --- a/src/activities/network/CalibreWirelessActivity.cpp +++ b/src/activities/network/CalibreWirelessActivity.cpp @@ -50,6 +50,7 @@ void CalibreWirelessActivity::onEnter() { skipOpcode = -1; skipExtractedLpath.clear(); skipExtractedLength = 0; + shouldExit = false; updateRequired = true; @@ -66,30 +67,27 @@ void CalibreWirelessActivity::onEnter() { void CalibreWirelessActivity::onExit() { Activity::onExit(); - // Stop network task FIRST before touching any shared state - // This prevents the task from accessing members while we clean up - if (networkTaskHandle) { - vTaskDelete(networkTaskHandle); - networkTaskHandle = nullptr; - } + // Signal tasks to exit gracefully FIRST + shouldExit = true; - // Stop display task - if (displayTaskHandle) { - vTaskDelete(displayTaskHandle); - displayTaskHandle = nullptr; - } + // Small delay to let tasks see the flag + vTaskDelay(50 / portTICK_PERIOD_MS); - // Now safe to clean up - tasks are stopped - // Turn off WiFi when exiting - WiFi.mode(WIFI_OFF); - - // Stop UDP listening - udp.stop(); - - // Close TCP client if connected + // Close TCP to unblock any pending reads in the network task if (tcpClient.connected()) { tcpClient.stop(); } + udp.stop(); + + // Give tasks more time to notice the closed connection and exit + vTaskDelay(250 / portTICK_PERIOD_MS); + + // Clear task handles (tasks self-deleted) + networkTaskHandle = nullptr; + displayTaskHandle = nullptr; + + // Turn off WiFi when exiting + WiFi.mode(WIFI_OFF); // Close any open file if (currentFile) { @@ -122,23 +120,28 @@ void CalibreWirelessActivity::loop() { } void CalibreWirelessActivity::displayTaskLoop() { - while (true) { + while (!shouldExit) { if (updateRequired) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); - render(); + if (!shouldExit) { // Double-check after acquiring mutex + render(); + } xSemaphoreGive(renderingMutex); } vTaskDelay(50 / portTICK_PERIOD_MS); } + vTaskDelete(nullptr); // Self-delete when done } void CalibreWirelessActivity::networkTaskLoop() { - while (true) { + while (!shouldExit) { xSemaphoreTake(stateMutex, portMAX_DELAY); const auto currentState = state; xSemaphoreGive(stateMutex); + if (shouldExit) break; + switch (currentState) { case WirelessState::DISCOVERING: listenForDiscovery(); @@ -160,6 +163,7 @@ void CalibreWirelessActivity::networkTaskLoop() { vTaskDelay(10 / portTICK_PERIOD_MS); } + vTaskDelete(nullptr); // Self-delete when done } void CalibreWirelessActivity::listenForDiscovery() { @@ -802,7 +806,8 @@ void CalibreWirelessActivity::handleSendBook(const std::string& data) { currentFileSize = length; bytesReceived = 0; - Serial.printf("[%lu] [CAL] SEND_BOOK: lpath='%s', length=%zu\n", millis(), lpath.c_str(), length); + Serial.printf("[%lu] [CAL] SEND_BOOK: lpath='%s', length=%zu, recvBuffer leftover=%zu\n", + millis(), lpath.c_str(), length, recvBuffer.size()); setState(WirelessState::RECEIVING); setStatus("Receiving: " + filename); @@ -824,11 +829,18 @@ void CalibreWirelessActivity::handleSendBook(const std::string& data) { // 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); + Serial.printf("[%lu] [CAL] Writing %zu bytes from recvBuffer (had %zu bytes)\n", + millis(), toWrite, recvBuffer.size()); size_t written = currentFile.write(reinterpret_cast(recvBuffer.data()), toWrite); + if (written != toWrite) { + Serial.printf("[%lu] [CAL] WARNING: file.write returned %zu, expected %zu\n", millis(), written, toWrite); + } bytesReceived += written; binaryBytesRemaining -= written; - recvBuffer = recvBuffer.substr(toWrite); + recvBuffer = recvBuffer.substr(written); // Use written, not toWrite! updateRequired = true; + Serial.printf("[%lu] [CAL] After recvBuffer write: received=%zu, remaining=%zu, recvBuffer=%zu\n", + millis(), bytesReceived, binaryBytesRemaining, recvBuffer.size()); } } @@ -856,11 +868,23 @@ void CalibreWirelessActivity::handleNoop(const std::string& data) { } void CalibreWirelessActivity::receiveBinaryData() { + static unsigned long lastProgressLog = 0; + // Read all available data in a loop to prevent TCP backpressure // This is important because Calibre sends data continuously while (binaryBytesRemaining > 0) { + if (shouldExit) return; + const int available = tcpClient.available(); if (available == 0) { + // Log progress periodically when waiting for data + if (millis() - lastProgressLog > 2000) { + Serial.printf("[%lu] [CAL] Binary transfer waiting: %zu/%zu bytes (%.1f%%), remaining=%zu\n", + millis(), bytesReceived, currentFileSize, + currentFileSize > 0 ? (100.0 * bytesReceived / currentFileSize) : 0.0, + binaryBytesRemaining); + lastProgressLog = millis(); + } // Check if connection is still alive if (!tcpClient.connected()) { Serial.printf("[%lu] [CAL] Connection lost during binary transfer. Received %zu/%zu bytes\n", diff --git a/src/activities/network/CalibreWirelessActivity.h b/src/activities/network/CalibreWirelessActivity.h index 28ba2d0d..e970e305 100644 --- a/src/activities/network/CalibreWirelessActivity.h +++ b/src/activities/network/CalibreWirelessActivity.h @@ -66,6 +66,7 @@ class CalibreWirelessActivity final : public Activity { SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t stateMutex = nullptr; bool updateRequired = false; + volatile bool shouldExit = false; // Signal for tasks to exit gracefully WirelessState state = WirelessState::DISCOVERING; const std::function onComplete; From dce9f7dd4d7c7b60f883e1bd9055e87b3e2078ab Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 14 Jan 2026 14:18:58 -0500 Subject: [PATCH 03/13] changes how we interact with calibre --- platformio.ini | 1 + .../network/CalibreWirelessActivity.cpp | 839 ++++++++---------- .../network/CalibreWirelessActivity.h | 53 +- 3 files changed, 402 insertions(+), 491 deletions(-) diff --git a/platformio.ini b/platformio.ini index cbe47fe9..29bf9e1a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -48,6 +48,7 @@ lib_deps = ArduinoJson @ 7.4.2 QRCode @ 0.0.1 links2004/WebSockets @ ^2.4.1 + mathieucarbou/AsyncTCP @ ^3.2.14 [env:default] extends = base diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp index 79934465..ff3239d0 100644 --- a/src/activities/network/CalibreWirelessActivity.cpp +++ b/src/activities/network/CalibreWirelessActivity.cpp @@ -14,24 +14,22 @@ namespace { constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678}; -constexpr uint16_t LOCAL_UDP_PORT = 8134; // Port to receive responses +constexpr uint16_t LOCAL_UDP_PORT = 8134; } // namespace void CalibreWirelessActivity::displayTaskTrampoline(void* param) { - auto* self = static_cast(param); - self->displayTaskLoop(); + static_cast(param)->displayTaskLoop(); } -void CalibreWirelessActivity::networkTaskTrampoline(void* param) { - auto* self = static_cast(param); - self->networkTaskLoop(); +void CalibreWirelessActivity::discoveryTaskTrampoline(void* param) { + static_cast(param)->discoveryTaskLoop(); } void CalibreWirelessActivity::onEnter() { Activity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); - stateMutex = xSemaphoreCreateMutex(); + dataMutex = xSemaphoreCreateMutex(); state = WirelessState::DISCOVERING; statusMessage = "Discovering Calibre..."; @@ -54,67 +52,60 @@ void CalibreWirelessActivity::onEnter() { 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); + // Create discovery task (UDP is synchronous) + xTaskCreate(&CalibreWirelessActivity::discoveryTaskTrampoline, "CalDiscoveryTask", 4096, this, 2, &discoveryTaskHandle); } void CalibreWirelessActivity::onExit() { Activity::onExit(); - // Signal tasks to exit gracefully FIRST shouldExit = true; - - // Small delay to let tasks see the flag vTaskDelay(50 / portTICK_PERIOD_MS); - // Close TCP to unblock any pending reads in the network task - if (tcpClient.connected()) { - tcpClient.stop(); + // Close async TCP client + if (tcpClient) { + tcpClient->close(true); + delete tcpClient; + tcpClient = nullptr; } + udp.stop(); + vTaskDelay(100 / portTICK_PERIOD_MS); - // Give tasks more time to notice the closed connection and exit - vTaskDelay(250 / portTICK_PERIOD_MS); - - // Clear task handles (tasks self-deleted) - networkTaskHandle = nullptr; + // Tasks will self-delete when they see shouldExit + discoveryTaskHandle = nullptr; displayTaskHandle = nullptr; - // Turn off WiFi when exiting WiFi.mode(WIFI_OFF); - // Close any open file if (currentFile) { currentFile.close(); } - // Clear string buffers to free memory recvBuffer.clear(); recvBuffer.shrink_to_fit(); skipExtractedLpath.clear(); skipExtractedLpath.shrink_to_fit(); - // Delete mutexes last if (renderingMutex) { vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; } - if (stateMutex) { - vSemaphoreDelete(stateMutex); - stateMutex = nullptr; + if (dataMutex) { + vSemaphoreDelete(dataMutex); + dataMutex = nullptr; } } void CalibreWirelessActivity::loop() { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { - onComplete(); + onCompleteCallback(); return; } } @@ -124,298 +115,342 @@ void CalibreWirelessActivity::displayTaskLoop() { if (updateRequired) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); - if (!shouldExit) { // Double-check after acquiring mutex + if (!shouldExit) { render(); } xSemaphoreGive(renderingMutex); } vTaskDelay(50 / portTICK_PERIOD_MS); } - vTaskDelete(nullptr); // Self-delete when done + 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: - // Just wait, user will exit - vTaskDelay(100 / portTICK_PERIOD_MS); - break; +void CalibreWirelessActivity::discoveryTaskLoop() { + while (!shouldExit && state == WirelessState::DISCOVERING) { + // Broadcast "hello" on all UDP discovery ports + for (const uint16_t port : UDP_PORTS) { + udp.beginPacket("255.255.255.255", port); + udp.write(reinterpret_cast("hello"), 5); + udp.endPacket(); } - vTaskDelay(10 / portTICK_PERIOD_MS); - } - vTaskDelete(nullptr); // Self-delete when done -} + vTaskDelay(500 / portTICK_PERIOD_MS); + if (shouldExit) break; -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(); - } + 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); - // Wait for Calibre's response - vTaskDelay(500 / portTICK_PERIOD_MS); + // Parse Calibre response: "calibre wireless device client (on HOSTNAME);PORT,ALT_PORT" + size_t onPos = response.find("(on "); + size_t closePos = response.find(')'); + size_t semiPos = response.find(';'); + size_t commaPos = response.find(',', semiPos); - // 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'; + 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); + } - // 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 - parse safely - uint16_t altPort = 0; - for (size_t i = commaPos + 1; i < response.size(); i++) { - char c = response[i]; + uint16_t mainPort = 0; + for (char c : portStr) { if (c >= '0' && c <= '9') { - altPort = altPort * 10 + (c - '0'); - } else { + mainPort = mainPort * 10 + (c - '0'); + } else if (c != ' ' && c != '\t') { break; } } - calibreAltPort = altPort; - } else { - portStr = response.substr(semiPos + 1); - } + calibrePort = mainPort; - // Parse main port safely - uint16_t mainPort = 0; - for (size_t i = 0; i < portStr.size(); i++) { - char c = portStr[i]; - if (c >= '0' && c <= '9') { - mainPort = mainPort * 10 + (c - '0'); - } else if (c != ' ' && c != '\t') { - break; - } - } - calibrePort = mainPort; - - // 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 (onPos != std::string::npos && closePos != std::string::npos && closePos > onPos + 4) { + calibreHostname = response.substr(onPos + 4, closePos - onPos - 4); } } - 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; + calibreHost = udp.remoteIP().toString().c_str(); + if (calibreHostname.empty()) { + calibreHostname = calibreHost; + } + + if (calibrePort > 0) { + Serial.printf("[%lu] [CAL] Discovered Calibre at %s:%d (alt:%d)\n", millis(), calibreHost.c_str(), calibrePort, + calibreAltPort); + setState(WirelessState::CONNECTING); + setStatus("Connecting to " + calibreHostname + "..."); + connectToCalibr(); } } } } + vTaskDelete(nullptr); } -void CalibreWirelessActivity::handleTcpClient() { - if (!tcpClient.connected()) { +void CalibreWirelessActivity::connectToCalibr() { + Serial.printf("[%lu] [CAL] connectToCalibr called\n", millis()); + + if (tcpClient) { + tcpClient->close(true); + delete tcpClient; + tcpClient = nullptr; + } + + tcpClient = new AsyncClient(); + if (!tcpClient) { + Serial.printf("[%lu] [CAL] Failed to create AsyncClient\n", millis()); + setState(WirelessState::DISCOVERING); + return; + } + + // Set up callbacks with lambdas that call our member functions + tcpClient->onConnect( + [](void* arg, AsyncClient* client) { + Serial.printf("[%lu] [CAL] onConnect callback fired\n", millis()); + static_cast(arg)->onTcpConnect(client); + }, + this); + + tcpClient->onDisconnect( + [](void* arg, AsyncClient* client) { + Serial.printf("[%lu] [CAL] onDisconnect callback fired\n", millis()); + static_cast(arg)->onTcpDisconnect(client); + }, + this); + + tcpClient->onData( + [](void* arg, AsyncClient* client, void* data, size_t len) { + static_cast(arg)->onTcpData(client, data, len); + }, + this); + + tcpClient->onError( + [](void* arg, AsyncClient* client, int8_t error) { + Serial.printf("[%lu] [CAL] onError callback fired: %d\n", millis(), error); + static_cast(arg)->onTcpError(client, error); + }, + this); + + // Use IPAddress explicitly to avoid any DNS resolution issues + IPAddress ip; + if (!ip.fromString(calibreHost.c_str())) { + Serial.printf("[%lu] [CAL] Failed to parse IP: %s\n", millis(), calibreHost.c_str()); + setState(WirelessState::DISCOVERING); + return; + } + + Serial.printf("[%lu] [CAL] Attempting connect to %s:%d\n", millis(), ip.toString().c_str(), calibrePort); + bool connectResult = tcpClient->connect(ip, calibrePort); + Serial.printf("[%lu] [CAL] connect() returned %s\n", millis(), connectResult ? "true" : "false"); + + if (!connectResult) { + // Try alternative port + if (calibreAltPort > 0) { + Serial.printf("[%lu] [CAL] Trying alt port %d\n", millis(), calibreAltPort); + connectResult = tcpClient->connect(ip, calibreAltPort); + Serial.printf("[%lu] [CAL] alt connect() returned %s\n", millis(), connectResult ? "true" : "false"); + if (!connectResult) { + setState(WirelessState::DISCOVERING); + setStatus("Discovering Calibre...\n(Connection failed, retrying)"); + } + } else { + setState(WirelessState::DISCOVERING); + setStatus("Discovering Calibre...\n(Connection failed, retrying)"); + } + } + // If connect() returned true, connection is in progress - wait for callbacks +} + +void CalibreWirelessActivity::onTcpConnect(AsyncClient* client) { + Serial.printf("[%lu] [CAL] Connected to Calibre\n", millis()); + setState(WirelessState::WAITING); + setStatus("Connected to " + calibreHostname + "\nWaiting for commands..."); +} + +void CalibreWirelessActivity::onTcpDisconnect(AsyncClient* client) { + Serial.printf("[%lu] [CAL] Disconnected from Calibre\n", millis()); + if (state != WirelessState::ERROR) { setState(WirelessState::DISCONNECTED); setStatus("Calibre disconnected"); - return; } +} + +void CalibreWirelessActivity::onTcpError(AsyncClient* client, int8_t error) { + Serial.printf("[%lu] [CAL] TCP error: %d\n", millis(), error); + setError("Connection error"); +} + +void CalibreWirelessActivity::onTcpData(AsyncClient* client, void* data, size_t len) { + // This is the key callback - data arrives here like KOReader's receiveCallback + const char* charData = static_cast(data); + + Serial.printf("[%lu] [CAL] Received %zu bytes\n", millis(), len); if (inBinaryMode) { - receiveBinaryData(); - return; + processBinaryData(charData, len); + } else { + // Append to buffer and process JSON messages + xSemaphoreTake(dataMutex, portMAX_DELAY); + recvBuffer.append(charData, len); + xSemaphoreGive(dataMutex); + processJsonData(); + } +} + +void CalibreWirelessActivity::processBinaryData(const char* data, size_t len) { + // Like KOReader: write only what we need, put excess in buffer + size_t toWrite = std::min(len, binaryBytesRemaining); + + if (toWrite > 0) { + currentFile.write(reinterpret_cast(data), toWrite); + bytesReceived += toWrite; + binaryBytesRemaining -= toWrite; + updateRequired = true; + + // Progress logging + static unsigned long lastLog = 0; + unsigned long now = millis(); + if (now - lastLog > 500) { + Serial.printf("[%lu] [CAL] Binary: %zu/%zu bytes (%.1f%%)\n", now, bytesReceived, currentFileSize, + currentFileSize > 0 ? (100.0 * bytesReceived / currentFileSize) : 0.0); + lastLog = now; + } } - std::string message; - if (readJsonMessage(message)) { + // If we received more than needed, it's the next JSON message + if (len > toWrite) { + size_t excess = len - toWrite; + xSemaphoreTake(dataMutex, portMAX_DELAY); + recvBuffer.assign(data + toWrite, excess); + xSemaphoreGive(dataMutex); + Serial.printf("[%lu] [CAL] Binary complete, %zu excess bytes buffered\n", millis(), excess); + } + + // Check if binary transfer is complete + 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..."); + + // Process any buffered JSON data + if (!recvBuffer.empty()) { + processJsonData(); + } + } +} + +void CalibreWirelessActivity::processJsonData() { + // Process JSON messages from buffer (like KOReader's onReceiveJSON) + while (true) { + std::string message; + if (!parseJsonMessage(message)) { + break; // Need more data + } + // 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) { - // Parse opcode safely without exceptions 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; // Invalid character - } - } - 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 && dataStart < message.size()) { - size_t len = dataEnd - dataStart; - if (dataStart + len <= message.size()) { - data = message.substr(dataStart, len); + break; } } - handleCommand(opcode, data); + if (opcodeInt >= 0 && opcodeInt <= OpCode::ERROR) { + auto opcode = static_cast(opcodeInt); + + // Extract data object + 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) { - // Maximum message size we'll buffer in memory - // Messages larger than this (typically due to base64 covers) are streamed through +bool CalibreWirelessActivity::parseJsonMessage(std::string& message) { constexpr size_t MAX_BUFFERED_MSG_SIZE = 32768; - // If in skip mode, consume bytes until we've skipped the full message + xSemaphoreTake(dataMutex, portMAX_DELAY); + + // Handle skip mode for large messages if (inSkipMode) { - while (skipBytesRemaining > 0) { - int available = tcpClient.available(); - if (available <= 0) { - return false; // Need more data - } - - // Read and discard in chunks - uint8_t discardBuf[1024]; - size_t toRead = std::min({static_cast(available), sizeof(discardBuf), skipBytesRemaining}); - int bytesRead = tcpClient.read(discardBuf, toRead); - if (bytesRead > 0) { - skipBytesRemaining -= bytesRead; - } else { - break; - } - } - - if (skipBytesRemaining == 0) { - // Skip complete - if this was SEND_BOOK, construct minimal message + if (recvBuffer.size() >= skipBytesRemaining) { + recvBuffer = recvBuffer.substr(skipBytesRemaining); + skipBytesRemaining = 0; inSkipMode = false; + if (skipOpcode == OpCode::SEND_BOOK && !skipExtractedLpath.empty() && skipExtractedLength > 0) { - // Build minimal JSON that handleSendBook can parse message = "[" + std::to_string(skipOpcode) + ",{\"lpath\":\"" + skipExtractedLpath + "\",\"length\":" + std::to_string(skipExtractedLength) + "}]"; skipOpcode = -1; skipExtractedLpath.clear(); skipExtractedLength = 0; + xSemaphoreGive(dataMutex); return true; } - // For other opcodes, just acknowledge if (skipOpcode >= 0) { message = "[" + std::to_string(skipOpcode) + ",{}]"; skipOpcode = -1; + xSemaphoreGive(dataMutex); return true; } - } - return false; - } - - // Read available data into buffer (limited to prevent memory issues) - int available = tcpClient.available(); - if (available > 0) { - // Only buffer up to a reasonable amount while looking for length prefix - size_t maxBuffer = MAX_BUFFERED_MSG_SIZE + 20; // +20 for length prefix digits - 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; - } - } + } else { + skipBytesRemaining -= recvBuffer.size(); + recvBuffer.clear(); + xSemaphoreGive(dataMutex); + return false; } } if (recvBuffer.empty()) { + xSemaphoreGive(dataMutex); return false; } - // Find '[' which marks the start of JSON + // Find '[' which marks JSON start size_t bracketPos = recvBuffer.find('['); if (bracketPos == std::string::npos) { if (recvBuffer.size() > 1000) { recvBuffer.clear(); } + xSemaphoreGive(dataMutex); return false; } - // Parse length prefix (digits before '[') + // Parse length prefix size_t msgLen = 0; bool validPrefix = false; @@ -438,31 +473,28 @@ bool CalibreWirelessActivity::readJsonMessage(std::string& message) { } if (!validPrefix) { - if (bracketPos > 0 && bracketPos < recvBuffer.size()) { + if (bracketPos > 0) { recvBuffer = recvBuffer.substr(bracketPos); } + xSemaphoreGive(dataMutex); return false; } - // Sanity check - reject absurdly large messages if (msgLen > 10000000) { - Serial.printf("[%lu] [CAL] Rejecting message with length %zu (too large)\n", millis(), msgLen); recvBuffer.clear(); + xSemaphoreGive(dataMutex); return false; } - // For large messages, extract essential fields then skip the rest + // Handle large messages by extracting essential fields and skipping the rest if (msgLen > MAX_BUFFERED_MSG_SIZE) { - Serial.printf("[%lu] [CAL] Large message detected (%zu bytes), streaming\n", millis(), msgLen); + Serial.printf("[%lu] [CAL] Large message (%zu bytes), streaming\n", millis(), msgLen); - // We need to extract: opcode, and for SEND_BOOK: lpath and length - // These fields appear early in the JSON before the large cover data - - // Parse opcode from what we have buffered + // Extract opcode int opcodeInt = -1; size_t opcodeStart = bracketPos + 1; size_t commaPos = recvBuffer.find(',', opcodeStart); - if (commaPos != std::string::npos && commaPos < recvBuffer.size()) { + if (commaPos != std::string::npos) { opcodeInt = 0; for (size_t i = opcodeStart; i < commaPos; i++) { char c = recvBuffer[i]; @@ -478,41 +510,36 @@ bool CalibreWirelessActivity::readJsonMessage(std::string& message) { skipExtractedLpath.clear(); skipExtractedLength = 0; - // For SEND_BOOK, try to extract lpath and length from buffered data + // Extract lpath and length for SEND_BOOK if (opcodeInt == OpCode::SEND_BOOK) { - // Extract lpath size_t lpathPos = recvBuffer.find("\"lpath\""); - if (lpathPos != std::string::npos && lpathPos + 7 < recvBuffer.size()) { + if (lpathPos != std::string::npos) { size_t colonPos = recvBuffer.find(':', lpathPos + 7); - if (colonPos != std::string::npos && colonPos + 1 < recvBuffer.size()) { + if (colonPos != std::string::npos) { size_t quoteStart = recvBuffer.find('"', colonPos + 1); - if (quoteStart != std::string::npos && quoteStart + 1 < recvBuffer.size()) { + if (quoteStart != std::string::npos) { size_t quoteEnd = recvBuffer.find('"', quoteStart + 1); - if (quoteEnd != std::string::npos && quoteEnd > quoteStart + 1) { + if (quoteEnd != std::string::npos) { skipExtractedLpath = recvBuffer.substr(quoteStart + 1, quoteEnd - quoteStart - 1); } } } } - // Extract top-level length (track depth to skip nested length fields in cover metadata) - // Message format is [opcode, {data}], so depth 2 = top level of data object + // Extract top-level length int depth = 0; const char* lengthKey = "\"length\""; - const size_t keyLen = 8; 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 + keyLen <= recvBuffer.size()) { + 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 < keyLen && match; j++) { + for (size_t j = 0; j < 8 && match; j++) { if (recvBuffer[i + j] != lengthKey[j]) match = false; } if (match) { - size_t numStart = i + keyLen; + size_t numStart = i + 8; while (numStart < recvBuffer.size() && (recvBuffer[numStart] == ':' || recvBuffer[numStart] == ' ')) { numStart++; } @@ -526,78 +553,58 @@ bool CalibreWirelessActivity::readJsonMessage(std::string& message) { } } - // Calculate how many bytes we still need to skip size_t totalMsgBytes = bracketPos + msgLen; - size_t alreadyBuffered = recvBuffer.size(); - if (alreadyBuffered >= totalMsgBytes) { - // Entire message is already buffered - just discard it + if (recvBuffer.size() >= totalMsgBytes) { recvBuffer = recvBuffer.substr(totalMsgBytes); - skipBytesRemaining = 0; - } else { - // Need to skip remaining bytes from network - skipBytesRemaining = totalMsgBytes - alreadyBuffered; - recvBuffer.clear(); - } - - inSkipMode = true; - - // If skip is already complete, return immediately - 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; + xSemaphoreGive(dataMutex); return true; } if (skipOpcode >= 0) { message = "[" + std::to_string(skipOpcode) + ",{}]"; skipOpcode = -1; + xSemaphoreGive(dataMutex); return true; } - return false; + } else { + skipBytesRemaining = totalMsgBytes - recvBuffer.size(); + recvBuffer.clear(); + inSkipMode = true; } - return false; // Continue skipping in next iteration - } - - // Normal path for small messages - size_t totalNeeded = bracketPos + msgLen; - if (recvBuffer.size() < totalNeeded) { - return false; // Wait for more data - } - - // Extract the message - if (bracketPos < recvBuffer.size() && bracketPos + msgLen <= recvBuffer.size()) { - message = recvBuffer.substr(bracketPos, msgLen); - } else { - recvBuffer.clear(); + xSemaphoreGive(dataMutex); return false; } - // Keep remainder in buffer - if (recvBuffer.size() > totalNeeded) { - recvBuffer = recvBuffer.substr(totalNeeded); - } else { - recvBuffer.clear(); + // Normal message handling + size_t totalNeeded = bracketPos + msgLen; + if (recvBuffer.size() < totalNeeded) { + xSemaphoreGive(dataMutex); + return false; } + message = recvBuffer.substr(bracketPos, msgLen); + recvBuffer = recvBuffer.size() > totalNeeded ? recvBuffer.substr(totalNeeded) : ""; + + xSemaphoreGive(dataMutex); 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); + if (!tcpClient || !tcpClient->connected()) return; - tcpClient.write(reinterpret_cast(json.c_str()), json.length()); - tcpClient.flush(); + std::string json = "[" + std::to_string(opcode) + "," + data + "]"; + std::string msg = std::to_string(json.length()) + json; + + tcpClient->write(msg.c_str(), msg.length()); } void CalibreWirelessActivity::handleCommand(const OpCode opcode, const std::string& data) { - Serial.printf("[%lu] [CAL] Received opcode: %d, data size: %zu\n", millis(), opcode, data.size()); + Serial.printf("[%lu] [CAL] Command: %d, data size: %zu\n", millis(), opcode, data.size()); switch (opcode) { case OpCode::GET_INITIALIZATION_INFO: @@ -607,6 +614,7 @@ void CalibreWirelessActivity::handleCommand(const OpCode opcode, const std::stri handleGetDeviceInformation(); break; case OpCode::FREE_SPACE: + case OpCode::TOTAL_SPACE: handleFreeSpace(); break; case OpCode::GET_BOOK_COUNT: @@ -626,22 +634,10 @@ void CalibreWirelessActivity::handleCommand(const OpCode opcode, const std::stri 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, "{}"); @@ -655,8 +651,6 @@ void CalibreWirelessActivity::handleGetInitializationInfo(const std::string& dat "\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\"],"; @@ -668,11 +662,7 @@ void CalibreWirelessActivity::handleGetInitializationInfo(const std::string& dat 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. Set to 0 to prevent Calibre from embedding - // large base64-encoded covers in SEND_BOOK metadata, which would bloat the JSON. response += "\"coverHeight\":0,"; response += "\"deviceKind\":\"CrossPoint\","; response += "\"deviceName\":\"CrossPoint\","; @@ -701,85 +691,59 @@ void CalibreWirelessActivity::handleGetDeviceInformation() { } 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); + sendJsonResponse(OpCode::OK, "{\"count\":0,\"willStream\":true,\"willScan\":false}"); } 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 + Serial.printf("[%lu] [CAL] SEND_BOOK data (first 500 chars): %.500s\n", millis(), data.c_str()); - // Extract "lpath" field - format: "lpath": "value" + // Extract lpath std::string lpath; size_t lpathPos = data.find("\"lpath\""); - if (lpathPos != std::string::npos && lpathPos + 7 < data.size()) { + if (lpathPos != std::string::npos) { size_t colonPos = data.find(':', lpathPos + 7); - if (colonPos != std::string::npos && colonPos + 1 < data.size()) { + if (colonPos != std::string::npos) { size_t quoteStart = data.find('"', colonPos + 1); - if (quoteStart != std::string::npos && quoteStart + 1 < data.size()) { + if (quoteStart != std::string::npos) { size_t quoteEnd = data.find('"', quoteStart + 1); - if (quoteEnd != std::string::npos && quoteEnd > quoteStart + 1) { - // Safe bounds check before substr - size_t start = quoteStart + 1; - size_t len = quoteEnd - quoteStart - 1; - if (start < data.size() && start + len <= data.size()) { - lpath = data.substr(start, len); - } + 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) + // Extract top-level length size_t length = 0; int depth = 0; const char* lengthKey = "\"length\""; - const size_t keyLen = 8; 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" by comparing directly - if (i + keyLen <= data.size()) { - bool match = true; - for (size_t j = 0; j < keyLen && match; j++) { - if (data[i + j] != lengthKey[j]) { - match = false; + 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 (match) { - // Found top-level "length" - extract the number after ':' - size_t colonPos = i + keyLen; - 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++; - } - // Parse number safely without exceptions - size_t parsedLen = 0; - while (numStart < data.size() && data[numStart] >= '0' && data[numStart] <= '9') { - parsedLen = parsedLen * 10 + (data[numStart] - '0'); - numStart++; - } - if (parsedLen > 0) { - length = parsedLen; - break; - } + if (length > 0) { + Serial.printf("[%lu] [CAL] Extracted length=%zu\n", millis(), length); + break; } } } @@ -791,67 +755,71 @@ void CalibreWirelessActivity::handleSendBook(const std::string& data) { return; } - // Extract filename from lpath std::string filename = lpath; - const size_t lastSlash = filename.rfind('/'); + 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; + binaryBytesRemaining = length; - Serial.printf("[%lu] [CAL] SEND_BOOK: lpath='%s', length=%zu, recvBuffer leftover=%zu\n", - millis(), lpath.c_str(), length, recvBuffer.size()); + Serial.printf("[%lu] [CAL] SEND_BOOK: file='%s', length=%zu\n", millis(), currentFilename.c_str(), length); 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 + // Send OK - Calibre will start sending binary data sendJsonResponse(OpCode::OK, "{}"); - // Switch to binary mode + // Switch to binary mode - subsequent data in onTcpData will be file content inBinaryMode = true; - binaryBytesRemaining = length; - // Check if recvBuffer has leftover data (binary file data that arrived with the JSON) + // Process any data already in buffer (like KOReader) + xSemaphoreTake(dataMutex, portMAX_DELAY); if (!recvBuffer.empty()) { size_t toWrite = std::min(recvBuffer.size(), binaryBytesRemaining); - Serial.printf("[%lu] [CAL] Writing %zu bytes from recvBuffer (had %zu bytes)\n", - millis(), toWrite, recvBuffer.size()); - size_t written = currentFile.write(reinterpret_cast(recvBuffer.data()), toWrite); - if (written != toWrite) { - Serial.printf("[%lu] [CAL] WARNING: file.write returned %zu, expected %zu\n", millis(), written, toWrite); + Serial.printf("[%lu] [CAL] Writing %zu bytes 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(); } - bytesReceived += written; - binaryBytesRemaining -= written; - recvBuffer = recvBuffer.substr(written); // Use written, not toWrite! updateRequired = true; - Serial.printf("[%lu] [CAL] After recvBuffer write: received=%zu, remaining=%zu, recvBuffer=%zu\n", - millis(), bytesReceived, binaryBytesRemaining, recvBuffer.size()); + + 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..."); + } } + xSemaphoreGive(dataMutex); } void CalibreWirelessActivity::handleSendBookMetadata(const std::string& data) { - // We receive metadata after the book - just acknowledge + Serial.printf("[%lu] [CAL] SEND_BOOK_METADATA received\n", millis()); 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"); } @@ -859,7 +827,6 @@ void CalibreWirelessActivity::handleDisplayMessage(const std::string& data) { } 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"); @@ -867,82 +834,18 @@ void CalibreWirelessActivity::handleNoop(const std::string& data) { sendJsonResponse(OpCode::NOOP, "{}"); } -void CalibreWirelessActivity::receiveBinaryData() { - static unsigned long lastProgressLog = 0; - - // Read all available data in a loop to prevent TCP backpressure - // This is important because Calibre sends data continuously - while (binaryBytesRemaining > 0) { - if (shouldExit) return; - - const int available = tcpClient.available(); - if (available == 0) { - // Log progress periodically when waiting for data - if (millis() - lastProgressLog > 2000) { - Serial.printf("[%lu] [CAL] Binary transfer waiting: %zu/%zu bytes (%.1f%%), remaining=%zu\n", - millis(), bytesReceived, currentFileSize, - currentFileSize > 0 ? (100.0 * bytesReceived / currentFileSize) : 0.0, - binaryBytesRemaining); - lastProgressLog = millis(); - } - // Check if connection is still alive - if (!tcpClient.connected()) { - Serial.printf("[%lu] [CAL] Connection lost during binary transfer. Received %zu/%zu bytes\n", - millis(), bytesReceived, currentFileSize); - currentFile.close(); - inBinaryMode = false; - setError("Transfer interrupted"); - } - return; // No data available right now, will continue next iteration - } - - uint8_t buffer[4096]; // Larger buffer for faster transfer - const size_t toRead = std::min({sizeof(buffer), binaryBytesRemaining, static_cast(available)}); - const size_t bytesRead = tcpClient.read(buffer, toRead); - - if (bytesRead == 0) { - break; // No more data to read right now - } - - currentFile.write(buffer, bytesRead); - bytesReceived += bytesRead; - binaryBytesRemaining -= bytesRead; - updateRequired = true; - } - - if (binaryBytesRemaining == 0) { - // Transfer complete - switch back to JSON mode - // Note: Do NOT send OK here. KOReader doesn't, and sending an extra OK - // could be misinterpreted as a response to SEND_BOOK_METADATA before - // we've received it, causing protocol desync. - currentFile.flush(); - currentFile.close(); - inBinaryMode = false; - - Serial.printf("[%lu] [CAL] Binary transfer complete: %zu bytes received\n", millis(), bytesReceived); - - setState(WirelessState::WAITING); - setStatus("Received: " + currentFilename + "\nWaiting for more..."); - } -} - 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) { @@ -955,7 +858,6 @@ void CalibreWirelessActivity::render() const { statusY += 25; } - // Draw progress if receiving if (state == WirelessState::RECEIVING && currentFileSize > 0) { const int barWidth = pageWidth - 100; constexpr int barHeight = 20; @@ -964,12 +866,10 @@ void CalibreWirelessActivity::render() const { 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); @@ -977,21 +877,18 @@ void CalibreWirelessActivity::render() const { } 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); + xSemaphoreTake(dataMutex, portMAX_DELAY); state = newState; - xSemaphoreGive(stateMutex); + xSemaphoreGive(dataMutex); updateRequired = true; } diff --git a/src/activities/network/CalibreWirelessActivity.h b/src/activities/network/CalibreWirelessActivity.h index e970e305..505bf9cc 100644 --- a/src/activities/network/CalibreWirelessActivity.h +++ b/src/activities/network/CalibreWirelessActivity.h @@ -1,6 +1,6 @@ #pragma once +#include #include -#include #include #include #include @@ -18,6 +18,8 @@ * Protocol specification sourced from Calibre's smart device driver: * https://github.com/kovidgoyal/calibre/blob/master/src/calibre/devices/smart_device_app/driver.py * + * Uses AsyncTCP for callback-based networking (like KOReader's StreamMessageQueue). + * * Protocol overview: * 1. Device broadcasts "hello" on UDP ports 54982, 48123, 39001, 44044, 59678 * 2. Calibre responds with its TCP server address @@ -50,7 +52,7 @@ class CalibreWirelessActivity final : public Activity { SEND_BOOK = 8, GET_INITIALIZATION_INFO = 9, BOOK_DONE = 11, - NOOP = 12, // Was incorrectly 18 + NOOP = 12, DELETE_BOOK = 13, GET_BOOK_FILE_SEGMENT = 14, GET_BOOK_METADATA = 15, @@ -62,23 +64,23 @@ class CalibreWirelessActivity final : public Activity { }; TaskHandle_t displayTaskHandle = nullptr; - TaskHandle_t networkTaskHandle = nullptr; + TaskHandle_t discoveryTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; - SemaphoreHandle_t stateMutex = nullptr; + SemaphoreHandle_t dataMutex = nullptr; // Protects shared data accessed from callbacks bool updateRequired = false; - volatile bool shouldExit = false; // Signal for tasks to exit gracefully + volatile bool shouldExit = false; WirelessState state = WirelessState::DISCOVERING; - const std::function onComplete; + const std::function onCompleteCallback; // UDP discovery WiFiUDP udp; - // TCP connection (we connect to Calibre) - WiFiClient tcpClient; + // Async TCP connection + AsyncClient* tcpClient = nullptr; std::string calibreHost; uint16_t calibrePort = 0; - uint16_t calibreAltPort = 0; // Alternative port (content server) + uint16_t calibreAltPort = 0; std::string calibreHostname; // Transfer state @@ -94,26 +96,36 @@ class CalibreWirelessActivity final : public Activity { FsFile currentFile; std::string recvBuffer; // Buffer for incoming data (like KOReader) - // Large message skip state - for streaming past oversized JSON (e.g., large covers) + // Large message skip state bool inSkipMode = false; size_t skipBytesRemaining = 0; - int skipOpcode = -1; // Opcode of message being skipped + int skipOpcode = -1; std::string skipExtractedLpath; size_t skipExtractedLength = 0; + // Display task static void displayTaskTrampoline(void* param); - static void networkTaskTrampoline(void* param); - [[noreturn]] void displayTaskLoop(); - [[noreturn]] void networkTaskLoop(); + void displayTaskLoop(); void render() const; - // Network operations - void listenForDiscovery(); - void handleTcpClient(); - bool readJsonMessage(std::string& message); + // Discovery task (UDP is not async) + static void discoveryTaskTrampoline(void* param); + void discoveryTaskLoop(); + + // AsyncTCP callbacks + void onTcpConnect(AsyncClient* client); + void onTcpDisconnect(AsyncClient* client); + void onTcpData(AsyncClient* client, void* data, size_t len); + void onTcpError(AsyncClient* client, int8_t error); + + // Data processing (called from onTcpData callback) + void processReceivedData(); + void processBinaryData(const char* data, size_t len); + void processJsonData(); + bool parseJsonMessage(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); @@ -130,11 +142,12 @@ class CalibreWirelessActivity final : public Activity { void setState(WirelessState newState); void setStatus(const std::string& message); void setError(const std::string& message); + void connectToCalibr(); public: explicit CalibreWirelessActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onComplete) - : Activity("CalibreWireless", renderer, mappedInput), onComplete(onComplete) {} + : Activity("CalibreWireless", renderer, mappedInput), onCompleteCallback(onComplete) {} void onEnter() override; void onExit() override; void loop() override; From 53592e58b220aa9f3b69bd24101d4c6cb36511a5 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 14 Jan 2026 14:28:19 -0500 Subject: [PATCH 04/13] refactor original method as the prior commit broke connecting --- platformio.ini | 1 - .../network/CalibreWirelessActivity.cpp | 500 ++++++++---------- .../network/CalibreWirelessActivity.h | 67 +-- 3 files changed, 236 insertions(+), 332 deletions(-) diff --git a/platformio.ini b/platformio.ini index 29bf9e1a..cbe47fe9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -48,7 +48,6 @@ lib_deps = ArduinoJson @ 7.4.2 QRCode @ 0.0.1 links2004/WebSockets @ ^2.4.1 - mathieucarbou/AsyncTCP @ ^3.2.14 [env:default] extends = base diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp index ff3239d0..6a701696 100644 --- a/src/activities/network/CalibreWirelessActivity.cpp +++ b/src/activities/network/CalibreWirelessActivity.cpp @@ -21,15 +21,15 @@ void CalibreWirelessActivity::displayTaskTrampoline(void* param) { static_cast(param)->displayTaskLoop(); } -void CalibreWirelessActivity::discoveryTaskTrampoline(void* param) { - static_cast(param)->discoveryTaskLoop(); +void CalibreWirelessActivity::networkTaskTrampoline(void* param) { + static_cast(param)->networkTaskLoop(); } void CalibreWirelessActivity::onEnter() { Activity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); - dataMutex = xSemaphoreCreateMutex(); + stateMutex = xSemaphoreCreateMutex(); state = WirelessState::DISCOVERING; statusMessage = "Discovering Calibre..."; @@ -54,11 +54,8 @@ void CalibreWirelessActivity::onEnter() { udp.begin(LOCAL_UDP_PORT); - // Create display task xTaskCreate(&CalibreWirelessActivity::displayTaskTrampoline, "CalDisplayTask", 2048, this, 1, &displayTaskHandle); - - // Create discovery task (UDP is synchronous) - xTaskCreate(&CalibreWirelessActivity::discoveryTaskTrampoline, "CalDiscoveryTask", 4096, this, 2, &discoveryTaskHandle); + xTaskCreate(&CalibreWirelessActivity::networkTaskTrampoline, "CalNetworkTask", 12288, this, 2, &networkTaskHandle); } void CalibreWirelessActivity::onExit() { @@ -67,18 +64,14 @@ void CalibreWirelessActivity::onExit() { shouldExit = true; vTaskDelay(50 / portTICK_PERIOD_MS); - // Close async TCP client - if (tcpClient) { - tcpClient->close(true); - delete tcpClient; - tcpClient = nullptr; + if (tcpClient.connected()) { + tcpClient.stop(); } - udp.stop(); - vTaskDelay(100 / portTICK_PERIOD_MS); - // Tasks will self-delete when they see shouldExit - discoveryTaskHandle = nullptr; + vTaskDelay(250 / portTICK_PERIOD_MS); + + networkTaskHandle = nullptr; displayTaskHandle = nullptr; WiFi.mode(WIFI_OFF); @@ -97,9 +90,9 @@ void CalibreWirelessActivity::onExit() { renderingMutex = nullptr; } - if (dataMutex) { - vSemaphoreDelete(dataMutex); - dataMutex = nullptr; + if (stateMutex) { + vSemaphoreDelete(stateMutex); + stateMutex = nullptr; } } @@ -125,248 +118,136 @@ void CalibreWirelessActivity::displayTaskLoop() { vTaskDelete(nullptr); } -void CalibreWirelessActivity::discoveryTaskLoop() { - while (!shouldExit && state == WirelessState::DISCOVERING) { - // Broadcast "hello" on all UDP discovery ports - for (const uint16_t port : UDP_PORTS) { - udp.beginPacket("255.255.255.255", port); - udp.write(reinterpret_cast("hello"), 5); - udp.endPacket(); - } +void CalibreWirelessActivity::networkTaskLoop() { + while (!shouldExit) { + xSemaphoreTake(stateMutex, portMAX_DELAY); + const auto currentState = state; + xSemaphoreGive(stateMutex); - vTaskDelay(500 / portTICK_PERIOD_MS); if (shouldExit) break; - 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); - - // Parse Calibre response: "calibre wireless device client (on HOSTNAME);PORT,ALT_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) { - 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) { - Serial.printf("[%lu] [CAL] Discovered Calibre at %s:%d (alt:%d)\n", millis(), calibreHost.c_str(), calibrePort, - calibreAltPort); - setState(WirelessState::CONNECTING); - setStatus("Connecting to " + calibreHostname + "..."); - connectToCalibr(); - } - } + 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::connectToCalibr() { - Serial.printf("[%lu] [CAL] connectToCalibr called\n", millis()); - - if (tcpClient) { - tcpClient->close(true); - delete tcpClient; - tcpClient = 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(); } - tcpClient = new AsyncClient(); - if (!tcpClient) { - Serial.printf("[%lu] [CAL] Failed to create AsyncClient\n", millis()); - setState(WirelessState::DISCOVERING); - return; - } + vTaskDelay(500 / portTICK_PERIOD_MS); - // Set up callbacks with lambdas that call our member functions - tcpClient->onConnect( - [](void* arg, AsyncClient* client) { - Serial.printf("[%lu] [CAL] onConnect callback fired\n", millis()); - static_cast(arg)->onTcpConnect(client); - }, - this); + 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); - tcpClient->onDisconnect( - [](void* arg, AsyncClient* client) { - Serial.printf("[%lu] [CAL] onDisconnect callback fired\n", millis()); - static_cast(arg)->onTcpDisconnect(client); - }, - this); + size_t onPos = response.find("(on "); + size_t closePos = response.find(')'); + size_t semiPos = response.find(';'); + size_t commaPos = response.find(',', semiPos); - tcpClient->onData( - [](void* arg, AsyncClient* client, void* data, size_t len) { - static_cast(arg)->onTcpData(client, data, len); - }, - this); + 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); + } - tcpClient->onError( - [](void* arg, AsyncClient* client, int8_t error) { - Serial.printf("[%lu] [CAL] onError callback fired: %d\n", millis(), error); - static_cast(arg)->onTcpError(client, error); - }, - this); + 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; - // Use IPAddress explicitly to avoid any DNS resolution issues - IPAddress ip; - if (!ip.fromString(calibreHost.c_str())) { - Serial.printf("[%lu] [CAL] Failed to parse IP: %s\n", millis(), calibreHost.c_str()); - setState(WirelessState::DISCOVERING); - return; - } - - Serial.printf("[%lu] [CAL] Attempting connect to %s:%d\n", millis(), ip.toString().c_str(), calibrePort); - bool connectResult = tcpClient->connect(ip, calibrePort); - Serial.printf("[%lu] [CAL] connect() returned %s\n", millis(), connectResult ? "true" : "false"); - - if (!connectResult) { - // Try alternative port - if (calibreAltPort > 0) { - Serial.printf("[%lu] [CAL] Trying alt port %d\n", millis(), calibreAltPort); - connectResult = tcpClient->connect(ip, calibreAltPort); - Serial.printf("[%lu] [CAL] alt connect() returned %s\n", millis(), connectResult ? "true" : "false"); - if (!connectResult) { - setState(WirelessState::DISCOVERING); - setStatus("Discovering Calibre...\n(Connection failed, retrying)"); + 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; + } } - } else { - setState(WirelessState::DISCOVERING); - setStatus("Discovering Calibre...\n(Connection failed, retrying)"); } } - // If connect() returned true, connection is in progress - wait for callbacks } -void CalibreWirelessActivity::onTcpConnect(AsyncClient* client) { - Serial.printf("[%lu] [CAL] Connected to Calibre\n", millis()); - setState(WirelessState::WAITING); - setStatus("Connected to " + calibreHostname + "\nWaiting for commands..."); -} - -void CalibreWirelessActivity::onTcpDisconnect(AsyncClient* client) { - Serial.printf("[%lu] [CAL] Disconnected from Calibre\n", millis()); - if (state != WirelessState::ERROR) { +void CalibreWirelessActivity::handleTcpClient() { + if (!tcpClient.connected()) { setState(WirelessState::DISCONNECTED); setStatus("Calibre disconnected"); + return; } -} - -void CalibreWirelessActivity::onTcpError(AsyncClient* client, int8_t error) { - Serial.printf("[%lu] [CAL] TCP error: %d\n", millis(), error); - setError("Connection error"); -} - -void CalibreWirelessActivity::onTcpData(AsyncClient* client, void* data, size_t len) { - // This is the key callback - data arrives here like KOReader's receiveCallback - const char* charData = static_cast(data); - - Serial.printf("[%lu] [CAL] Received %zu bytes\n", millis(), len); if (inBinaryMode) { - processBinaryData(charData, len); - } else { - // Append to buffer and process JSON messages - xSemaphoreTake(dataMutex, portMAX_DELAY); - recvBuffer.append(charData, len); - xSemaphoreGive(dataMutex); - processJsonData(); - } -} - -void CalibreWirelessActivity::processBinaryData(const char* data, size_t len) { - // Like KOReader: write only what we need, put excess in buffer - size_t toWrite = std::min(len, binaryBytesRemaining); - - if (toWrite > 0) { - currentFile.write(reinterpret_cast(data), toWrite); - bytesReceived += toWrite; - binaryBytesRemaining -= toWrite; - updateRequired = true; - - // Progress logging - static unsigned long lastLog = 0; - unsigned long now = millis(); - if (now - lastLog > 500) { - Serial.printf("[%lu] [CAL] Binary: %zu/%zu bytes (%.1f%%)\n", now, bytesReceived, currentFileSize, - currentFileSize > 0 ? (100.0 * bytesReceived / currentFileSize) : 0.0); - lastLog = now; - } + receiveBinaryData(); + return; } - // If we received more than needed, it's the next JSON message - if (len > toWrite) { - size_t excess = len - toWrite; - xSemaphoreTake(dataMutex, portMAX_DELAY); - recvBuffer.assign(data + toWrite, excess); - xSemaphoreGive(dataMutex); - Serial.printf("[%lu] [CAL] Binary complete, %zu excess bytes buffered\n", millis(), excess); - } - - // Check if binary transfer is complete - 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..."); - - // Process any buffered JSON data - if (!recvBuffer.empty()) { - processJsonData(); - } - } -} - -void CalibreWirelessActivity::processJsonData() { - // Process JSON messages from buffer (like KOReader's onReceiveJSON) - while (true) { - std::string message; - if (!parseJsonMessage(message)) { - break; // Need more data - } - - // Parse opcode from JSON array format: [opcode, {...}] + std::string message; + if (readJsonMessage(message)) { size_t start = message.find('['); if (start != std::string::npos) { start++; @@ -384,15 +265,12 @@ void CalibreWirelessActivity::processJsonData() { if (opcodeInt >= 0 && opcodeInt <= OpCode::ERROR) { auto opcode = static_cast(opcodeInt); - - // Extract data object 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); } } @@ -400,57 +278,74 @@ void CalibreWirelessActivity::processJsonData() { } } -bool CalibreWirelessActivity::parseJsonMessage(std::string& message) { +bool CalibreWirelessActivity::readJsonMessage(std::string& message) { constexpr size_t MAX_BUFFERED_MSG_SIZE = 32768; - xSemaphoreTake(dataMutex, portMAX_DELAY); - // Handle skip mode for large messages if (inSkipMode) { - if (recvBuffer.size() >= skipBytesRemaining) { - recvBuffer = recvBuffer.substr(skipBytesRemaining); - skipBytesRemaining = 0; - inSkipMode = false; + 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; - xSemaphoreGive(dataMutex); return true; } if (skipOpcode >= 0) { message = "[" + std::to_string(skipOpcode) + ",{}]"; skipOpcode = -1; - xSemaphoreGive(dataMutex); return true; } - } else { - skipBytesRemaining -= recvBuffer.size(); - recvBuffer.clear(); - xSemaphoreGive(dataMutex); - return false; + } + 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()) { - xSemaphoreGive(dataMutex); return false; } - // Find '[' which marks JSON start size_t bracketPos = recvBuffer.find('['); if (bracketPos == std::string::npos) { if (recvBuffer.size() > 1000) { recvBuffer.clear(); } - xSemaphoreGive(dataMutex); return false; } - // Parse length prefix size_t msgLen = 0; bool validPrefix = false; @@ -476,21 +371,18 @@ bool CalibreWirelessActivity::parseJsonMessage(std::string& message) { if (bracketPos > 0) { recvBuffer = recvBuffer.substr(bracketPos); } - xSemaphoreGive(dataMutex); return false; } if (msgLen > 10000000) { recvBuffer.clear(); - xSemaphoreGive(dataMutex); return false; } - // Handle large messages by extracting essential fields and skipping the rest + // Handle large messages if (msgLen > MAX_BUFFERED_MSG_SIZE) { Serial.printf("[%lu] [CAL] Large message (%zu bytes), streaming\n", millis(), msgLen); - // Extract opcode int opcodeInt = -1; size_t opcodeStart = bracketPos + 1; size_t commaPos = recvBuffer.find(',', opcodeStart); @@ -510,7 +402,6 @@ bool CalibreWirelessActivity::parseJsonMessage(std::string& message) { skipExtractedLpath.clear(); skipExtractedLength = 0; - // Extract lpath and length for SEND_BOOK if (opcodeInt == OpCode::SEND_BOOK) { size_t lpathPos = recvBuffer.find("\"lpath\""); if (lpathPos != std::string::npos) { @@ -526,7 +417,6 @@ bool CalibreWirelessActivity::parseJsonMessage(std::string& message) { } } - // Extract top-level length int depth = 0; const char* lengthKey = "\"length\""; for (size_t i = bracketPos; i < recvBuffer.size() && i < bracketPos + 2000; i++) { @@ -562,13 +452,11 @@ bool CalibreWirelessActivity::parseJsonMessage(std::string& message) { skipOpcode = -1; skipExtractedLpath.clear(); skipExtractedLength = 0; - xSemaphoreGive(dataMutex); return true; } if (skipOpcode >= 0) { message = "[" + std::to_string(skipOpcode) + ",{}]"; skipOpcode = -1; - xSemaphoreGive(dataMutex); return true; } } else { @@ -576,31 +464,25 @@ bool CalibreWirelessActivity::parseJsonMessage(std::string& message) { recvBuffer.clear(); inSkipMode = true; } - xSemaphoreGive(dataMutex); return false; } - // Normal message handling size_t totalNeeded = bracketPos + msgLen; if (recvBuffer.size() < totalNeeded) { - xSemaphoreGive(dataMutex); return false; } message = recvBuffer.substr(bracketPos, msgLen); recvBuffer = recvBuffer.size() > totalNeeded ? recvBuffer.substr(totalNeeded) : ""; - xSemaphoreGive(dataMutex); return true; } void CalibreWirelessActivity::sendJsonResponse(const OpCode opcode, const std::string& data) { - if (!tcpClient || !tcpClient->connected()) return; - std::string json = "[" + std::to_string(opcode) + "," + data + "]"; std::string msg = std::to_string(json.length()) + json; - - tcpClient->write(msg.c_str(), msg.length()); + tcpClient.write(reinterpret_cast(msg.c_str()), msg.length()); + tcpClient.flush(); } void CalibreWirelessActivity::handleCommand(const OpCode opcode, const std::string& data) { @@ -639,12 +521,67 @@ void CalibreWirelessActivity::handleCommand(const OpCode opcode, const std::stri sendJsonResponse(OpCode::OK, "{}"); break; default: - Serial.printf("[%lu] [CAL] Unknown opcode: %d\n", millis(), opcode); 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) { + // Brief wait for more data + vTaskDelay(1); + 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 + @@ -699,7 +636,7 @@ void CalibreWirelessActivity::handleGetBookCount() { } void CalibreWirelessActivity::handleSendBook(const std::string& data) { - Serial.printf("[%lu] [CAL] SEND_BOOK data (first 500 chars): %.500s\n", millis(), data.c_str()); + Serial.printf("[%lu] [CAL] SEND_BOOK (first 500): %.500s\n", millis(), data.c_str()); // Extract lpath std::string lpath; @@ -769,7 +706,8 @@ void CalibreWirelessActivity::handleSendBook(const std::string& data) { bytesReceived = 0; binaryBytesRemaining = length; - Serial.printf("[%lu] [CAL] SEND_BOOK: file='%s', length=%zu\n", millis(), currentFilename.c_str(), 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); @@ -780,17 +718,16 @@ void CalibreWirelessActivity::handleSendBook(const std::string& data) { return; } - // Send OK - Calibre will start sending binary data + // Send OK - Calibre will start sending binary sendJsonResponse(OpCode::OK, "{}"); - // Switch to binary mode - subsequent data in onTcpData will be file content + // Switch to binary mode inBinaryMode = true; // Process any data already in buffer (like KOReader) - xSemaphoreTake(dataMutex, portMAX_DELAY); if (!recvBuffer.empty()) { size_t toWrite = std::min(recvBuffer.size(), binaryBytesRemaining); - Serial.printf("[%lu] [CAL] Writing %zu bytes from buffer\n", millis(), toWrite); + Serial.printf("[%lu] [CAL] Writing %zu from buffer\n", millis(), toWrite); currentFile.write(reinterpret_cast(recvBuffer.data()), toWrite); bytesReceived += toWrite; binaryBytesRemaining -= toWrite; @@ -811,11 +748,10 @@ void CalibreWirelessActivity::handleSendBook(const std::string& data) { setStatus("Received: " + currentFilename + "\nWaiting for more..."); } } - xSemaphoreGive(dataMutex); } void CalibreWirelessActivity::handleSendBookMetadata(const std::string& data) { - Serial.printf("[%lu] [CAL] SEND_BOOK_METADATA received\n", millis()); + Serial.printf("[%lu] [CAL] SEND_BOOK_METADATA\n", millis()); sendJsonResponse(OpCode::OK, "{}"); } @@ -886,9 +822,9 @@ std::string CalibreWirelessActivity::getDeviceUuid() const { } void CalibreWirelessActivity::setState(WirelessState newState) { - xSemaphoreTake(dataMutex, portMAX_DELAY); + xSemaphoreTake(stateMutex, portMAX_DELAY); state = newState; - xSemaphoreGive(dataMutex); + xSemaphoreGive(stateMutex); updateRequired = true; } diff --git a/src/activities/network/CalibreWirelessActivity.h b/src/activities/network/CalibreWirelessActivity.h index 505bf9cc..a84cebd8 100644 --- a/src/activities/network/CalibreWirelessActivity.h +++ b/src/activities/network/CalibreWirelessActivity.h @@ -1,6 +1,6 @@ #pragma once -#include #include +#include #include #include #include @@ -17,29 +17,18 @@ * * Protocol specification sourced from Calibre's smart device driver: * https://github.com/kovidgoyal/calibre/blob/master/src/calibre/devices/smart_device_app/driver.py - * - * Uses AsyncTCP for callback-based networking (like KOReader's StreamMessageQueue). - * - * 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 + DISCOVERING, + CONNECTING, + WAITING, + RECEIVING, + COMPLETE, + DISCONNECTED, + ERROR }; - // Calibre protocol opcodes (from calibre/devices/smart_device_app/driver.py) enum OpCode : uint8_t { OK = 0, SET_CALIBRE_DEVICE_INFO = 1, @@ -64,70 +53,52 @@ class CalibreWirelessActivity final : public Activity { }; TaskHandle_t displayTaskHandle = nullptr; - TaskHandle_t discoveryTaskHandle = nullptr; + TaskHandle_t networkTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; - SemaphoreHandle_t dataMutex = nullptr; // Protects shared data accessed from callbacks + SemaphoreHandle_t stateMutex = nullptr; bool updateRequired = false; volatile bool shouldExit = false; WirelessState state = WirelessState::DISCOVERING; const std::function onCompleteCallback; - // UDP discovery WiFiUDP udp; - - // Async TCP connection - AsyncClient* tcpClient = nullptr; + WiFiClient tcpClient; std::string calibreHost; uint16_t calibrePort = 0; uint16_t calibreAltPort = 0; 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) + std::string recvBuffer; - // Large message skip state bool inSkipMode = false; size_t skipBytesRemaining = 0; int skipOpcode = -1; std::string skipExtractedLpath; size_t skipExtractedLength = 0; - // Display task static void displayTaskTrampoline(void* param); + static void networkTaskTrampoline(void* param); void displayTaskLoop(); + void networkTaskLoop(); void render() const; - // Discovery task (UDP is not async) - static void discoveryTaskTrampoline(void* param); - void discoveryTaskLoop(); - - // AsyncTCP callbacks - void onTcpConnect(AsyncClient* client); - void onTcpDisconnect(AsyncClient* client); - void onTcpData(AsyncClient* client, void* data, size_t len); - void onTcpError(AsyncClient* client, int8_t error); - - // Data processing (called from onTcpData callback) - void processReceivedData(); - void processBinaryData(const char* data, size_t len); - void processJsonData(); - bool parseJsonMessage(std::string& message); - + 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(); @@ -137,12 +108,10 @@ class CalibreWirelessActivity final : public Activity { 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); - void connectToCalibr(); public: explicit CalibreWirelessActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, From 023cca11187d10c60e507ebe80e874a6553dc112 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Thu, 15 Jan 2026 10:33:40 -0500 Subject: [PATCH 05/13] fixed files stalling at 90% --- .../network/CalibreWirelessActivity.cpp | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp index 6a701696..d6df713a 100644 --- a/src/activities/network/CalibreWirelessActivity.cpp +++ b/src/activities/network/CalibreWirelessActivity.cpp @@ -235,17 +235,29 @@ void CalibreWirelessActivity::listenForDiscovery() { } 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; } - if (inBinaryMode) { - receiveBinaryData(); - return; - } - std::string message; if (readJsonMessage(message)) { size_t start = message.find('['); @@ -532,8 +544,9 @@ void CalibreWirelessActivity::receiveBinaryData() { int available = tcpClient.available(); if (available <= 0) { - // Brief wait for more data - vTaskDelay(1); + // 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; } From 5ef4a919ca71f1e2d6022a29661d3b12f27e834d Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Thu, 15 Jan 2026 10:36:44 -0500 Subject: [PATCH 06/13] clang format --- src/activities/network/CalibreWirelessActivity.cpp | 12 ++++++++---- src/activities/network/CalibreWirelessActivity.h | 10 +--------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp index d6df713a..ee5e45a9 100644 --- a/src/activities/network/CalibreWirelessActivity.cpp +++ b/src/activities/network/CalibreWirelessActivity.cpp @@ -433,8 +433,10 @@ bool CalibreWirelessActivity::readJsonMessage(std::string& message) { 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--; + 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++) { @@ -674,8 +676,10 @@ void CalibreWirelessActivity::handleSendBook(const std::string& data) { for (size_t i = 0; i < data.size(); i++) { char c = data[i]; - if (c == '{' || c == '[') depth++; - else if (c == '}' || c == ']') depth--; + 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++) { diff --git a/src/activities/network/CalibreWirelessActivity.h b/src/activities/network/CalibreWirelessActivity.h index a84cebd8..4af89525 100644 --- a/src/activities/network/CalibreWirelessActivity.h +++ b/src/activities/network/CalibreWirelessActivity.h @@ -19,15 +19,7 @@ * 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 class WirelessState { DISCOVERING, CONNECTING, WAITING, RECEIVING, COMPLETE, DISCONNECTED, ERROR }; enum OpCode : uint8_t { OK = 0, From 818120c2dc9b864dbf360387b75c7a34c433af19 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Fri, 16 Jan 2026 15:14:43 -0500 Subject: [PATCH 07/13] reduce max packet --- src/activities/network/CalibreWirelessActivity.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp index ee5e45a9..c62d8d58 100644 --- a/src/activities/network/CalibreWirelessActivity.cpp +++ b/src/activities/network/CalibreWirelessActivity.cpp @@ -619,7 +619,7 @@ void CalibreWirelessActivity::handleGetInitializationInfo(const std::string& dat response += "\"deviceKind\":\"CrossPoint\","; response += "\"deviceName\":\"CrossPoint\","; response += "\"extensionPathLengths\":{\"epub\":37},"; - response += "\"maxBookContentPacketLen\":4096,"; + response += "\"maxBookContentPacketLen\":1024,"; response += "\"passwordHash\":\"\","; response += "\"useUuidFileNames\":false,"; response += "\"versionOK\":true"; From 12666636a73afc0d83245556266beedd7a6ce5e2 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Fri, 16 Jan 2026 16:54:44 -0500 Subject: [PATCH 08/13] Moved to a custom calibre plugin instead --- calibre-plugin/crosspoint_reader/README.md | 25 ++ .../crosspoint_reader/plugin/__init__.py | 5 + .../crosspoint_reader/plugin/config.py | 86 ++++++ .../crosspoint_reader/plugin/driver.py | 191 ++++++++++++ .../crosspoint_reader/plugin/log.py | 17 ++ .../crosspoint_reader/plugin/ws_client.py | 280 ++++++++++++++++++ src/network/CrossPointWebServer.cpp | 34 ++- src/network/CrossPointWebServer.h | 5 +- 8 files changed, 641 insertions(+), 2 deletions(-) create mode 100644 calibre-plugin/crosspoint_reader/README.md create mode 100644 calibre-plugin/crosspoint_reader/plugin/__init__.py create mode 100644 calibre-plugin/crosspoint_reader/plugin/config.py create mode 100644 calibre-plugin/crosspoint_reader/plugin/driver.py create mode 100644 calibre-plugin/crosspoint_reader/plugin/log.py create mode 100644 calibre-plugin/crosspoint_reader/plugin/ws_client.py diff --git a/calibre-plugin/crosspoint_reader/README.md b/calibre-plugin/crosspoint_reader/README.md new file mode 100644 index 00000000..04f925f0 --- /dev/null +++ b/calibre-plugin/crosspoint_reader/README.md @@ -0,0 +1,25 @@ +# CrossPoint Reader Calibre Plugin + +This plugin adds CrossPoint Reader as a wireless device in Calibre. It uploads +EPUB files over WebSocket to the CrossPoint web server. + +Protocol: +- Connect to ws://:/ +- Send: START::: +- Wait for READY +- Send binary frames with file content +- Wait for DONE (or ERROR:) + +Default settings: +- Auto-discover device via UDP +- Host fallback: 192.168.4.1 +- Port: 81 +- Upload path: / + +Install: +1. Zip the contents of `calibre-plugin/crosspoint_reader/plugin`. +2. In Calibre: Preferences > Plugins > Load plugin from file. +3. The device should appear in Calibre once it is discoverable on the network. + +No configuration needed. The plugin auto-discovers the device via UDP and +falls back to 192.168.4.1:81. diff --git a/calibre-plugin/crosspoint_reader/plugin/__init__.py b/calibre-plugin/crosspoint_reader/plugin/__init__.py new file mode 100644 index 00000000..9aaedbda --- /dev/null +++ b/calibre-plugin/crosspoint_reader/plugin/__init__.py @@ -0,0 +1,5 @@ +from .driver import CrossPointDevice + + +class CrossPointReaderDevice(CrossPointDevice): + pass diff --git a/calibre-plugin/crosspoint_reader/plugin/config.py b/calibre-plugin/crosspoint_reader/plugin/config.py new file mode 100644 index 00000000..115a57f6 --- /dev/null +++ b/calibre-plugin/crosspoint_reader/plugin/config.py @@ -0,0 +1,86 @@ +from calibre.utils.config import JSONConfig +from qt.core import ( + QCheckBox, + QDialog, + QDialogButtonBox, + QFormLayout, + QHBoxLayout, + QLineEdit, + QPlainTextEdit, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from .log import get_log_text + + +PREFS = JSONConfig('plugins/crosspoint_reader') +PREFS.defaults['host'] = '192.168.4.1' +PREFS.defaults['port'] = 81 +PREFS.defaults['path'] = '/' +PREFS.defaults['chunk_size'] = 2048 +PREFS.defaults['debug'] = False + + +class CrossPointConfigWidget(QWidget): + def __init__(self): + super().__init__() + layout = QFormLayout(self) + self.host = QLineEdit(self) + self.port = QSpinBox(self) + self.port.setRange(1, 65535) + self.path = QLineEdit(self) + self.chunk_size = QSpinBox(self) + self.chunk_size.setRange(512, 65536) + self.debug = QCheckBox('Enable debug logging', self) + + self.host.setText(PREFS['host']) + self.port.setValue(PREFS['port']) + self.path.setText(PREFS['path']) + self.chunk_size.setValue(PREFS['chunk_size']) + self.debug.setChecked(PREFS['debug']) + + layout.addRow('Host', self.host) + layout.addRow('Port', self.port) + layout.addRow('Upload path', self.path) + layout.addRow('Chunk size', self.chunk_size) + layout.addRow('', self.debug) + + self.log_view = QPlainTextEdit(self) + self.log_view.setReadOnly(True) + self.log_view.setPlaceholderText('Discovery log will appear here when debug is enabled.') + self._refresh_logs() + + refresh_btn = QPushButton('Refresh Log', self) + refresh_btn.clicked.connect(self._refresh_logs) + log_layout = QHBoxLayout() + log_layout.addWidget(refresh_btn) + + layout.addRow('Log', self.log_view) + layout.addRow('', log_layout) + + def save(self): + PREFS['host'] = self.host.text().strip() or PREFS.defaults['host'] + PREFS['port'] = int(self.port.value()) + PREFS['path'] = self.path.text().strip() or PREFS.defaults['path'] + PREFS['chunk_size'] = int(self.chunk_size.value()) + PREFS['debug'] = bool(self.debug.isChecked()) + + def _refresh_logs(self): + self.log_view.setPlainText(get_log_text()) + + +class CrossPointConfigDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle('CrossPoint Reader') + self.widget = CrossPointConfigWidget() + layout = QVBoxLayout(self) + layout.addWidget(self.widget) + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | + QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) diff --git a/calibre-plugin/crosspoint_reader/plugin/driver.py b/calibre-plugin/crosspoint_reader/plugin/driver.py new file mode 100644 index 00000000..9249846d --- /dev/null +++ b/calibre-plugin/crosspoint_reader/plugin/driver.py @@ -0,0 +1,191 @@ +import os +import time + +from calibre.devices.errors import ControlError +from calibre.devices.interface import DevicePlugin +from calibre.devices.usbms.deviceconfig import DeviceConfig + +from . import ws_client +from .config import CrossPointConfigWidget, PREFS +from .log import add_log + + +class CrossPointDevice(DeviceConfig, DevicePlugin): + name = 'CrossPoint Reader' + gui_name = 'CrossPoint Reader' + description = 'CrossPoint Reader wireless device' + supported_platforms = ['windows', 'osx', 'linux'] + author = 'CrossPoint Reader' + version = (0, 1, 0) + + # Invalid USB vendor info to avoid USB scans matching. + VENDOR_ID = [0xFFFF] + PRODUCT_ID = [0xFFFF] + BCD = [0xFFFF] + + FORMATS = ['epub'] + ALL_FORMATS = ['epub'] + SUPPORTS_SUB_DIRS = True + MUST_READ_METADATA = False + MANAGES_DEVICE_PRESENCE = True + DEVICE_PLUGBOARD_NAME = 'CROSSPOINT_READER' + + def __init__(self, path): + super().__init__(path) + self.is_connected = False + self.device_host = None + self.device_port = None + self.last_discovery = 0.0 + self.report_progress = lambda x, y: x + self._debug_enabled = False + + def _log(self, message): + add_log(message) + if self._debug_enabled: + try: + self.report_progress(0.0, message) + except Exception: + pass + + # Device discovery / presence + def _discover(self): + now = time.time() + if now - self.last_discovery < 2.0: + return None, None + self.last_discovery = now + host, port = ws_client.discover_device( + timeout=1.0, + debug=PREFS['debug'], + logger=self._log, + extra_hosts=[PREFS['host']], + ) + if host and port: + return host, port + return None, None + + def detect_managed_devices(self, devices_on_system, force_refresh=False): + if self.is_connected: + return self + debug = PREFS['debug'] + self._debug_enabled = debug + if debug: + self._log('[CrossPoint] detect_managed_devices') + host, port = self._discover() + if host: + if debug: + self._log(f'[CrossPoint] discovered {host} {port}') + self.device_host = host + self.device_port = port + self.is_connected = True + return self + if debug: + self._log('[CrossPoint] discovery failed') + return None + + def open(self, connected_device, library_uuid): + if not self.is_connected: + raise ControlError(desc='Attempt to open a closed device') + return True + + def get_device_information(self, end_session=True): + host = self.device_host or PREFS['host'] + device_info = { + 'device_store_uuid': 'crosspoint-' + host.replace('.', '-'), + 'device_name': 'CrossPoint Reader', + 'device_version': '1', + } + return (self.gui_name, '1', '1', '', {'main': device_info}) + + def reset(self, key='-1', log_packets=False, report_progress=None, detected_device=None): + self.set_progress_reporter(report_progress) + + def set_progress_reporter(self, report_progress): + if report_progress is None: + self.report_progress = lambda x, y: x + else: + self.report_progress = report_progress + + def config_widget(self): + return CrossPointConfigWidget() + + def save_settings(self, config_widget): + config_widget.save() + + def books(self, oncard=None, end_session=True): + # Device does not expose a browsable library yet. + return [] + + def sync_booklists(self, booklists, end_session=True): + # No on-device metadata sync supported. + return None + + def card_prefix(self, end_session=True): + return None, None + + def total_space(self, end_session=True): + return 10 * 1024 * 1024 * 1024, 0, 0 + + def free_space(self, end_session=True): + return 10 * 1024 * 1024 * 1024, 0, 0 + + def upload_books(self, files, names, on_card=None, end_session=True, metadata=None): + host = self.device_host or PREFS['host'] + port = self.device_port or PREFS['port'] + upload_path = PREFS['path'] + chunk_size = PREFS['chunk_size'] + if chunk_size > 2048: + self._log(f'[CrossPoint] chunk_size capped to 2048 (was {chunk_size})') + chunk_size = 2048 + debug = PREFS['debug'] + + paths = [] + total = len(files) + for i, (infile, name) in enumerate(zip(files, names)): + if hasattr(infile, 'read'): + filepath = getattr(infile, 'name', None) + if not filepath: + raise ControlError(desc='In-memory uploads are not supported') + else: + filepath = infile + filename = os.path.basename(name) + + def _progress(sent, size): + if size > 0: + self.report_progress((i + sent / float(size)) / float(total), + 'Transferring books to device...') + + ws_client.upload_file( + host, + port, + upload_path, + filename, + filepath, + chunk_size=chunk_size, + debug=debug, + progress_cb=_progress, + logger=self._log, + ) + paths.append((filename, os.path.getsize(filepath))) + + self.report_progress(1.0, 'Transferring books to device...') + return paths + + def add_books_to_metadata(self, locations, metadata, booklists): + # No on-device catalog to update yet. + return + + def delete_books(self, paths, end_session=True): + # Deletion not supported in current device API. + raise ControlError(desc='Device does not support deleting books') + + def eject(self): + self.is_connected = False + + def is_dynamically_controllable(self): + return 'crosspoint' + + def start_plugin(self): + return None + + def stop_plugin(self): + self.is_connected = False diff --git a/calibre-plugin/crosspoint_reader/plugin/log.py b/calibre-plugin/crosspoint_reader/plugin/log.py new file mode 100644 index 00000000..9c58acd4 --- /dev/null +++ b/calibre-plugin/crosspoint_reader/plugin/log.py @@ -0,0 +1,17 @@ +import time + + +_LOG = [] +_MAX_LINES = 200 + + +def add_log(message): + timestamp = time.strftime('%H:%M:%S') + line = f'[{timestamp}] {message}' + _LOG.append(line) + if len(_LOG) > _MAX_LINES: + _LOG[:len(_LOG) - _MAX_LINES] = [] + + +def get_log_text(): + return '\n'.join(_LOG) diff --git a/calibre-plugin/crosspoint_reader/plugin/ws_client.py b/calibre-plugin/crosspoint_reader/plugin/ws_client.py new file mode 100644 index 00000000..99bc890d --- /dev/null +++ b/calibre-plugin/crosspoint_reader/plugin/ws_client.py @@ -0,0 +1,280 @@ +import base64 +import os +import select +import socket +import struct +import time + + +class WebSocketError(RuntimeError): + pass + + +class WebSocketClient: + def __init__(self, host, port, timeout=10, debug=False, logger=None): + self.host = host + self.port = port + self.timeout = timeout + self.debug = debug + self.logger = logger + self.sock = None + + def _log(self, *args): + if self.debug: + msg = '[CrossPoint WS] ' + ' '.join(str(a) for a in args) + if self.logger: + self.logger(msg) + else: + print(msg) + + def connect(self): + self.sock = socket.create_connection((self.host, self.port), self.timeout) + key = base64.b64encode(os.urandom(16)).decode('ascii') + req = ( + 'GET / HTTP/1.1\r\n' + f'Host: {self.host}:{self.port}\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + f'Sec-WebSocket-Key: {key}\r\n' + 'Sec-WebSocket-Version: 13\r\n' + '\r\n' + ) + self.sock.sendall(req.encode('ascii')) + data = self._read_http_response() + if b' 101 ' not in data.split(b'\r\n', 1)[0]: + raise WebSocketError('Handshake failed: ' + data.split(b'\r\n', 1)[0].decode('ascii', 'ignore')) + self._log('Handshake OK') + + def _read_http_response(self): + self.sock.settimeout(self.timeout) + data = b'' + while b'\r\n\r\n' not in data: + chunk = self.sock.recv(1024) + if not chunk: + break + data += chunk + return data + + def close(self): + if not self.sock: + return + try: + self._send_frame(0x8, b'') + except Exception: + pass + try: + self.sock.close() + finally: + self.sock = None + + def send_text(self, text): + self._send_frame(0x1, text.encode('utf-8')) + + def send_binary(self, payload): + self._send_frame(0x2, payload) + + def _send_frame(self, opcode, payload): + if self.sock is None: + raise WebSocketError('Socket not connected') + fin = 0x80 + first = fin | (opcode & 0x0F) + mask_bit = 0x80 + length = len(payload) + header = bytearray([first]) + if length <= 125: + header.append(mask_bit | length) + elif length <= 65535: + header.append(mask_bit | 126) + header.extend(struct.pack('!H', length)) + else: + header.append(mask_bit | 127) + header.extend(struct.pack('!Q', length)) + + mask = os.urandom(4) + header.extend(mask) + masked = bytearray(payload) + for i in range(length): + masked[i] ^= mask[i % 4] + 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') + + def _read_frame(self): + if self.sock is None: + raise WebSocketError('Socket not connected') + hdr = self._recv_exact(2) + b1, b2 = hdr[0], hdr[1] + opcode = b1 & 0x0F + masked = (b2 & 0x80) != 0 + length = b2 & 0x7F + if length == 126: + length = struct.unpack('!H', self._recv_exact(2))[0] + elif length == 127: + length = struct.unpack('!Q', self._recv_exact(8))[0] + mask = b'' + if masked: + mask = self._recv_exact(4) + payload = self._recv_exact(length) if length else b'' + if masked: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + return opcode, payload + + def _recv_exact(self, n): + data = b'' + while len(data) < n: + chunk = self.sock.recv(n - len(data)) + if not chunk: + raise WebSocketError('Socket closed') + data += chunk + return data + + def drain_messages(self): + if self.sock is None: + return [] + messages = [] + while True: + r, _, _ = select.select([self.sock], [], [], 0) + if not r: + break + opcode, payload = self._read_frame() + if opcode == 0x1: + messages.append(payload.decode('utf-8', 'ignore')) + elif opcode == 0x8: + raise WebSocketError('Connection closed') + return messages + + +def _log(logger, debug, message): + if not debug: + return + if logger: + logger(message) + else: + print(message) + + +def _broadcast_from_host(host): + parts = host.split('.') + if len(parts) != 4: + return None + try: + _ = [int(p) for p in parts] + except Exception: + return None + parts[-1] = '255' + return '.'.join(parts) + + +def discover_device(timeout=2.0, debug=False, logger=None, extra_hosts=None): + ports = [8134, 54982, 48123, 39001, 44044, 59678] + local_port = 0 + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.settimeout(0.5) + try: + sock.bind(('', local_port)) + except Exception: + _log(logger, debug, '[CrossPoint WS] discovery bind failed') + pass + + msg = b'hello' + try: + addr, port = sock.getsockname() + _log(logger, debug, f'[CrossPoint WS] discovery local {addr} {port}') + except Exception: + pass + + targets = [] + for port in ports: + targets.append(('255.255.255.255', port)) + for host in extra_hosts or []: + if not host: + continue + for port in ports: + targets.append((host, port)) + bcast = _broadcast_from_host(host) + if bcast: + for port in ports: + targets.append((bcast, port)) + + for _ in range(3): + for host, port in targets: + try: + sock.sendto(msg, (host, port)) + except Exception as exc: + _log(logger, debug, f'[CrossPoint WS] discovery send failed {host}:{port} {exc}') + pass + start = time.time() + while time.time() - start < timeout: + try: + data, addr = sock.recvfrom(256) + except Exception: + break + _log(logger, debug, f'[CrossPoint WS] discovery {addr} {data}') + try: + text = data.decode('utf-8', 'ignore') + except Exception: + continue + semi = text.find(';') + port = 81 + if semi != -1: + try: + port = int(text[semi + 1:].strip().split(',')[0]) + except Exception: + port = 81 + return addr[0], port + return None, None + + +def upload_file(host, port, upload_path, filename, filepath, chunk_size=16384, debug=False, progress_cb=None, + logger=None): + client = WebSocketClient(host, port, timeout=10, debug=debug, logger=logger) + try: + client.connect() + size = os.path.getsize(filepath) + start = f'START:{filename}:{size}:{upload_path}' + client._log('Sending START', start) + client.send_text(start) + + msg = client.read_text() + client._log('Received', msg) + if msg.startswith('ERROR'): + raise WebSocketError(msg) + if msg != 'READY': + raise WebSocketError('Unexpected response: ' + msg) + + sent = 0 + with open(filepath, 'rb') as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + client.send_binary(chunk) + sent += len(chunk) + if progress_cb: + progress_cb(sent, size) + client.drain_messages() + + # Wait for DONE or ERROR + while True: + msg = client.read_text() + client._log('Received', msg) + if msg == 'DONE': + return + if msg.startswith('ERROR'): + raise WebSocketError(msg) + finally: + client.close() diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 23ba36ba..1a5fd972 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -16,6 +16,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; @@ -108,6 +110,9 @@ 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); @@ -145,6 +150,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); @@ -163,7 +173,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 @@ -189,6 +199,28 @@ 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(); + } + } + } + } } void CrossPointWebServer::handleRoot() const { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index ecc2d3d2..f985c8c3 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -2,6 +2,7 @@ #include #include +#include #include @@ -25,7 +26,7 @@ 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; } @@ -40,6 +41,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); From 8114899bef6e40decc1d15d92e5767c46780be6b Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Fri, 16 Jan 2026 17:41:55 -0500 Subject: [PATCH 09/13] 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); From e2124ca7a0406fb02c64944a0f3f4cb094344da1 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Fri, 16 Jan 2026 18:34:54 -0500 Subject: [PATCH 10/13] fixed basic auth for opds and added more calibre commands now supports viewing books on device and deleting them --- USER_GUIDE.md | 13 ++- .../CrossPointReaderCalibrePlugin.zip | Bin 0 -> 8855 bytes .../crosspoint_reader/plugin/Archive.zip | Bin 7816 -> 0 bytes .../crosspoint_reader/plugin/config.py | 5 + .../crosspoint_reader/plugin/driver.py | 101 +++++++++++++++++- src/CrossPointSettings.cpp | 2 +- .../network/CalibreConnectActivity.cpp | 56 +++++----- .../network/CalibreConnectActivity.h | 1 + .../settings/CalibreSettingsActivity.cpp | 4 +- src/network/CrossPointWebServer.cpp | 64 +++++++++++ src/network/CrossPointWebServer.h | 1 + src/network/HttpDownloader.cpp | 9 +- 12 files changed, 220 insertions(+), 36 deletions(-) create mode 100644 calibre-plugin/crosspoint_reader/CrossPointReaderCalibrePlugin.zip delete mode 100644 calibre-plugin/crosspoint_reader/plugin/Archive.zip diff --git a/USER_GUIDE.md b/USER_GUIDE.md index b411140e..edafd05a 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -60,6 +60,17 @@ 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: + - Open Calibre → Preferences → Plugins → Load plugin from file. + - Select `calibre-plugin/crosspoint_reader/CrossPointReaderCalibrePlugin.zip`. +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: @@ -106,7 +117,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/calibre-plugin/crosspoint_reader/CrossPointReaderCalibrePlugin.zip b/calibre-plugin/crosspoint_reader/CrossPointReaderCalibrePlugin.zip new file mode 100644 index 0000000000000000000000000000000000000000..61ec171d9f8876c92c7204e50db004a606c299ae GIT binary patch literal 8855 zcmc(lbx>T}8s!^zhsK@Y?(PyaIKkarf(C~mjY}X92oN*`cWK<+32wn1g4@8IshQ-} zyKnCNGw+-_wX3VT*7^0`wZHn-Uac$-1&s}Wf4&<6SoHwET}S{jfT5wagSDHXA+wX$ zJ5MA41oQ<20Py^M06;?>1pvoVXk(pGWn=v^$}aI57{f zFr)U34ly{(G7TyyZLY7&jmR=GODDj~DJsY^1--V-DxR%gR|Nz?W&xQ2%JOjV3`4>Y zlFu(NdVZAu77q9qoT8Y-8@0DA%!dDvb9unV+SCZg)Y8br=tNOL^8`&ZTsZ(T4iAb3 zoe;vA96k6NgdYwWLryNHO#QkSnQTj1ts@*AS8-%?TAq=%Pj;Gtc7R2GSbj)e$=t}y zgtn>rP=9gexzKgem4{+w2XXL3_V2McSXIfm|aYl1Iw_{SA2p=Bv5Jh;xe% z?>~EWg0ahJX`+s(%xdx#%o|pHyhVT2V5)vy$s}nhT^cZPta!45iW^Hih5U4U^$y`? zkml5M*Iz`fqs#c@yl6(}1kiwWc9Gc((&0fejIu9xdc(H}tbXt{3pumA&c3s)q4!<2 zSS!i_6RoVrtynt)&J&sMjoGUzE~$FTxmtDYRT+Vx-r>U~tj668QW~6Mw4`FR25dQv zAQy15C}N!K-l;Z(k19vWqk>$v+gS=i+;_i*K25FJxn8^EC?oEgVEwWl?i_j2_0At` zEVhr88ovyk;y_=74Pseg4)%gMRxt97%vOR&1`ipmRM8c^+qx+%CF!bb;e4y%F0efx zojOf#i%qAyBo>%9)g9~JLM-s?U#h)>!7R|Wd?SG6ifgKRDc9Zv=Xjo?O~LeyDU2?v zsV)8JcAM-IB$)gj;8`Q7m69T2*hAKN-JlnE&4CZa`V*|De?6py@k2iu<%c__k^|oy zo9MC${bbTIxpq#3LZ=UrbVC*Q)LgmAI{bvB?^ixD>13s*EYtPF5&KbNEq;5AQRTbn zasr)PmIDO8jkuP9H!eKzi!Aqam?gmi!q_G9lW;WJ=}w@9uQG8g%Td_f(nQW@-4m6_ z)!iD)5Tr~DgQ5$JC$W(Ts$?=F>~TCGLtV`{Q*(SZ-u)?YVi8Lf3I27PiisQPHLGGr zvT3!F@gRLuJQ&~A17eXu0;N@jF(SG4sKv%JSz)zg5Kr83E#Fu?`-j&W5b=f399c21 zPV&79#aGjc`Vwe^F;2kH8algL^&W-pz6A{esyxc2gt7^ zk+Cj$d1z1~@hr$QgMJiS=hmk4x$WFDE4&oU@lJWL!R_rvQ0y%vF~F} zDdnh5#A5a)?IftLqs$DAWv7*SB;r`_GuIkl`S?fngl*;Bx2E@dcuZ@K!4czr7tnOk zK8@F;FXiha#<5i?6ac`4{CoNO*CK%ZXZiX^_29ozyqNz@@iKFruJ%NNI;LUV(=vW! z!fRRg%0rw&XUnp_Pisysme?&!F4?b{WtwkO)^#0d<}Tswq%$ns5z#TCP_b6v#yRn)Fa2$NopJp<)fSUvSOc`-~{1HJm>(z4L31; zQw9*M2EC@8fR&7O8<&oPKdz9#q%r=%(j!p%;hmqCdks9xNL>5wB;R{_hf-WEOP+$_ zz>L^}kpu&QR-O04+DsQr+A5{P$x6b$h*x$@j-_Q~z+&_I<{P|BeMWSnS91A0A}fqq zYbJg!K)=i~r4c1fnsOf{HS5|p%y0ZYv2<45l;IYfqaRrEwF+h|VDFW@;c0!}%&+}9 zG==8NwoWamc=PObZ@rS=sO3ajdJ+LaBGVb?LyMU;J2{L7p{cBNvBLF6R^Y_lA@?dY z5fRxaJ23E!_#<4@fTWUdDJl+>8ATi(ndwDP75X z+6q_P%cZt3hS~xF+5x1u|R;9NwE+~j38x>dkOsqbJ%a50Ph64xN9 z7=J1yJv*nHDFI6)3suQhE%A)lx097iGI^u^=n(KFgOwPqloqR;yFj z(=9pd4pyL0uPTLsn|Ip0j!fCLyfXr?lFYnbx^h@ZIx!#31D}@1r=Hsu9~E^9xu#;J zq~~_VkfU6AO9!$1#EU?j?h8hVhP-rZRH>_<%`Qj!4{sUZX_3i-(gZ|NFyzAP%h@G3LAGC;GljFaoJ;~X$%e@T zt`77@IMP)&gRE~nNyBloDG=4W;$>Q9X-rgKXDeKS0Z0Xuj)M49Auz-VJG082C;5fq zNglvHc`bJxW-)zsCC=)bprjg1V zSQE0oRrEU=BrAAH1~m*=W+e``JHBfBI!Bn({QCuGH;JAYb+MtryyoT-(%Pt-aK3()p=T8n&G@=@v zES%Z<^@hlCuI7BfDusAo7D5nlZ)|_K3?^}udl_4YUCnJ2&<`s0e9W? z+PqUlUZLjpCmR)QQ{n~7r)Of~a3|3^5qrNOkS9I<6~?A}W&WgRpl`#riT~ES)1f!_ zxw%BAtT0vU8}m0;*ca?!cZmh>+`(?O7Y>gQ8R|~W>j0-6&6&bQIw{K~!%(?1y=Jur zr^(HUDn&gXfk>RUlR#P=jl9qIq@PT`uGpYs3hzb8@Q8mBK#rApFJLwa*O#O~XSHIk zYge7blr!QTkjni^Xq657j4_9Tn{8ioIV@E@(4?!kF^}|OSkYm@2yr-KRiV*hK1As_ z6?o4Jk-ete1wCqsR)ULlR&m8%`yJ0xlOx@;L%kYdpG`wIsA*r*3T|bB=n#I?X2$!X z3>IGb7fFhxp6J$a_Xy!tG@2Rmx1FTS{6g2lA?Kr0O-KtWbBr-A6d=JycB0c&bndGs zumpjYppb0kn(7olNj`>c-0H*k&av^3gq`jzF~{$db<4Ze8T8$gF8E{(qCh&WG4|UF zNNI+yvr}8amJC1V6BYjegHZxsMMucnW7+m;4?|#ZBJ4e{+Gt@2g&Wctyqv){sO(kj z5#d1DX*z zHAaTFn^1nriEY?Qy}pd6lk|jNJBLn?APeSM*(COS74XL(C$tYXB+4)*(KYo}%vHi| z=~BL-4i2BUXWbKr<93{>Xw21WNhKT-@$)V=nJ9;)rMlSM*`X z@KAd;1l~`R4DxXs;3)7l(G=edy3^=5(|2)#re*0z1WCd345dt#*dMo}Vk z(L7Rm9=J9=u^)MU>Lkf&=GL^$gSk4Gf|`FU9}MAF-7kOMb{TKN(W3c!8FSQ>G+>f+ zsS8aWkDE{Ihhuxe6GjrRG~_td1*2#sB8cmvb;vpzd8yNFZFkAjICVGBWE#O(*`Ncf z!CK`Kyveu_M9tyUJ>{`@|JL)Pa>ELty`wY=bKc}wBB!Ml&Z_qTY8u$Y=!B?74EyOM z;ipTpP{LspFuMt+32immZPI5OTPh1umIiJo8zs=gW*s2`atrF2Mia?uuwtw4N`IiP zN6Y7g-!FT4GcV|N5B}0(S~+r@A;4S4@39uLpLwi8(*$FUtw4RBInS{Yvi-RNCvZR0 z#wB{#^RDt&I*98d%u#UE?L~Kwsr+T|RzMlwa!z|%xGZj3JU7#7F;g_~TXRX=xBIxQ z_{`)1?rYr}Z+~}K#z>a>!1zi4qqO^2`a8S|Lx)@iHX06VYBXvQWViWu4GpR^AJR$Y zhM>Rae2&i8yhCcvOcnwMDx)xFAYMV`q3H#KokWeK)d?`0?t-ZuxB0 zQi*Ay-fT;xJEv7jQ2RaknRX$87fq<#mtC|h8p*b|-I4Q7MF`Uza{86l+V1-)50kH0 z(I>xnYV!AN`97ugSLCKVy%&t=-9NQ_XC%Pj%_GE0?Y$Tv`0Fm#SZqIhM}gh{_O!S4 zo^x?Em8irSbqA$y5$%ZJ0y-A**4Qxdrn5(7I~mTnkb~YvsEEhSt!b!T#M`NH0dg(w zqA?HLlR~2^wyw*jmu=$VpoK)=4(Wi`mtqcy(mRCYmNIuE5!qA90Md*wXOoS~&0D-4 zNiaKD{&JbqC?|awI_Jo^r?_(Yv?Lu2*K~?Hfe9y;D-a^tP;kxcG0HgV9c&Kf9dk`A zAM?+th8wpp%pCv;o!rem^1QeRUy&qZ9wY$Z2lekR;$OS*e{m82s4@RF-3ZqBtI%5za)$f97|w7`uhR#f_j+1 z>!pRM%8X^t)|HK?6vf#~*eJU=bc&n>1ot=#$*!=baOPQ4lcxpN>On~PK9Km7eK)}r zf7#eJ^nq(Fx7Vch%y1yL?=Gw~nB&Ui=+)GhJ{|gMW!cF!nrM9FL2$fM5(wj`e%Lpo zkyL$c@#x5gJT#xTSR>>uCIebF=Q8H56%8_>Js0S(D<7DcygADZAbe>F1a%u0$^No~ zfP;oX@bh^P1;prXEn_c7r7F4Xlyg1ES(osuAn(t5`^?Fn05 zKl?CJIvJntm^@E+7x51OfgLdM>XF`(TRRT~vyu45x&xc(o(vN1DltOTlo=j@8nUV( ztFSHY)`A(SqntK#E5QtE<|nQz1`dLJpJ^EoNS2)Ce|bYOTOYek0KVn&o8eABOlT6Xbz;f)6vBheDD@!xso?W9l~Q<{gApCVYYRn0lz3WYP9BaiA0PRi zKwJd92)uk*$VaMNI|7+q$=g&cmo3~OHNthy>}vLGeZmq)LicYsjpk?p3XL^bM6qu% z^D(|!AKBG^k=#vd?0pcXRd24n7mPA3ULKiiH4@+osq|~9rxr;-o1OjQ??!yZ^Qdcr z4{+Uwa^j(m#VL5R(Hl8ka0$CFU%Tqp)$@}ODR-4v)G`^hN78;L$G5v9m_80wxDw6? zx_V4%2Kqr@qe1kD#Jw%8?AAV=k58V$za}S(L#C)<$`Xa-CNy@YTc6|y_t{6O`1U$M z`NYrTEl2x-Qj38|kLkHEw+s&MXLdNaoN>r=U5BHvJo&TEA|u)yS8qw%7H=k6&CQN_ z8GzAJc6L&8P6+95Pbp;iCPU*@jp}WFu!!iS6?pX55ATZwhq@Te9}dg;1ZwE6FJOj{ zCwR2CmNKfbthppEejLDs=0iFz6u^to(FVlBU%QLKkxi7GYL;KvQ1Dn)sM*#3lmOjp z%+CbmXq`^87PP5>>92zum`ehdw}%^AE!>>^O}@t1WdHE(s|?Z$hr6v06rIXi+6k?t zI_8@3`W(>e2<5o>uHCr(2q}5-)OHd@flUuVU1~BvmGpBKR2nbTYOo0H_fJpdat~HK z>&7Sr4c}lXob&J}4m-4G5Xh}QBgtMNh~G$frzlu5Rw73S&A0hM9pd126Qn$nsaHz+ z0kYcfyf@}Y9mUY3T(2TuflMN%Q(jeLHt6Y#+eOY331SazFZyS3h#mL5mO$)ZKyVI| z$xp%z-mt{qiP6-%&%U%Gsb@zgCM7050u2z8Ts4ukbD*o%8Y*pt)lJC!AS z4he*Vm%scd<7evxW~r5Q#iY@*jFT5yO9Jt?tUe+8q~J{wW*M-4E9B8353~fX#I4Y* z_cBuZQ+!P)=92g7V5rdoA;z~B7f#bkm6u0i<$H_Af43%8-YIH|&o8^lH-PIcl}br&j}Th-d?uQZBnIKcaZQFz!wxP;)3_1zg~6&*By z28MlsAtJmV`nzhDq4Cmu#MKKQ{Z$v7!Jid$b~P0YAp%`&;@T`3kT?56@lW%i_g#{u zk;%&{P+Uck?i#zQEt-q-vNb`fp>mU0z)jzsnb#GJTbyLA`%;RUlC1ndsgHIuQJsMe zoj1w;Jct2n!#H)75Awfk>JEPf#(x0$o_(b9$>70sQ9kzWZ#T8NW_Ag}yK*bJNf)YdCjz`R^aG(x&NY&X1vLhnFe zs`yl*>aI{7K~ORv@>4)?nz#sy=;`i!rz}SI)3-TV78kApbq>Tq6$;H^tblMvAscpG z9;&G(bJkGQ z0niV9B6b~A$FPx@-4YZ*oJHKd{tGW=rAbM*jQTLjbfW0x&u#7QZtJLM_Ab{JtN}W_ z1Srz=ms{5t5!g=2A07wP5)HH$hT^}zSH9T)1-=?A0NFO_fg1(yb~2s>g&uTU=Rv`Q z@AI{54)@N=NP-9iz`NZk#h}Uab2`*d1}zT-yPLZ=bGy^;Y<>8+33z25YKLLe(Iu=@ zkz(zVVb!S|>b$g?t1X7|G)UnmlsCBKSwOrSxSzz4i=b@#HH)WgIQC>PQi6TWQ7y?e z5>vg3KqWV*Eg@Y$F~&vWW!OS%GO@s0jVFASD%;K&JbhFjQ2C9)^6ejs;!KQq=fAw_ zR*RI8TxW;0^*H2|F&u?#IYVOe9Yg}0^GLqAPFbrfAGNf6Xc2TNbtO&l!+tbm6FjTy7LnKoQ#o}99InI4*#>*(^`CMYk2@_+zooZat%86cV=f2L} zL|=hJe-qc&GV0 zm2VfYwFEw(pw|9!tY2HJN$RaN8pj;1Z-X|Caj=^2saGOmmIgQct0H{fd$QY9!{NWd zC2FK&K{ksW!_4u0AdHkZNB`zLtl(it!*Hd3{qyVB9=QW0uc{(_%s^xpqR51ZAXrB$ zI-6FU8Q1;2TQ_eZKgNroy0CH>lC=5E@6@|4lF5?1gc!UhfT*{3if<2@#Y<-kfGRmS zZ-wsdjN_*A8O=_K)qT?OX4}7+v^V+YlNe&Ue~HkBqhsms`N^bf+1)8HD2fPA-0mFX z%50;rP>`Zl+zJ;@?L$ig{e>&k?|^j1fB4lh`HaUxN7O|BUQtA9U!L-|)!{M_?Yr#WJwpq~*v$dsH=Q+Kb& z-U7Vq)e2njxOLx`t{jrv+d30@u{34TYQ=X50KgXI@15|!HkG)4cEbO(zx@B%4?{p= zL;at`{P(yT34r(9>i^FxH}GZrPq{P<0OBP#SC;=%@b?rM_=jM|UxNQA^Zqy5FA>!5 z;S|Cj+FB5R|By;Q%e|!0zoWj${f>(LhnybF%WwbRsDGbt{Tt;Mm;1Z_{Sy=$76ABr zqx&rPl05wm`XcxDpg9vu|wKeTMI z{-FFDfB0`?U$)op8xQ#pS+eIpa{R{~_N@5g+VMCYu(lR)ZTZky;VRku($xk`@1QSLm%+yLIF?$jErm?ZQPBF*qyy! zd7%KHV9%fcfcxJA02=CO00iJiTbt|}TbqX+#4Un;*PhyN!zMR7@GHEkff(Ldyw)nsd9W{hWMWo&AEq^O{IgrOO!5(u3@2*ZF$ z0_8%58FB$8iiD1%q7ql3mmNT*+jgJ+F1Q2s0jA~rMX`Zdzhx8(-J>s z+qKpaJu=7abBEwsG4@wDxYCp6*|oyBVd5Y&Fq)*T9J3Ps_2vkM+#9B$so0(YqV|k+ z5{CjWDFG9A8mdwr3+55HwP~^s@hJ(!er+w6_3o*k=S}bC;3?m)^mCcF2c*(%VdV6l zE(1l?x_b|dkBjGZj{r^BC&!tsk-B{0K+D5UXGH;h{91!mPKirlp}qEU%>vJ5!UJXq z_?X7yG8tcx1MI#%WWH_6hEr3;J5t9>Wm)ri|NF^zbb~)GHuFu-;#|+@(~o{=Ev)_dT+C>4r`=213yoqd$bcCU>W!jIE5peR&Qh2 zN#DUB2zX!rdL77QtaiBk6VC)0we&4k!Q}Mn-Tdkr*QrgHqR(_ZyG}ovZY5KZPg_6o zVtv(;@*JM*Tgw%Hh{LlqC!A_6lK`Q&yrrYt10NvF@h18P?9C-g;+$|k(iiAQrg7s+ z%4^yEl*xk&reUw!v7Z4XmuLE-;$Pzj8NikG{0#cCm6VJIv*oGKlMx4@bK9apb2ATF ztQe?hbTCphUq>+Esg+&Rr74ayxDpYRKkansIP1TFz{CM@_Y(_od|~}^%<~yFP2-b+ zsq;dUaU>-$tGb$}V!qfHZKlDvgNa!PC&?ED7Gb2N-fx44B-v(mHzQ*>N z@lvO9j@34o!|k$$Xa+Nv6EiQ<%t81v1?96ZHq5^^Ox7W`3AwV45Y;)DSfNHNGi-1e z1LwkRRFXN!j|h7;^AoEKTX+3Y2=+OB$_A-aHm6JSsFg8UHoI7O&e0DS$MI+~*MQMUu-=6^lc>_e4F2j zdF@+5L@2&{5U9Ct)ZG=-wx^Yx7v(L)tKI`tq@@nAnnb};AvrfpIm5k8U0+OCvFS#k zMsXT)B%i#?&Y@Ye^G^-k>4euBmVGB#VCHZ%Po>*U5fU}E>O;#kcI^I>;GqKL_r0TX zfdK$6kRMl|zveOS(+c#DQu;qpf5QK!{+PSkcv!gprv9Mb*B$ix^|04z>p3m);`*Og zQhq`h%F1h!rU*AB|I&YenX3pbxJw=!m`ewlVg^IY(Yb`&oM%hE#AX_jyW+^Jqj`ri zkxh|ufI`KCW&1`mVAp*@Ca)aV7rTaaMV6fIMkKZ=y77$!vni3XbVb2nY9s$>&UWSq z8`ztznj>_iY`Ng_lWZ}suSJe&qSH(^(ARdp%N~rZ1JFD9_p=26JLcVh zY4d@zQ2mP3oCQs495g`?HgbI46ytchYMhsa#5Y~e%m zvq({$%(VEf@|-6T-^H@10#mSEQc8-8Q#o0=Ko? za{Vncs<$pLZw|j^vZt<%E2ZInw`vm-HE1Tbole{95H%RpLh%u~Yv!&pFeqC{Be2?; zgCEi0sLoi$mY)%tdlMdQwS>f!-d>YpUNdaLA4kkC=`=)%Q!ojjp72H;I#+PyFM0mn zQ^LlWpWu>X#k~`gOjmq{R--D=l^-y|oq0u?l*hrAzbIEWN;I_SpUiQ>PZbfrHMLWS zwDv}D*sXquG;1kc3uPS%NfN&>xR|(RPzjbeSJ>b@qncB zsaZadtHJQ{md8mdYRE}A%pN>*O-Y>P@(Uip@?nNQV9>UsRVP-(35A2@#z$k;vO0xe z8$=LjJqNy)4burJJfB)f%23s;At{B=3D%Q|C16nd$*LTK5c1N7jPw%VZtT6b>NDG%kAua}8OP3Xplb*-K__haw~m(bD{?Ldb&doW#8LWV%}Au?J@K0? z2A1zuV#k6zba2i%8g1f3muQp_HrJ`Et`{^`KoK1~pXl0omf{37c`nXEk#mM{9B)Zu zu2(N+FaqLEv`^dQt-YSR)e~IAljD^D5cGbvQ2DDTu4EB(kN&`J02PwsLeWEvlv=jMffR=CxKcs z0f1^8tV;oqRDr`G(6nUS)^gGd681znVEEJUOp8i=m($Fo_E4mb_QlW`O8gZ}Yex)Q3*Ugss?#U1Vs zooIPdqnrVUvg0>XytQkWCiri2Y1PTtv~RL|Zn$f#C7#Qyl@a@B4Di8OXd0LY7k0tN z5|+=3wRrv^K#z8+MekA2nzX+GR?pBRv@YeN!9s8EkZZ4*_EB5m{ua`t>K^Zy)xCZ# z^Q}L0MUu(29Ezf}K1**bPSr_RLQZSuMdV=}eByutJ#Pw-TN6bER?Cv}XaJg+nAn$Q zeKpWZMp%9jig*9Hm)l;W>!4Y*ra2So#C+2BC7$58$%icljVh?4X;za=qLlrW!iE-6 zj46vjoVCLE%J??~N?vT-BOjSRovoGZth~8FHaZl&5Op>Q4k)(;K;7I;>EmY@BfSC} zqeLKvl0`&xC*=ZXd-J2b8)gE0el}Bf3Tx{@y>^E!+x2R&d4?t;Aarby5m3Z@+ohN4 zF=i5KvwrkTdR1BU#^uMwelD^MyQMT#5izM7qjEPw;h-8QfZQ`7+=@K96@Ct49h9?O zvf6C?9fN^@^pHMc-%zjBLRl8Eha6GEalrX7{B^1?d&c&9d@bV2X#9Y17i?<_F*$jRo2;UrPul_$)l$N#6;6;FX7h4! z?tXC}rW<7le^=daA$(801?E-AD46VhwN;gsrRydNE7IALeiZbH>SJYz)AJvfUsAXf zpgeO88)+Q79Qf5#ZX3P1UrlvU0{w$ohFt5us&=odW@BhiYpA_=iR-R!5A>|3dnRS# zBpPyd-C*ck*zx@jj~s0)<8)5e84pO=ZOw${3fI_qU@;GT6kiSfFTeUUrw{&6nY?k% zwp((9ym34!aQh+Q;dN%4RXEpOuAQZ)dYsxURRdwq@`dq_UPWQ$=}!9eE}c^0RCTSD zko_9>_PcS1X9@=@b|q8;m_$NRmr>l{wWkF$ViGH%aWH#4;|W&Ea;fI$?nT0KgGG)*OF*cmG9m{G-?Se?oH<20!&Y z@b_BD-syLSXQ$eO4qK)g^^nh+QXHb{9Rz+@RogtP8b)+p(=?xfb%OIk} zcs$)eM&X_Eg5gt*S{Ttp5xXel47h&4;_v5Xz5{SV*sFnUdn+ zAOSy`M%wNTAT`wEgZyhiu%3#?|2;PUiBnXMetL?i_oO}DjLht9EF9f`BOb->9k#^% zdf4lT?A(?bk6ZlV936c^i8C37t2aNsw0vFf)+9%=V_uXIH$JcqlcWuzMNy42$hq_N zgi3&wl}}wNrbnkUtvV#YKYkAK7KawK8ptzb4Jh!wWV2$y$Dga@ZnZe840ifnM{QVR zSV>TEBg9~&<4k7^5P(3=GD_4Sf10*LMLlh1!KTk+}?fZ;Zim2J9l*={JI5zdpN#LU@Me9mjQ>U{sA{gZq6g=X(Q^awigxlFMcbj6j9vIvmn?ZR|p< zJevdi#vG~L%;tex5k~da`WvAbv(nY^&mG2sJfYS8?Tz%JNf-+YIRWlu=X}5POo;$) zdoa#?^znE_%9{hxb45Sl_dxYu{CoSiNKo>>kcnBPp!Z8TeEZ1hq7JD0kjo`g+%#*2Mt&Igq31Uz5if{W<54%fLhMd$M6bccoc!2k<> zthBwo^k-+JEbU`zIf0q5L{;NP+eM(LZf23^P~+I1cu1J5F=T&C-Zw}?Z(|8Nlq$)y ztD}Nd4Y=-_yqr2r04soU_)(BBPFDwzh4hCn%8eysWF%$AzZx=PKyaZ3_>qXC%@ z{GfF_$5GU&*2a7h(!^dKxVkge)M4rF9AKIkXZwE9Z?HO8KN8`pF-UAScja4HJ?$aS zy!YF{4ks9=udljHx(-lMmXGab&=j8OBdJTz6sA+W&4tP2hxrmB%2<|h_euWNns37d zt*EI4p4z2=XnMa}XC8^l`YnpwIg-Ssq)(cH6>BwWY%rwLA7&qqppQ817o~c&ls_Q1 z>)K}%GT|hSA?Hx-^Sz3=_m334w zvTZeIvGVz|9{gOj^4_>iX5bXnM;j@8qHXI8RNpkh8IoK>j*^djx>P|{_-hGk3>yQi z^a0d)S!6sQ?{1blEiiIoN9o5oM(IzVqHzj+BofPPC{(@`wS$n+!4HYFdz-|V2}>5g1br*`fHfn2_O{UVv7m8QBSaHc3!C8exOs ztiFWdDUSBg*i~)UT!zTi1*?Y1&*0#H_4_t2TgAG~P1&(0t*9x*@f=?|)qXyvC#b3C zG9`cyIdFXpuc7)D^oMQ3{#H<8IN0wbmDV?#58GAc&}XR2%xch`ev*Jxfx7dZmtE@4 zFX-Bv6R%WRt4up4{amR1{rVSyOT8fhss@^mXhTfGhlIE3VN zp-$a>gR_!6m{_oFw=b;}Jac-=gq~s8ep|Hrb@%e~?%XRoUjbfXewo|)F*tQhNo!S< zc>5H1by~*;Z>`o^%h3W23dCuZO&$;s%)d#HA%R*9V>hH(I%~`IT?Q*H#LoiVib^9n z-MbiEeu>^5+Pj4{C7LMnEUfMwPTO|#kwEp!9TzOVLE3Qi=O%5SuGHcLQ)7Ne4qcyG zw2agSC$ydC{uZp^gk#M25CNnGV!Zm#g9l&Az%5tQ(j!Aqmb922zCP1LIFs}#R_WhN zrHqUDi`_Tp7;YuYiPLOrSU8c5Bq7_37@~7k9kR!&-SjK+-%ijrb}lMnPYVtrw=~id zWL}9{#6vm_3_z0S|T;N#M8hIeshaqIwvj_vfc~oe)$k;{pSiE{! zat2>d>K%0&hg==n!Cy`Aa9VHZ*P`N9Mm7U#qI_R_aoW}*5GfOo zHZuWH&EqGrKlt5}M1w3aOI*ejJdGGw&ebor^78uSe<*p^6dPa%p}LYrCq)IrJ6SW? zcHqss?R~#;_YwAIJqvD#_yk9u33*pWzw0WMBE?UF#eW2d(Y{vH-e;GnSSZ4O`2kN` z_{QEOVYZOf{FqGLH;ZtgtHiXc#jlXu2-_nkN(X@nIMlzzrf1dHBRC?4j7Zky66eNl zYoJh+rdHa4kVx;#$N-ze6XySeVm@GuZk1}@bE!LKdgyy`RQh0n%8vE^Dn3S;$b52> zocGeM3Zo%e(M~sOZCrv!%%OI!B9MYKJYdv;vwK zR)JzOoN5dY`flcY2ZPaVBW8Bj<<}yVAGx7ULcRz7^J_dOEq;bWteC-OR3w^u-}@ab z+jhM>@RvNVJoa8xkIH}FJ`sIT!j-XVrPoLRz&6ceCHz-WN$^w&|5JVW|EY(epmAaT zXGHdRK7|6fw`>1>%IS_Md?NXvp<9*bfcs$E|Fn zC)iq0fPXWFx`%rhKRu@U0QZ6ZVz> diff --git a/calibre-plugin/crosspoint_reader/plugin/config.py b/calibre-plugin/crosspoint_reader/plugin/config.py index 115a57f6..18d4fc94 100644 --- a/calibre-plugin/crosspoint_reader/plugin/config.py +++ b/calibre-plugin/crosspoint_reader/plugin/config.py @@ -22,6 +22,7 @@ PREFS.defaults['port'] = 81 PREFS.defaults['path'] = '/' PREFS.defaults['chunk_size'] = 2048 PREFS.defaults['debug'] = False +PREFS.defaults['fetch_metadata'] = False class CrossPointConfigWidget(QWidget): @@ -35,18 +36,21 @@ class CrossPointConfigWidget(QWidget): self.chunk_size = QSpinBox(self) self.chunk_size.setRange(512, 65536) self.debug = QCheckBox('Enable debug logging', self) + self.fetch_metadata = QCheckBox('Fetch metadata (slower device list)', self) self.host.setText(PREFS['host']) self.port.setValue(PREFS['port']) self.path.setText(PREFS['path']) self.chunk_size.setValue(PREFS['chunk_size']) self.debug.setChecked(PREFS['debug']) + self.fetch_metadata.setChecked(PREFS['fetch_metadata']) layout.addRow('Host', self.host) layout.addRow('Port', self.port) layout.addRow('Upload path', self.path) layout.addRow('Chunk size', self.chunk_size) layout.addRow('', self.debug) + layout.addRow('', self.fetch_metadata) self.log_view = QPlainTextEdit(self) self.log_view.setReadOnly(True) @@ -67,6 +71,7 @@ class CrossPointConfigWidget(QWidget): PREFS['path'] = self.path.text().strip() or PREFS.defaults['path'] PREFS['chunk_size'] = int(self.chunk_size.value()) PREFS['debug'] = bool(self.debug.isChecked()) + PREFS['fetch_metadata'] = bool(self.fetch_metadata.isChecked()) def _refresh_logs(self): self.log_view.setPlainText(get_log_text()) diff --git a/calibre-plugin/crosspoint_reader/plugin/driver.py b/calibre-plugin/crosspoint_reader/plugin/driver.py index 9249846d..846206ff 100644 --- a/calibre-plugin/crosspoint_reader/plugin/driver.py +++ b/calibre-plugin/crosspoint_reader/plugin/driver.py @@ -1,9 +1,13 @@ import os import time +import urllib.parse +import urllib.request from calibre.devices.errors import ControlError from calibre.devices.interface import DevicePlugin from calibre.devices.usbms.deviceconfig import DeviceConfig +from calibre.devices.usbms.books import Book +from calibre.ebooks.metadata.book.base import Metadata from . import ws_client from .config import CrossPointConfigWidget, PREFS @@ -105,6 +109,35 @@ class CrossPointDevice(DeviceConfig, DevicePlugin): else: self.report_progress = report_progress + def _http_base(self): + host = self.device_host or PREFS['host'] + return f'http://{host}' + + def _http_get_json(self, path, params=None, timeout=5): + url = self._http_base() + path + if params: + url += '?' + urllib.parse.urlencode(params) + try: + with urllib.request.urlopen(url, timeout=timeout) as resp: + data = resp.read().decode('utf-8', 'ignore') + except Exception as exc: + raise ControlError(desc=f'HTTP request failed: {exc}') + try: + import json + return json.loads(data) + except Exception as exc: + raise ControlError(desc=f'Invalid JSON response: {exc}') + + def _http_post_form(self, path, data, timeout=5): + url = self._http_base() + path + body = urllib.parse.urlencode(data).encode('utf-8') + req = urllib.request.Request(url, data=body, method='POST') + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, resp.read().decode('utf-8', 'ignore') + except Exception as exc: + raise ControlError(desc=f'HTTP request failed: {exc}') + def config_widget(self): return CrossPointConfigWidget() @@ -112,8 +145,37 @@ class CrossPointDevice(DeviceConfig, DevicePlugin): config_widget.save() def books(self, oncard=None, end_session=True): - # Device does not expose a browsable library yet. - return [] + if oncard is not None: + return [] + entries = self._http_get_json('/api/files', params={'path': '/'}) + books = [] + fetch_metadata = PREFS['fetch_metadata'] + for entry in entries: + if entry.get('isDirectory'): + continue + if not entry.get('isEpub'): + continue + name = entry.get('name', '') + if not name: + continue + size = entry.get('size', 0) + lpath = '/' + name if not name.startswith('/') else name + title = os.path.splitext(os.path.basename(name))[0] + meta = Metadata(title, []) + if fetch_metadata: + try: + from calibre.customize.ui import quick_metadata + from calibre.ebooks.metadata.meta import get_metadata + with self._download_temp(lpath) as tf: + with quick_metadata: + m = get_metadata(tf, stream_type='epub', force_read_metadata=True) + if m is not None: + meta = m + except Exception as exc: + self._log(f'[CrossPoint] metadata read failed for {lpath}: {exc}') + book = Book('', lpath, size=size, other=meta) + books.append(book) + return books def sync_booklists(self, booklists, end_session=True): # No on-device metadata sync supported. @@ -175,8 +237,39 @@ class CrossPointDevice(DeviceConfig, DevicePlugin): return def delete_books(self, paths, end_session=True): - # Deletion not supported in current device API. - raise ControlError(desc='Device does not support deleting books') + for path in paths: + status, body = self._http_post_form('/delete', {'path': path, 'type': 'file'}) + if status != 200: + raise ControlError(desc=f'Delete failed for {path}: {body}') + + def remove_books_from_metadata(self, paths, booklists): + for path in paths: + for bl in booklists: + for book in tuple(bl): + if path == book.path or path == book.lpath: + bl.remove_book(book) + + def get_file(self, path, outfile, end_session=True, this_book=None, total_books=None): + url = self._http_base() + '/download' + params = urllib.parse.urlencode({'path': path}) + try: + with urllib.request.urlopen(url + '?' + params, timeout=10) as resp: + while True: + chunk = resp.read(65536) + if not chunk: + break + outfile.write(chunk) + except Exception as exc: + raise ControlError(desc=f'Failed to download {path}: {exc}') + + def _download_temp(self, path): + from calibre.ptempfile import PersistentTemporaryFile + tf = PersistentTemporaryFile(suffix='.epub') + self.get_file(path, tf) + tf.flush() + tf.seek(0) + return tf + def eject(self): self.is_connected = False diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 8d599b48..28377eee 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 = 21; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace diff --git a/src/activities/network/CalibreConnectActivity.cpp b/src/activities/network/CalibreConnectActivity.cpp index 3c7ef8f9..a096efea 100644 --- a/src/activities/network/CalibreConnectActivity.cpp +++ b/src/activities/network/CalibreConnectActivity.cpp @@ -33,6 +33,7 @@ void CalibreConnectActivity::onEnter() { currentUploadName.clear(); lastCompleteName.clear(); lastCompleteAt = 0; + exitRequested = false; xTaskCreate(&CalibreConnectActivity::taskTrampoline, "CalibreConnectTask", 2048, // Stack size @@ -124,8 +125,7 @@ void CalibreConnectActivity::loop() { } if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { - onComplete(); - return; + exitRequested = true; } if (webServer && webServer->isRunning()) { @@ -135,17 +135,17 @@ void CalibreConnectActivity::loop() { } esp_task_wdt_reset(); - constexpr int MAX_ITERATIONS = 500; + constexpr int MAX_ITERATIONS = 80; for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) { webServer->handleClient(); - if ((i & 0x1F) == 0x1F) { + if ((i & 0x07) == 0x07) { esp_task_wdt_reset(); } - if ((i & 0x3F) == 0x3F) { + if ((i & 0x0F) == 0x0F) { yield(); if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { - onComplete(); - return; + exitRequested = true; + break; } } } @@ -181,6 +181,11 @@ void CalibreConnectActivity::loop() { updateRequired = true; } } + + if (exitRequested) { + onComplete(); + return; + } } void CalibreConnectActivity::displayTaskLoop() { @@ -215,10 +220,14 @@ void CalibreConnectActivity::render() const { void CalibreConnectActivity::renderServerRunning() const { constexpr int LINE_SPACING = 24; - constexpr int TOP_PADDING = 18; + 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 = 60 + TOP_PADDING; + 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, "..."); @@ -226,22 +235,17 @@ void CalibreConnectActivity::renderServerRunning() const { 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 + 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 += 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; + 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()) { @@ -254,9 +258,9 @@ void CalibreConnectActivity::renderServerRunning() const { constexpr int barWidth = 300; constexpr int barHeight = 16; constexpr int barX = (480 - barWidth) / 2; - ScreenComponents::drawProgressBar(renderer, barX, y + 28, barWidth, barHeight, lastProgressReceived, + ScreenComponents::drawProgressBar(renderer, barX, y + 22, barWidth, barHeight, lastProgressReceived, lastProgressTotal); - y += 46; + y += 40; } if (lastCompleteAt > 0 && (millis() - lastCompleteAt) < 6000) { diff --git a/src/activities/network/CalibreConnectActivity.h b/src/activities/network/CalibreConnectActivity.h index 60d5a70b..08cf4bb4 100644 --- a/src/activities/network/CalibreConnectActivity.h +++ b/src/activities/network/CalibreConnectActivity.h @@ -32,6 +32,7 @@ class CalibreConnectActivity final : public ActivityWithSubactivity { std::string currentUploadName; std::string lastCompleteName; unsigned long lastCompleteAt = 0; + bool exitRequested = false; static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); diff --git a/src/activities/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp index 055b9cd1..9e4ce1ca 100644 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ b/src/activities/settings/CalibreSettingsActivity.cpp @@ -116,8 +116,8 @@ void CalibreSettingsActivity::handleSelection() { exitActivity(); enterNewActivity(new KeyboardEntryActivity( renderer, mappedInput, "Password", SETTINGS.opdsPassword, 10, - 63, // maxLength - true, // password mode + 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'; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index b4796d97..1f5b3ebb 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -90,6 +90,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(); }); @@ -382,6 +383,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; diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index cff3e05f..36030292 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -73,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 d05eeda3..e7fd4526 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -31,7 +32,9 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { // Add Basic HTTP auth if credentials are configured if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { - http.setAuthorization(SETTINGS.opdsUsername, SETTINGS.opdsPassword); + 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(); @@ -70,7 +73,9 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& // Add Basic HTTP auth if credentials are configured if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { - http.setAuthorization(SETTINGS.opdsUsername, SETTINGS.opdsPassword); + 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(); From 4fecafc890614156690d2b4aef5091d4776da013 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Fri, 16 Jan 2026 18:55:47 -0500 Subject: [PATCH 11/13] list books by file name as metadata is slow --- .../CrossPointReaderCalibrePlugin.zip | Bin 8855 -> 9536 bytes .../crosspoint_reader/plugin/driver.py | 95 ++++++++++++++++-- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/calibre-plugin/crosspoint_reader/CrossPointReaderCalibrePlugin.zip b/calibre-plugin/crosspoint_reader/CrossPointReaderCalibrePlugin.zip index 61ec171d9f8876c92c7204e50db004a606c299ae..bf7f55d010ae5dd10c68ab23fcbac32b15b5a8f6 100644 GIT binary patch delta 3809 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 diff --git a/calibre-plugin/crosspoint_reader/plugin/driver.py b/calibre-plugin/crosspoint_reader/plugin/driver.py index 846206ff..bca803d3 100644 --- a/calibre-plugin/crosspoint_reader/plugin/driver.py +++ b/calibre-plugin/crosspoint_reader/plugin/driver.py @@ -33,6 +33,10 @@ class CrossPointDevice(DeviceConfig, DevicePlugin): MUST_READ_METADATA = False MANAGES_DEVICE_PRESENCE = True DEVICE_PLUGBOARD_NAME = 'CROSSPOINT_READER' + MUST_READ_METADATA = False + SUPPORTS_DEVICE_DB = False + # Disable Calibre's device cache so we always refresh from device. + device_is_usb_mass_storage = False def __init__(self, path): super().__init__(path) @@ -210,6 +214,15 @@ class CrossPointDevice(DeviceConfig, DevicePlugin): else: filepath = infile filename = os.path.basename(name) + lpath = upload_path + if not lpath.startswith('/'): + lpath = '/' + lpath + if lpath != '/' and lpath.endswith('/'): + lpath = lpath[:-1] + if lpath == '/': + lpath = '/' + filename + else: + lpath = lpath + '/' + filename def _progress(sent, size): if size > 0: @@ -227,11 +240,21 @@ class CrossPointDevice(DeviceConfig, DevicePlugin): progress_cb=_progress, logger=self._log, ) - paths.append((filename, os.path.getsize(filepath))) + paths.append((lpath, os.path.getsize(filepath))) self.report_progress(1.0, 'Transferring books to device...') return paths + def add_books_to_metadata(self, locations, metadata, booklists): + metadata = iter(metadata) + for location in locations: + info = next(metadata) + lpath = location[0] + length = location[1] + book = Book('', lpath, size=length, other=info) + if booklists: + booklists[0].add_book(book, replace_metadata=True) + def add_books_to_metadata(self, locations, metadata, booklists): # No on-device catalog to update yet. return @@ -241,13 +264,73 @@ class CrossPointDevice(DeviceConfig, DevicePlugin): status, body = self._http_post_form('/delete', {'path': path, 'type': 'file'}) if status != 200: raise ControlError(desc=f'Delete failed for {path}: {body}') + self._log(f'[CrossPoint] deleted {path}') def remove_books_from_metadata(self, paths, booklists): - for path in paths: - for bl in booklists: - for book in tuple(bl): - if path == book.path or path == book.lpath: - bl.remove_book(book) + def norm(p): + if not p: + return '' + p = p.replace('\\', '/') + if not p.startswith('/'): + p = '/' + p + return p + + def norm_name(p): + if not p: + return '' + name = os.path.basename(p) + try: + import unicodedata + name = unicodedata.normalize('NFKC', name) + except Exception: + pass + name = name.replace('\u2019', "'").replace('\u2018', "'") + return name.casefold() + + device_names = set() + try: + entries = self._http_get_json('/api/files', params={'path': '/'}) + on_device = set() + for entry in entries: + if entry.get('isDirectory'): + continue + name = entry.get('name', '') + if not name: + continue + on_device.add(norm(name)) + on_device.add(norm('/' + name)) + device_names.add(norm_name(name)) + self._log(f'[CrossPoint] on-device list: {sorted(on_device)}') + except Exception as exc: + self._log(f'[CrossPoint] refresh list failed: {exc}') + on_device = None + + removed = 0 + for bl in booklists: + for book in tuple(bl): + bpath = norm(getattr(book, 'path', '')) + blpath = norm(getattr(book, 'lpath', '')) + self._log(f'[CrossPoint] book paths: {bpath} | {blpath}') + should_remove = False + if on_device is not None: + if device_names: + if norm_name(bpath) not in device_names and norm_name(blpath) not in device_names: + should_remove = True + elif bpath and bpath not in on_device and blpath and blpath not in on_device: + should_remove = True + else: + for path in paths: + target = norm(path) + target_name = os.path.basename(target) + if target == bpath or target == blpath: + should_remove = True + elif target_name and (os.path.basename(bpath) == target_name or os.path.basename(blpath) == target_name): + should_remove = True + if should_remove: + bl.remove_book(book) + removed += 1 + if removed: + self._log(f'[CrossPoint] removed {removed} items from device list') def get_file(self, path, outfile, end_session=True, this_book=None, total_books=None): url = self._http_base() + '/download' From 6316c4346a55c2ad1a0f330b82cbd130be799cfb Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Fri, 16 Jan 2026 18:59:05 -0500 Subject: [PATCH 12/13] clang baby --- src/activities/network/CalibreConnectActivity.cpp | 4 ++-- src/activities/network/CrossPointWebServerActivity.cpp | 1 - src/activities/settings/CalibreSettingsActivity.cpp | 1 + src/network/CrossPointWebServer.cpp | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/activities/network/CalibreConnectActivity.cpp b/src/activities/network/CalibreConnectActivity.cpp index a096efea..8aa60c40 100644 --- a/src/activities/network/CalibreConnectActivity.cpp +++ b/src/activities/network/CalibreConnectActivity.cpp @@ -43,8 +43,8 @@ void CalibreConnectActivity::onEnter() { ); if (WiFi.status() != WL_CONNECTED) { - enterNewActivity(new WifiSelectionActivity( - renderer, mappedInput, [this](const bool connected) { onWifiSelectionComplete(connected); })); + enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, + [this](const bool connected) { onWifiSelectionComplete(connected); })); } else { connectedIP = WiFi.localIP().toString().c_str(); connectedSSID = WiFi.SSID().c_str(); diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 4d78739c..c6af1497 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -197,7 +197,6 @@ 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/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp index 9e4ce1ca..d1df9d0e 100644 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ b/src/activities/settings/CalibreSettingsActivity.cpp @@ -1,6 +1,7 @@ #include "CalibreSettingsActivity.h" #include + #include #include "CrossPointSettings.h" diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 1f5b3ebb..1427ae2f 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -115,7 +115,8 @@ void CrossPointWebServer::begin() { 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); + Serial.printf("[%lu] [WEB] Discovery UDP %s on port %d\n", millis(), udpActive ? "enabled" : "failed", + LOCAL_UDP_PORT); running = true; From c4740bd5fd7643155f1641b1ba0220a1afa70cb7 Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Sat, 24 Jan 2026 03:04:36 +0500 Subject: [PATCH 13/13] move calibre plugin to separate repo --- .../CrossPointReaderCalibrePlugin.zip | Bin 9536 -> 0 bytes calibre-plugin/crosspoint_reader/README.md | 25 -- .../crosspoint_reader/plugin/__init__.py | 5 - .../crosspoint_reader/plugin/config.py | 91 ----- .../crosspoint_reader/plugin/driver.py | 367 ------------------ .../crosspoint_reader/plugin/log.py | 17 - .../crosspoint_reader/plugin/ws_client.py | 294 -------------- src/CrossPointSettings.cpp | 2 +- .../settings/CategorySettingsActivity.cpp | 2 +- 9 files changed, 2 insertions(+), 801 deletions(-) delete mode 100644 calibre-plugin/crosspoint_reader/CrossPointReaderCalibrePlugin.zip delete mode 100644 calibre-plugin/crosspoint_reader/README.md delete mode 100644 calibre-plugin/crosspoint_reader/plugin/__init__.py delete mode 100644 calibre-plugin/crosspoint_reader/plugin/config.py delete mode 100644 calibre-plugin/crosspoint_reader/plugin/driver.py delete mode 100644 calibre-plugin/crosspoint_reader/plugin/log.py delete mode 100644 calibre-plugin/crosspoint_reader/plugin/ws_client.py diff --git a/calibre-plugin/crosspoint_reader/CrossPointReaderCalibrePlugin.zip b/calibre-plugin/crosspoint_reader/CrossPointReaderCalibrePlugin.zip deleted file mode 100644 index bf7f55d010ae5dd10c68ab23fcbac32b15b5a8f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9536 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:/ -- Send: START::: -- Wait for READY -- Send binary frames with file content -- Wait for DONE (or ERROR:) - -Default settings: -- Auto-discover device via UDP -- Host fallback: 192.168.4.1 -- Port: 81 -- Upload path: / - -Install: -1. Zip the contents of `calibre-plugin/crosspoint_reader/plugin`. -2. In Calibre: Preferences > Plugins > Load plugin from file. -3. The device should appear in Calibre once it is discoverable on the network. - -No configuration needed. The plugin auto-discovers the device via UDP and -falls back to 192.168.4.1:81. diff --git a/calibre-plugin/crosspoint_reader/plugin/__init__.py b/calibre-plugin/crosspoint_reader/plugin/__init__.py deleted file mode 100644 index 9aaedbda..00000000 --- a/calibre-plugin/crosspoint_reader/plugin/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .driver import CrossPointDevice - - -class CrossPointReaderDevice(CrossPointDevice): - pass diff --git a/calibre-plugin/crosspoint_reader/plugin/config.py b/calibre-plugin/crosspoint_reader/plugin/config.py deleted file mode 100644 index 18d4fc94..00000000 --- a/calibre-plugin/crosspoint_reader/plugin/config.py +++ /dev/null @@ -1,91 +0,0 @@ -from calibre.utils.config import JSONConfig -from qt.core import ( - QCheckBox, - QDialog, - QDialogButtonBox, - QFormLayout, - QHBoxLayout, - QLineEdit, - QPlainTextEdit, - QPushButton, - QSpinBox, - QVBoxLayout, - QWidget, -) - -from .log import get_log_text - - -PREFS = JSONConfig('plugins/crosspoint_reader') -PREFS.defaults['host'] = '192.168.4.1' -PREFS.defaults['port'] = 81 -PREFS.defaults['path'] = '/' -PREFS.defaults['chunk_size'] = 2048 -PREFS.defaults['debug'] = False -PREFS.defaults['fetch_metadata'] = False - - -class CrossPointConfigWidget(QWidget): - def __init__(self): - super().__init__() - layout = QFormLayout(self) - self.host = QLineEdit(self) - self.port = QSpinBox(self) - self.port.setRange(1, 65535) - self.path = QLineEdit(self) - self.chunk_size = QSpinBox(self) - self.chunk_size.setRange(512, 65536) - self.debug = QCheckBox('Enable debug logging', self) - self.fetch_metadata = QCheckBox('Fetch metadata (slower device list)', self) - - self.host.setText(PREFS['host']) - self.port.setValue(PREFS['port']) - self.path.setText(PREFS['path']) - self.chunk_size.setValue(PREFS['chunk_size']) - self.debug.setChecked(PREFS['debug']) - self.fetch_metadata.setChecked(PREFS['fetch_metadata']) - - layout.addRow('Host', self.host) - layout.addRow('Port', self.port) - layout.addRow('Upload path', self.path) - layout.addRow('Chunk size', self.chunk_size) - layout.addRow('', self.debug) - layout.addRow('', self.fetch_metadata) - - self.log_view = QPlainTextEdit(self) - self.log_view.setReadOnly(True) - self.log_view.setPlaceholderText('Discovery log will appear here when debug is enabled.') - self._refresh_logs() - - refresh_btn = QPushButton('Refresh Log', self) - refresh_btn.clicked.connect(self._refresh_logs) - log_layout = QHBoxLayout() - log_layout.addWidget(refresh_btn) - - layout.addRow('Log', self.log_view) - layout.addRow('', log_layout) - - def save(self): - PREFS['host'] = self.host.text().strip() or PREFS.defaults['host'] - PREFS['port'] = int(self.port.value()) - PREFS['path'] = self.path.text().strip() or PREFS.defaults['path'] - PREFS['chunk_size'] = int(self.chunk_size.value()) - PREFS['debug'] = bool(self.debug.isChecked()) - PREFS['fetch_metadata'] = bool(self.fetch_metadata.isChecked()) - - def _refresh_logs(self): - self.log_view.setPlainText(get_log_text()) - - -class CrossPointConfigDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle('CrossPoint Reader') - self.widget = CrossPointConfigWidget() - layout = QVBoxLayout(self) - layout.addWidget(self.widget) - buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | - QDialogButtonBox.StandardButton.Cancel) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) diff --git a/calibre-plugin/crosspoint_reader/plugin/driver.py b/calibre-plugin/crosspoint_reader/plugin/driver.py deleted file mode 100644 index bca803d3..00000000 --- a/calibre-plugin/crosspoint_reader/plugin/driver.py +++ /dev/null @@ -1,367 +0,0 @@ -import os -import time -import urllib.parse -import urllib.request - -from calibre.devices.errors import ControlError -from calibre.devices.interface import DevicePlugin -from calibre.devices.usbms.deviceconfig import DeviceConfig -from calibre.devices.usbms.books import Book -from calibre.ebooks.metadata.book.base import Metadata - -from . import ws_client -from .config import CrossPointConfigWidget, PREFS -from .log import add_log - - -class CrossPointDevice(DeviceConfig, DevicePlugin): - name = 'CrossPoint Reader' - gui_name = 'CrossPoint Reader' - description = 'CrossPoint Reader wireless device' - supported_platforms = ['windows', 'osx', 'linux'] - author = 'CrossPoint Reader' - version = (0, 1, 0) - - # Invalid USB vendor info to avoid USB scans matching. - VENDOR_ID = [0xFFFF] - PRODUCT_ID = [0xFFFF] - BCD = [0xFFFF] - - FORMATS = ['epub'] - ALL_FORMATS = ['epub'] - SUPPORTS_SUB_DIRS = True - MUST_READ_METADATA = False - MANAGES_DEVICE_PRESENCE = True - DEVICE_PLUGBOARD_NAME = 'CROSSPOINT_READER' - MUST_READ_METADATA = False - SUPPORTS_DEVICE_DB = False - # Disable Calibre's device cache so we always refresh from device. - device_is_usb_mass_storage = False - - def __init__(self, path): - super().__init__(path) - self.is_connected = False - self.device_host = None - self.device_port = None - self.last_discovery = 0.0 - self.report_progress = lambda x, y: x - self._debug_enabled = False - - def _log(self, message): - add_log(message) - if self._debug_enabled: - try: - self.report_progress(0.0, message) - except Exception: - pass - - # Device discovery / presence - def _discover(self): - now = time.time() - if now - self.last_discovery < 2.0: - return None, None - self.last_discovery = now - host, port = ws_client.discover_device( - timeout=1.0, - debug=PREFS['debug'], - logger=self._log, - extra_hosts=[PREFS['host']], - ) - if host and port: - return host, port - return None, None - - def detect_managed_devices(self, devices_on_system, force_refresh=False): - if self.is_connected: - return self - debug = PREFS['debug'] - self._debug_enabled = debug - if debug: - self._log('[CrossPoint] detect_managed_devices') - host, port = self._discover() - if host: - if debug: - self._log(f'[CrossPoint] discovered {host} {port}') - self.device_host = host - self.device_port = port - self.is_connected = True - return self - if debug: - self._log('[CrossPoint] discovery failed') - return None - - def open(self, connected_device, library_uuid): - if not self.is_connected: - raise ControlError(desc='Attempt to open a closed device') - return True - - def get_device_information(self, end_session=True): - host = self.device_host or PREFS['host'] - device_info = { - 'device_store_uuid': 'crosspoint-' + host.replace('.', '-'), - 'device_name': 'CrossPoint Reader', - 'device_version': '1', - } - return (self.gui_name, '1', '1', '', {'main': device_info}) - - def reset(self, key='-1', log_packets=False, report_progress=None, detected_device=None): - self.set_progress_reporter(report_progress) - - def set_progress_reporter(self, report_progress): - if report_progress is None: - self.report_progress = lambda x, y: x - else: - self.report_progress = report_progress - - def _http_base(self): - host = self.device_host or PREFS['host'] - return f'http://{host}' - - def _http_get_json(self, path, params=None, timeout=5): - url = self._http_base() + path - if params: - url += '?' + urllib.parse.urlencode(params) - try: - with urllib.request.urlopen(url, timeout=timeout) as resp: - data = resp.read().decode('utf-8', 'ignore') - except Exception as exc: - raise ControlError(desc=f'HTTP request failed: {exc}') - try: - import json - return json.loads(data) - except Exception as exc: - raise ControlError(desc=f'Invalid JSON response: {exc}') - - def _http_post_form(self, path, data, timeout=5): - url = self._http_base() + path - body = urllib.parse.urlencode(data).encode('utf-8') - req = urllib.request.Request(url, data=body, method='POST') - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - return resp.status, resp.read().decode('utf-8', 'ignore') - except Exception as exc: - raise ControlError(desc=f'HTTP request failed: {exc}') - - def config_widget(self): - return CrossPointConfigWidget() - - def save_settings(self, config_widget): - config_widget.save() - - def books(self, oncard=None, end_session=True): - if oncard is not None: - return [] - entries = self._http_get_json('/api/files', params={'path': '/'}) - books = [] - fetch_metadata = PREFS['fetch_metadata'] - for entry in entries: - if entry.get('isDirectory'): - continue - if not entry.get('isEpub'): - continue - name = entry.get('name', '') - if not name: - continue - size = entry.get('size', 0) - lpath = '/' + name if not name.startswith('/') else name - title = os.path.splitext(os.path.basename(name))[0] - meta = Metadata(title, []) - if fetch_metadata: - try: - from calibre.customize.ui import quick_metadata - from calibre.ebooks.metadata.meta import get_metadata - with self._download_temp(lpath) as tf: - with quick_metadata: - m = get_metadata(tf, stream_type='epub', force_read_metadata=True) - if m is not None: - meta = m - except Exception as exc: - self._log(f'[CrossPoint] metadata read failed for {lpath}: {exc}') - book = Book('', lpath, size=size, other=meta) - books.append(book) - return books - - def sync_booklists(self, booklists, end_session=True): - # No on-device metadata sync supported. - return None - - def card_prefix(self, end_session=True): - return None, None - - def total_space(self, end_session=True): - return 10 * 1024 * 1024 * 1024, 0, 0 - - def free_space(self, end_session=True): - return 10 * 1024 * 1024 * 1024, 0, 0 - - def upload_books(self, files, names, on_card=None, end_session=True, metadata=None): - host = self.device_host or PREFS['host'] - port = self.device_port or PREFS['port'] - upload_path = PREFS['path'] - chunk_size = PREFS['chunk_size'] - if chunk_size > 2048: - self._log(f'[CrossPoint] chunk_size capped to 2048 (was {chunk_size})') - chunk_size = 2048 - debug = PREFS['debug'] - - paths = [] - total = len(files) - for i, (infile, name) in enumerate(zip(files, names)): - if hasattr(infile, 'read'): - filepath = getattr(infile, 'name', None) - if not filepath: - raise ControlError(desc='In-memory uploads are not supported') - else: - filepath = infile - filename = os.path.basename(name) - lpath = upload_path - if not lpath.startswith('/'): - lpath = '/' + lpath - if lpath != '/' and lpath.endswith('/'): - lpath = lpath[:-1] - if lpath == '/': - lpath = '/' + filename - else: - lpath = lpath + '/' + filename - - def _progress(sent, size): - if size > 0: - self.report_progress((i + sent / float(size)) / float(total), - 'Transferring books to device...') - - ws_client.upload_file( - host, - port, - upload_path, - filename, - filepath, - chunk_size=chunk_size, - debug=debug, - progress_cb=_progress, - logger=self._log, - ) - paths.append((lpath, os.path.getsize(filepath))) - - self.report_progress(1.0, 'Transferring books to device...') - return paths - - def add_books_to_metadata(self, locations, metadata, booklists): - metadata = iter(metadata) - for location in locations: - info = next(metadata) - lpath = location[0] - length = location[1] - book = Book('', lpath, size=length, other=info) - if booklists: - booklists[0].add_book(book, replace_metadata=True) - - def add_books_to_metadata(self, locations, metadata, booklists): - # No on-device catalog to update yet. - return - - def delete_books(self, paths, end_session=True): - for path in paths: - status, body = self._http_post_form('/delete', {'path': path, 'type': 'file'}) - if status != 200: - raise ControlError(desc=f'Delete failed for {path}: {body}') - self._log(f'[CrossPoint] deleted {path}') - - def remove_books_from_metadata(self, paths, booklists): - def norm(p): - if not p: - return '' - p = p.replace('\\', '/') - if not p.startswith('/'): - p = '/' + p - return p - - def norm_name(p): - if not p: - return '' - name = os.path.basename(p) - try: - import unicodedata - name = unicodedata.normalize('NFKC', name) - except Exception: - pass - name = name.replace('\u2019', "'").replace('\u2018', "'") - return name.casefold() - - device_names = set() - try: - entries = self._http_get_json('/api/files', params={'path': '/'}) - on_device = set() - for entry in entries: - if entry.get('isDirectory'): - continue - name = entry.get('name', '') - if not name: - continue - on_device.add(norm(name)) - on_device.add(norm('/' + name)) - device_names.add(norm_name(name)) - self._log(f'[CrossPoint] on-device list: {sorted(on_device)}') - except Exception as exc: - self._log(f'[CrossPoint] refresh list failed: {exc}') - on_device = None - - removed = 0 - for bl in booklists: - for book in tuple(bl): - bpath = norm(getattr(book, 'path', '')) - blpath = norm(getattr(book, 'lpath', '')) - self._log(f'[CrossPoint] book paths: {bpath} | {blpath}') - should_remove = False - if on_device is not None: - if device_names: - if norm_name(bpath) not in device_names and norm_name(blpath) not in device_names: - should_remove = True - elif bpath and bpath not in on_device and blpath and blpath not in on_device: - should_remove = True - else: - for path in paths: - target = norm(path) - target_name = os.path.basename(target) - if target == bpath or target == blpath: - should_remove = True - elif target_name and (os.path.basename(bpath) == target_name or os.path.basename(blpath) == target_name): - should_remove = True - if should_remove: - bl.remove_book(book) - removed += 1 - if removed: - self._log(f'[CrossPoint] removed {removed} items from device list') - - def get_file(self, path, outfile, end_session=True, this_book=None, total_books=None): - url = self._http_base() + '/download' - params = urllib.parse.urlencode({'path': path}) - try: - with urllib.request.urlopen(url + '?' + params, timeout=10) as resp: - while True: - chunk = resp.read(65536) - if not chunk: - break - outfile.write(chunk) - except Exception as exc: - raise ControlError(desc=f'Failed to download {path}: {exc}') - - def _download_temp(self, path): - from calibre.ptempfile import PersistentTemporaryFile - tf = PersistentTemporaryFile(suffix='.epub') - self.get_file(path, tf) - tf.flush() - tf.seek(0) - return tf - - - def eject(self): - self.is_connected = False - - def is_dynamically_controllable(self): - return 'crosspoint' - - def start_plugin(self): - return None - - def stop_plugin(self): - self.is_connected = False diff --git a/calibre-plugin/crosspoint_reader/plugin/log.py b/calibre-plugin/crosspoint_reader/plugin/log.py deleted file mode 100644 index 9c58acd4..00000000 --- a/calibre-plugin/crosspoint_reader/plugin/log.py +++ /dev/null @@ -1,17 +0,0 @@ -import time - - -_LOG = [] -_MAX_LINES = 200 - - -def add_log(message): - timestamp = time.strftime('%H:%M:%S') - line = f'[{timestamp}] {message}' - _LOG.append(line) - if len(_LOG) > _MAX_LINES: - _LOG[:len(_LOG) - _MAX_LINES] = [] - - -def get_log_text(): - return '\n'.join(_LOG) diff --git a/calibre-plugin/crosspoint_reader/plugin/ws_client.py b/calibre-plugin/crosspoint_reader/plugin/ws_client.py deleted file mode 100644 index d87fa0b2..00000000 --- a/calibre-plugin/crosspoint_reader/plugin/ws_client.py +++ /dev/null @@ -1,294 +0,0 @@ -import base64 -import os -import select -import socket -import struct -import time - - -class WebSocketError(RuntimeError): - pass - - -class WebSocketClient: - def __init__(self, host, port, timeout=10, debug=False, logger=None): - self.host = host - self.port = port - self.timeout = timeout - self.debug = debug - self.logger = logger - self.sock = None - - def _log(self, *args): - if self.debug: - msg = '[CrossPoint WS] ' + ' '.join(str(a) for a in args) - if self.logger: - self.logger(msg) - else: - print(msg) - - def connect(self): - self.sock = socket.create_connection((self.host, self.port), self.timeout) - key = base64.b64encode(os.urandom(16)).decode('ascii') - req = ( - 'GET / HTTP/1.1\r\n' - f'Host: {self.host}:{self.port}\r\n' - 'Upgrade: websocket\r\n' - 'Connection: Upgrade\r\n' - f'Sec-WebSocket-Key: {key}\r\n' - 'Sec-WebSocket-Version: 13\r\n' - '\r\n' - ) - self.sock.sendall(req.encode('ascii')) - data = self._read_http_response() - if b' 101 ' not in data.split(b'\r\n', 1)[0]: - raise WebSocketError('Handshake failed: ' + data.split(b'\r\n', 1)[0].decode('ascii', 'ignore')) - self._log('Handshake OK') - - def _read_http_response(self): - self.sock.settimeout(self.timeout) - data = b'' - while b'\r\n\r\n' not in data: - chunk = self.sock.recv(1024) - if not chunk: - break - data += chunk - return data - - def close(self): - if not self.sock: - return - try: - self._send_frame(0x8, b'') - except Exception: - pass - try: - self.sock.close() - finally: - self.sock = None - - def send_text(self, text): - self._send_frame(0x1, text.encode('utf-8')) - - def send_binary(self, payload): - self._send_frame(0x2, payload) - - def _send_frame(self, opcode, payload): - if self.sock is None: - raise WebSocketError('Socket not connected') - fin = 0x80 - first = fin | (opcode & 0x0F) - mask_bit = 0x80 - length = len(payload) - header = bytearray([first]) - if length <= 125: - header.append(mask_bit | length) - elif length <= 65535: - header.append(mask_bit | 126) - header.extend(struct.pack('!H', length)) - else: - header.append(mask_bit | 127) - header.extend(struct.pack('!Q', length)) - - mask = os.urandom(4) - header.extend(mask) - masked = bytearray(payload) - for i in range(length): - masked[i] ^= mask[i % 4] - self.sock.sendall(header + masked) - - def read_text(self): - 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: - raise WebSocketError('Socket not connected') - hdr = self._recv_exact(2) - b1, b2 = hdr[0], hdr[1] - opcode = b1 & 0x0F - masked = (b2 & 0x80) != 0 - length = b2 & 0x7F - if length == 126: - length = struct.unpack('!H', self._recv_exact(2))[0] - elif length == 127: - length = struct.unpack('!Q', self._recv_exact(8))[0] - mask = b'' - if masked: - mask = self._recv_exact(4) - payload = self._recv_exact(length) if length else b'' - if masked: - payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) - return opcode, payload - - def _recv_exact(self, n): - data = b'' - while len(data) < n: - chunk = self.sock.recv(n - len(data)) - if not chunk: - raise WebSocketError('Socket closed') - data += chunk - return data - - def drain_messages(self): - if self.sock is None: - return [] - messages = [] - while True: - r, _, _ = select.select([self.sock], [], [], 0) - if not r: - break - opcode, payload = self._read_frame() - if opcode == 0x1: - messages.append(payload.decode('utf-8', 'ignore')) - elif opcode == 0x8: - raise WebSocketError('Connection closed') - return messages - - -def _log(logger, debug, message): - if not debug: - return - if logger: - logger(message) - else: - print(message) - - -def _broadcast_from_host(host): - parts = host.split('.') - if len(parts) != 4: - return None - try: - _ = [int(p) for p in parts] - except Exception: - return None - parts[-1] = '255' - return '.'.join(parts) - - -def discover_device(timeout=2.0, debug=False, logger=None, extra_hosts=None): - ports = [8134, 54982, 48123, 39001, 44044, 59678] - local_port = 0 - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.settimeout(0.5) - try: - sock.bind(('', local_port)) - except Exception: - _log(logger, debug, '[CrossPoint WS] discovery bind failed') - pass - - msg = b'hello' - try: - addr, port = sock.getsockname() - _log(logger, debug, f'[CrossPoint WS] discovery local {addr} {port}') - except Exception: - pass - - targets = [] - for port in ports: - targets.append(('255.255.255.255', port)) - for host in extra_hosts or []: - if not host: - continue - for port in ports: - targets.append((host, port)) - bcast = _broadcast_from_host(host) - if bcast: - for port in ports: - targets.append((bcast, port)) - - for _ in range(3): - for host, port in targets: - try: - sock.sendto(msg, (host, port)) - except Exception as exc: - _log(logger, debug, f'[CrossPoint WS] discovery send failed {host}:{port} {exc}') - pass - start = time.time() - while time.time() - start < timeout: - try: - data, addr = sock.recvfrom(256) - except Exception: - break - _log(logger, debug, f'[CrossPoint WS] discovery {addr} {data}') - try: - text = data.decode('utf-8', 'ignore') - except Exception: - continue - semi = text.find(';') - port = 81 - if semi != -1: - try: - port = int(text[semi + 1:].strip().split(',')[0]) - except Exception: - port = 81 - return addr[0], port - return None, None - - -def upload_file(host, port, upload_path, filename, filepath, chunk_size=16384, debug=False, progress_cb=None, - logger=None): - client = WebSocketClient(host, port, timeout=10, debug=debug, logger=logger) - try: - client.connect() - size = os.path.getsize(filepath) - start = f'START:{filename}:{size}:{upload_path}' - client._log('Sending START', start) - client.send_text(start) - - 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': - raise WebSocketError('Unexpected response: ' + msg) - - sent = 0 - with open(filepath, 'rb') as f: - while True: - chunk = f.read(chunk_size) - if not chunk: - break - client.send_binary(chunk) - sent += len(chunk) - if progress_cb: - progress_cb(sent, size) - client.drain_messages() - - # Wait for DONE or ERROR - while True: - msg = client.read_text() - client._log('Received', msg) - if msg == 'DONE': - return - if msg.startswith('ERROR'): - raise WebSocketError(msg) - finally: - client.close() diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index b96c0458..ea26ad91 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 = 21; +constexpr uint8_t SETTINGS_COUNT = 22; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace diff --git a/src/activities/settings/CategorySettingsActivity.cpp b/src/activities/settings/CategorySettingsActivity.cpp index a6182b5c..7fd5ef5f 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] {