Moved to a custom calibre plugin instead

This commit is contained in:
Justin Mitchell 2026-01-16 16:54:44 -05:00
parent 818120c2dc
commit 12666636a7
8 changed files with 641 additions and 2 deletions

View File

@ -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://<host>:<port>/
- Send: START:<filename>:<size>:<path>
- Wait for READY
- Send binary frames with file content
- Wait for DONE (or ERROR:<message>)
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.

View File

@ -0,0 +1,5 @@
from .driver import CrossPointDevice
class CrossPointReaderDevice(CrossPointDevice):
pass

View File

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

View File

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

View File

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

View File

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

View File

@ -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<const uint8_t*>(message.c_str()), message.length());
udp.endPacket();
}
}
}
}
}
void CrossPointWebServer::handleRoot() const {

View File

@ -2,6 +2,7 @@
#include <WebServer.h>
#include <WebSocketsServer.h>
#include <WiFiUdp.h>
#include <vector>
@ -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);