From 12666636a73afc0d83245556266beedd7a6ce5e2 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Fri, 16 Jan 2026 16:54:44 -0500 Subject: [PATCH] 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);