fixed basic auth for opds and added more calibre commands

now supports viewing books on device and deleting them
This commit is contained in:
Justin Mitchell 2026-01-16 18:34:54 -05:00
parent 8114899bef
commit e2124ca7a0
12 changed files with 220 additions and 36 deletions

View File

@ -60,6 +60,17 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con
> [!TIP] > [!TIP]
> Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details. > 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 ### 3.5 Settings
The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: 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". - **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. - **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. - **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. - **Check for updates**: Check for firmware updates over WiFi.
### 3.6 Sleep Screen ### 3.6 Sleep Screen

View File

@ -22,6 +22,7 @@ PREFS.defaults['port'] = 81
PREFS.defaults['path'] = '/' PREFS.defaults['path'] = '/'
PREFS.defaults['chunk_size'] = 2048 PREFS.defaults['chunk_size'] = 2048
PREFS.defaults['debug'] = False PREFS.defaults['debug'] = False
PREFS.defaults['fetch_metadata'] = False
class CrossPointConfigWidget(QWidget): class CrossPointConfigWidget(QWidget):
@ -35,18 +36,21 @@ class CrossPointConfigWidget(QWidget):
self.chunk_size = QSpinBox(self) self.chunk_size = QSpinBox(self)
self.chunk_size.setRange(512, 65536) self.chunk_size.setRange(512, 65536)
self.debug = QCheckBox('Enable debug logging', self) self.debug = QCheckBox('Enable debug logging', self)
self.fetch_metadata = QCheckBox('Fetch metadata (slower device list)', self)
self.host.setText(PREFS['host']) self.host.setText(PREFS['host'])
self.port.setValue(PREFS['port']) self.port.setValue(PREFS['port'])
self.path.setText(PREFS['path']) self.path.setText(PREFS['path'])
self.chunk_size.setValue(PREFS['chunk_size']) self.chunk_size.setValue(PREFS['chunk_size'])
self.debug.setChecked(PREFS['debug']) self.debug.setChecked(PREFS['debug'])
self.fetch_metadata.setChecked(PREFS['fetch_metadata'])
layout.addRow('Host', self.host) layout.addRow('Host', self.host)
layout.addRow('Port', self.port) layout.addRow('Port', self.port)
layout.addRow('Upload path', self.path) layout.addRow('Upload path', self.path)
layout.addRow('Chunk size', self.chunk_size) layout.addRow('Chunk size', self.chunk_size)
layout.addRow('', self.debug) layout.addRow('', self.debug)
layout.addRow('', self.fetch_metadata)
self.log_view = QPlainTextEdit(self) self.log_view = QPlainTextEdit(self)
self.log_view.setReadOnly(True) self.log_view.setReadOnly(True)
@ -67,6 +71,7 @@ class CrossPointConfigWidget(QWidget):
PREFS['path'] = self.path.text().strip() or PREFS.defaults['path'] PREFS['path'] = self.path.text().strip() or PREFS.defaults['path']
PREFS['chunk_size'] = int(self.chunk_size.value()) PREFS['chunk_size'] = int(self.chunk_size.value())
PREFS['debug'] = bool(self.debug.isChecked()) PREFS['debug'] = bool(self.debug.isChecked())
PREFS['fetch_metadata'] = bool(self.fetch_metadata.isChecked())
def _refresh_logs(self): def _refresh_logs(self):
self.log_view.setPlainText(get_log_text()) self.log_view.setPlainText(get_log_text())

View File

