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]
> 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

View File

@ -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())

View File

@ -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

View File

@ -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

View File

@ -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) {

View File

@ -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();

View File

@ -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';

View File

@ -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;

View File

@ -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;

View File

@ -4,6 +4,7 @@
#include <HardwareSerial.h>
#include <WiFiClient.h>
#include <WiFiClientSecure.h>
#include <base64.h>
#include <cstring>
#include <memory>
@ -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();