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 00000000..61ec171d Binary files /dev/null and b/calibre-plugin/crosspoint_reader/CrossPointReaderCalibrePlugin.zip differ diff --git a/calibre-plugin/crosspoint_reader/plugin/Archive.zip b/calibre-plugin/crosspoint_reader/plugin/Archive.zip deleted file mode 100644 index 008b3550..00000000 Binary files a/calibre-plugin/crosspoint_reader/plugin/Archive.zip and /dev/null differ 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();