@ -1,9 +1,13 @@
import os import os
import time import time
import urllib.parse
import urllib.request
from calibre.devices.errors import ControlError from calibre.devices.errors import ControlError
from calibre.devices.interface import DevicePlugin from calibre.devices.interface import DevicePlugin
from calibre.devices.usbms.deviceconfig import DeviceConfig 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 . import ws_client
from .config import CrossPointConfigWidget, PREFS from .config import CrossPointConfigWidget, PREFS
@ -105,6 +109,35 @@ class CrossPointDevice(DeviceConfig, DevicePlugin):
else: else:
self.report_progress = report_progress 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): def config_widget(self):
return CrossPointConfigWidget() return CrossPointConfigWidget()
@ -112,8 +145,37 @@ class CrossPointDevice(DeviceConfig, DevicePlugin):
config_widget.save() config_widget.save()
def books(self, oncard=None, end_session=True): def books(self, oncard=None, end_session=True):
# Device does not expose a browsable library yet. if oncard is not None:
return [] 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): def sync_booklists(self, booklists, end_session=True):
# No on-device metadata sync supported. # No on-device metadata sync supported.
@ -175,8 +237,39 @@ class CrossPointDevice(DeviceConfig, DevicePlugin):
return return
def delete_books(self, paths, end_session=True): def delete_books(self, paths, end_session=True):
# Deletion not supported in current device API. for path in paths:
raise ControlError(desc='Device does not support deleting books') 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): def eject(self):
self.is_connected = False self.is_connected = False

View File

@ -14,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance;
namespace { namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1; constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields // 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"; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace } // namespace

View File

@ -33,6 +33,7 @@ void CalibreConnectActivity::onEnter() {
currentUploadName.clear(); currentUploadName.clear();
lastCompleteName.clear(); lastCompleteName.clear();
lastCompleteAt = 0; lastCompleteAt = 0;
exitRequested = false;
xTaskCreate(&CalibreConnectActivity::taskTrampoline, "CalibreConnectTask", xTaskCreate(&CalibreConnectActivity::taskTrampoline, "CalibreConnectTask",
2048, // Stack size 2048, // Stack size
@ -124,8 +125,7 @@ void CalibreConnectActivity::loop() {
} }
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onComplete(); exitRequested = true;
return;
} }
if (webServer && webServer->isRunning()) { if (webServer && webServer->isRunning()) {
@ -135,17 +135,17 @@ void CalibreConnectActivity::loop() {
} }
esp_task_wdt_reset(); esp_task_wdt_reset();
constexpr int MAX_ITERATIONS = 500; constexpr int MAX_ITERATIONS = 80;
for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) { for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) {
webServer->handleClient(); webServer->handleClient();
if ((i & 0x1F) == 0x1F) { if ((i & 0x07) == 0x07) {
esp_task_wdt_reset(); esp_task_wdt_reset();
} }
if ((i & 0x3F) == 0x3F) { if ((i & 0x0F) == 0x0F) {
yield(); yield();
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onComplete(); exitRequested = true;
return; break;
} }
} }
} }
@ -181,6 +181,11 @@ void CalibreConnectActivity::loop() {
updateRequired = true; updateRequired = true;
} }
} }
if (exitRequested) {
onComplete();
return;
}
} }
void CalibreConnectActivity::displayTaskLoop() { void CalibreConnectActivity::displayTaskLoop() {
@ -215,10 +220,14 @@ void CalibreConnectActivity::render() const {
void CalibreConnectActivity::renderServerRunning() const { void CalibreConnectActivity::renderServerRunning() const {
constexpr int LINE_SPACING = 24; 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); 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; std::string ssidInfo = "Network: " + connectedSSID;
if (ssidInfo.length() > 28) { if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "..."); 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, ssidInfo.c_str());
renderer.drawCenteredText(UI_10_FONT_ID, y + LINE_SPACING, ("IP: " + connectedIP).c_str()); renderer.drawCenteredText(UI_10_FONT_ID, y + LINE_SPACING, ("IP: " + connectedIP).c_str());
y += LINE_SPACING * 2; y += LINE_SPACING * 2 + SECTION_SPACING;
renderer.drawCenteredText(SMALL_FONT_ID, y, "Install the CrossPoint Reader"); renderer.drawCenteredText(UI_10_FONT_ID, y, "Setup", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, y + LINE_SPACING, "device plugin in Calibre."); 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; y += SMALL_SPACING * 3 + SECTION_SPACING;
renderer.drawCenteredText(SMALL_FONT_ID, y, "Make sure your computer is"); renderer.drawCenteredText(UI_10_FONT_ID, y, "Status", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(SMALL_FONT_ID, y + LINE_SPACING, "on the same WiFi network."); y += LINE_SPACING;
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) { if (lastProgressTotal > 0 && lastProgressReceived <= lastProgressTotal) {
std::string label = "Receiving"; std::string label = "Receiving";
if (!currentUploadName.empty()) { if (!currentUploadName.empty()) {
@ -254,9 +258,9 @@ void CalibreConnectActivity::renderServerRunning() const {
constexpr int barWidth = 300; constexpr int barWidth = 300;
constexpr int barHeight = 16; constexpr int barHeight = 16;
constexpr int barX = (480 - barWidth) / 2; 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); lastProgressTotal);
y += 46; y += 40;
} }
if (lastCompleteAt > 0 && (millis() - lastCompleteAt) < 6000) { if (lastCompleteAt > 0 && (millis() - lastCompleteAt) < 6000) {

View File

@ -32,6 +32,7 @@ class CalibreConnectActivity final : public ActivityWithSubactivity {
std::string currentUploadName; std::string currentUploadName;
std::string lastCompleteName; std::string lastCompleteName;
unsigned long lastCompleteAt = 0; unsigned long lastCompleteAt = 0;
bool exitRequested = false;
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();

View File

@ -117,7 +117,7 @@ void CalibreSettingsActivity::handleSelection() {
enterNewActivity(new KeyboardEntryActivity( enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, "Password", SETTINGS.opdsPassword, 10, renderer, mappedInput, "Password", SETTINGS.opdsPassword, 10,
63, // maxLength 63, // maxLength
true, // password mode false, // not password mode
[this](const std::string& password) { [this](const std::string& password) {
strncpy(SETTINGS.opdsPassword, password.c_str(), sizeof(SETTINGS.opdsPassword) - 1); strncpy(SETTINGS.opdsPassword, password.c_str(), sizeof(SETTINGS.opdsPassword) - 1);
SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0'; SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0';

View File

@ -90,6 +90,7 @@ void CrossPointWebServer::begin() {
server->on("/api/status", HTTP_GET, [this] { handleStatus(); }); server->on("/api/status", HTTP_GET, [this] { handleStatus(); });
server->on("/api/files", HTTP_GET, [this] { handleFileListData(); }); server->on("/api/files", HTTP_GET, [this] { handleFileListData(); });
server->on("/download", HTTP_GET, [this] { handleDownload(); });
// Upload endpoint with special handling for multipart form data // Upload endpoint with special handling for multipart form data
server->on("/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); }); 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()); 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 variables for upload handling
static FsFile uploadFile; static FsFile uploadFile;
static String uploadFileName; static String uploadFileName;

View File

@ -73,6 +73,7 @@ class CrossPointWebServer {
void handleStatus() const; void handleStatus() const;
void handleFileList() const; void handleFileList() const;
void handleFileListData() const; void handleFileListData() const;
void handleDownload() const;
void handleUpload() const; void handleUpload() const;
void handleUploadPost() const; void handleUploadPost() const;
void handleCreateFolder() const; void handleCreateFolder() const;

View File

@ -4,6 +4,7 @@
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <WiFiClient.h> #include <WiFiClient.h>
#include <WiFiClientSecure.h> #include <WiFiClientSecure.h>
#include <base64.h>
#include <cstring> #include <cstring>
#include <memory> #include <memory>
@ -31,7 +32,9 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
// Add Basic HTTP auth if credentials are configured // Add Basic HTTP auth if credentials are configured
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { 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(); const int httpCode = http.GET();
@ -70,7 +73,9 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
// Add Basic HTTP auth if credentials are configured // Add Basic HTTP auth if credentials are configured
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { 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(); const int httpCode = http.GET();