diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 0c852691..55408056 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: @@ -110,7 +121,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..bf7f55d0 Binary files /dev/null and b/calibre-plugin/crosspoint_reader/CrossPointReaderCalibrePlugin.zip differ 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..18d4fc94 --- /dev/null +++ b/calibre-plugin/crosspoint_reader/plugin/config.py @@ -0,0 +1,91 @@ +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 +PREFS.defaults['fetch_metadata'] = 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.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) + 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()) + PREFS['fetch_metadata'] = bool(self.fetch_metadata.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..bca803d3 --- /dev/null +++ b/calibre-plugin/crosspoint_reader/plugin/driver.py @@ -0,0 +1,367 @@ +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 +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' + MUST_READ_METADATA = False + SUPPORTS_DEVICE_DB = False + # Disable Calibre's device cache so we always refresh from device. + device_is_usb_mass_storage = False + + 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 _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() + + def save_settings(self, config_widget): + config_widget.save() + + def books(self, oncard=None, end_session=True): + 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. + 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) + lpath = upload_path + if not lpath.startswith('/'): + lpath = '/' + lpath + if lpath != '/' and lpath.endswith('/'): + lpath = lpath[:-1] + if lpath == '/': + lpath = '/' + filename + else: + lpath = lpath + '/' + filename + + 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((lpath, os.path.getsize(filepath))) + + self.report_progress(1.0, 'Transferring books to device...') + return paths + + def add_books_to_metadata(self, locations, metadata, booklists): + metadata = iter(metadata) + for location in locations: + info = next(metadata) + lpath = location[0] + length = location[1] + book = Book('', lpath, size=length, other=info) + if booklists: + booklists[0].add_book(book, replace_metadata=True) + + 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): + 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}') + self._log(f'[CrossPoint] deleted {path}') + + def remove_books_from_metadata(self, paths, booklists): + def norm(p): + if not p: + return '' + p = p.replace('\\', '/') + if not p.startswith('/'): + p = '/' + p + return p + + def norm_name(p): + if not p: + return '' + name = os.path.basename(p) + try: + import unicodedata + name = unicodedata.normalize('NFKC', name) + except Exception: + pass + name = name.replace('\u2019', "'").replace('\u2018', "'") + return name.casefold() + + device_names = set() + try: + entries = self._http_get_json('/api/files', params={'path': '/'}) + on_device = set() + for entry in entries: + if entry.get('isDirectory'): + continue + name = entry.get('name', '') + if not name: + continue + on_device.add(norm(name)) + on_device.add(norm('/' + name)) + device_names.add(norm_name(name)) + self._log(f'[CrossPoint] on-device list: {sorted(on_device)}') + except Exception as exc: + self._log(f'[CrossPoint] refresh list failed: {exc}') + on_device = None + + removed = 0 + for bl in booklists: + for book in tuple(bl): + bpath = norm(getattr(book, 'path', '')) + blpath = norm(getattr(book, 'lpath', '')) + self._log(f'[CrossPoint] book paths: {bpath} | {blpath}') + should_remove = False + if on_device is not None: + if device_names: + if norm_name(bpath) not in device_names and norm_name(blpath) not in device_names: + should_remove = True + elif bpath and bpath not in on_device and blpath and blpath not in on_device: + should_remove = True + else: + for path in paths: + target = norm(path) + target_name = os.path.basename(target) + if target == bpath or target == blpath: + should_remove = True + elif target_name and (os.path.basename(bpath) == target_name or os.path.basename(blpath) == target_name): + should_remove = True + if should_remove: + bl.remove_book(book) + removed += 1 + if removed: + self._log(f'[CrossPoint] removed {removed} items from device list') + + 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 + + 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..d87fa0b2 --- /dev/null +++ b/calibre-plugin/crosspoint_reader/plugin/ws_client.py @@ -0,0 +1,294 @@ +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): + deadline = time.time() + self.timeout + while True: + if time.time() > deadline: + raise WebSocketError('Timed out waiting for text frame') + 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 == 0x9: + # Ping -> respond with Pong + self._send_frame(0xA, payload) + continue + if opcode == 0xA: + # Pong -> ignore + continue + if opcode != 0x1: + self._log('Ignoring non-text opcode', opcode, len(payload)) + continue + 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 not msg: + raise WebSocketError('Unexpected response: ') + 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/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 17b5d053..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 = 18; +constexpr uint8_t SETTINGS_COUNT = 21; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -48,6 +48,9 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, textAntiAliasing); serialization::writePod(outputFile, hideBatteryPercentage); serialization::writePod(outputFile, longPressChapterSkip); + // New fields added at end for backward compatibility + serialization::writeString(outputFile, std::string(opdsUsername)); + serialization::writeString(outputFile, std::string(opdsPassword)); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -110,12 +113,28 @@ bool CrossPointSettings::loadFromFile() { strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1); opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0'; } + if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, textAntiAliasing); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, hideBatteryPercentage); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, longPressChapterSkip); if (++settingsRead >= fileSettingsCount) break; + // New fields added at end for backward compatibility + { + std::string usernameStr; + serialization::readString(inputFile, usernameStr); + strncpy(opdsUsername, usernameStr.c_str(), sizeof(opdsUsername) - 1); + opdsUsername[sizeof(opdsUsername) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; + { + std::string passwordStr; + serialization::readString(inputFile, passwordStr); + strncpy(opdsPassword, passwordStr.c_str(), sizeof(opdsPassword) - 1); + opdsPassword[sizeof(opdsPassword) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index a5641aad..b07346e9 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -88,6 +88,8 @@ class CrossPointSettings { uint8_t screenMargin = 5; // OPDS browser settings char opdsServerUrl[128] = ""; + char opdsUsername[64] = ""; + char opdsPassword[64] = ""; // Hide battery percentage uint8_t hideBatteryPercentage = HIDE_NEVER; // Long-press chapter skip on side buttons diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 4e0a08d2..dadbde51 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -16,7 +16,6 @@ namespace { constexpr int PAGE_ITEMS = 23; constexpr int SKIP_PAGE_MS = 700; -constexpr char OPDS_ROOT_PATH[] = "opds"; // No leading slash - relative to server URL } // namespace void OpdsBookBrowserActivity::taskTrampoline(void* param) { @@ -31,7 +30,7 @@ void OpdsBookBrowserActivity::onEnter() { state = BrowserState::CHECK_WIFI; entries.clear(); navigationHistory.clear(); - currentPath = OPDS_ROOT_PATH; + currentPath = ""; // Root path - user provides full URL in settings selectorIndex = 0; errorMessage.clear(); statusMessage = "Checking WiFi..."; @@ -170,7 +169,7 @@ void OpdsBookBrowserActivity::render() const { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre Library", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, 15, "OPDS Browser", true, EpdFontFamily::BOLD); if (state == BrowserState::CHECK_WIFI) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 3a97e132..b1488328 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -498,8 +498,8 @@ void HomeActivity::render() { // Build menu items dynamically std::vector menuItems = {"Browse Files", "File Transfer", "Settings"}; if (hasOpdsUrl) { - // Insert Calibre Library after Browse Files - menuItems.insert(menuItems.begin() + 1, "Calibre Library"); + // Insert OPDS Browser after Browse Files + menuItems.insert(menuItems.begin() + 1, "OPDS Browser"); } const int menuTileWidth = pageWidth - 2 * margin; diff --git a/src/activities/network/CalibreConnectActivity.cpp b/src/activities/network/CalibreConnectActivity.cpp new file mode 100644 index 00000000..8aa60c40 --- /dev/null +++ b/src/activities/network/CalibreConnectActivity.cpp @@ -0,0 +1,276 @@ +#include "CalibreConnectActivity.h" + +#include +#include +#include +#include + +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "WifiSelectionActivity.h" +#include "fontIds.h" + +namespace { +constexpr const char* HOSTNAME = "crosspoint"; +} // namespace + +void CalibreConnectActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void CalibreConnectActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + updateRequired = true; + state = CalibreConnectState::WIFI_SELECTION; + connectedIP.clear(); + connectedSSID.clear(); + lastHandleClientTime = 0; + lastProgressReceived = 0; + lastProgressTotal = 0; + currentUploadName.clear(); + lastCompleteName.clear(); + lastCompleteAt = 0; + exitRequested = false; + + xTaskCreate(&CalibreConnectActivity::taskTrampoline, "CalibreConnectTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); + + if (WiFi.status() != WL_CONNECTED) { + enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, + [this](const bool connected) { onWifiSelectionComplete(connected); })); + } else { + connectedIP = WiFi.localIP().toString().c_str(); + connectedSSID = WiFi.SSID().c_str(); + startWebServer(); + } +} + +void CalibreConnectActivity::onExit() { + ActivityWithSubactivity::onExit(); + + stopWebServer(); + MDNS.end(); + + delay(50); + WiFi.disconnect(false); + delay(30); + WiFi.mode(WIFI_OFF); + delay(30); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void CalibreConnectActivity::onWifiSelectionComplete(const bool connected) { + if (!connected) { + exitActivity(); + onComplete(); + return; + } + + if (subActivity) { + connectedIP = static_cast(subActivity.get())->getConnectedIP(); + } else { + connectedIP = WiFi.localIP().toString().c_str(); + } + connectedSSID = WiFi.SSID().c_str(); + exitActivity(); + startWebServer(); +} + +void CalibreConnectActivity::startWebServer() { + state = CalibreConnectState::SERVER_STARTING; + updateRequired = true; + + if (MDNS.begin(HOSTNAME)) { + // mDNS is optional for the Calibre plugin but still helpful for users. + Serial.printf("[%lu] [CAL] mDNS started: http://%s.local/\n", millis(), HOSTNAME); + } + + webServer.reset(new CrossPointWebServer()); + webServer->begin(); + + if (webServer->isRunning()) { + state = CalibreConnectState::SERVER_RUNNING; + updateRequired = true; + } else { + state = CalibreConnectState::ERROR; + updateRequired = true; + } +} + +void CalibreConnectActivity::stopWebServer() { + if (webServer) { + webServer->stop(); + webServer.reset(); + } +} + +void CalibreConnectActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + exitRequested = true; + } + + if (webServer && webServer->isRunning()) { + const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; + if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) { + Serial.printf("[%lu] [CAL] WARNING: %lu ms gap since last handleClient\n", millis(), timeSinceLastHandleClient); + } + + esp_task_wdt_reset(); + constexpr int MAX_ITERATIONS = 80; + for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) { + webServer->handleClient(); + if ((i & 0x07) == 0x07) { + esp_task_wdt_reset(); + } + if ((i & 0x0F) == 0x0F) { + yield(); + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + exitRequested = true; + break; + } + } + } + lastHandleClientTime = millis(); + + const auto status = webServer->getWsUploadStatus(); + bool changed = false; + if (status.inProgress) { + if (status.received != lastProgressReceived || status.total != lastProgressTotal || + status.filename != currentUploadName) { + lastProgressReceived = status.received; + lastProgressTotal = status.total; + currentUploadName = status.filename; + changed = true; + } + } else if (lastProgressReceived != 0 || lastProgressTotal != 0) { + lastProgressReceived = 0; + lastProgressTotal = 0; + currentUploadName.clear(); + changed = true; + } + if (status.lastCompleteAt != 0 && status.lastCompleteAt != lastCompleteAt) { + lastCompleteAt = status.lastCompleteAt; + lastCompleteName = status.lastCompleteName; + changed = true; + } + if (lastCompleteAt > 0 && (millis() - lastCompleteAt) >= 6000) { + lastCompleteAt = 0; + lastCompleteName.clear(); + changed = true; + } + if (changed) { + updateRequired = true; + } + } + + if (exitRequested) { + onComplete(); + return; + } +} + +void CalibreConnectActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void CalibreConnectActivity::render() const { + if (state == CalibreConnectState::SERVER_RUNNING) { + renderer.clearScreen(); + renderServerRunning(); + renderer.displayBuffer(); + return; + } + + renderer.clearScreen(); + const auto pageHeight = renderer.getScreenHeight(); + if (state == CalibreConnectState::SERVER_STARTING) { + renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Starting Calibre...", true, EpdFontFamily::BOLD); + } else if (state == CalibreConnectState::ERROR) { + renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Calibre setup failed", true, EpdFontFamily::BOLD); + } + renderer.displayBuffer(); +} + +void CalibreConnectActivity::renderServerRunning() const { + constexpr int LINE_SPACING = 24; + 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 = 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, "..."); + } + 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 + 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 += 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()) { + label += ": " + currentUploadName; + if (label.length() > 34) { + label.replace(31, label.length() - 31, "..."); + } + } + renderer.drawCenteredText(SMALL_FONT_ID, y, label.c_str()); + constexpr int barWidth = 300; + constexpr int barHeight = 16; + constexpr int barX = (480 - barWidth) / 2; + ScreenComponents::drawProgressBar(renderer, barX, y + 22, barWidth, barHeight, lastProgressReceived, + lastProgressTotal); + y += 40; + } + + if (lastCompleteAt > 0 && (millis() - lastCompleteAt) < 6000) { + std::string msg = "Received: " + lastCompleteName; + if (msg.length() > 36) { + msg.replace(33, msg.length() - 33, "..."); + } + renderer.drawCenteredText(SMALL_FONT_ID, y, msg.c_str()); + } + + const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} diff --git a/src/activities/network/CalibreConnectActivity.h b/src/activities/network/CalibreConnectActivity.h new file mode 100644 index 00000000..08cf4bb4 --- /dev/null +++ b/src/activities/network/CalibreConnectActivity.h @@ -0,0 +1,55 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "activities/ActivityWithSubactivity.h" +#include "network/CrossPointWebServer.h" + +enum class CalibreConnectState { WIFI_SELECTION, SERVER_STARTING, SERVER_RUNNING, ERROR }; + +/** + * CalibreConnectActivity starts the file transfer server in STA mode, + * but renders Calibre-specific instructions instead of the web transfer UI. + */ +class CalibreConnectActivity final : public ActivityWithSubactivity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + CalibreConnectState state = CalibreConnectState::WIFI_SELECTION; + const std::function onComplete; + + std::unique_ptr webServer; + std::string connectedIP; + std::string connectedSSID; + unsigned long lastHandleClientTime = 0; + size_t lastProgressReceived = 0; + size_t lastProgressTotal = 0; + std::string currentUploadName; + std::string lastCompleteName; + unsigned long lastCompleteAt = 0; + bool exitRequested = false; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + void renderServerRunning() const; + + void onWifiSelectionComplete(bool connected); + void startWebServer(); + void stopWebServer(); + + public: + explicit CalibreConnectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onComplete) + : ActivityWithSubactivity("CalibreConnect", renderer, mappedInput), onComplete(onComplete) {} + void onEnter() override; + void onExit() override; + void loop() override; + bool skipLoopDelay() override { return webServer && webServer->isRunning(); } + bool preventAutoSleep() override { return webServer && webServer->isRunning(); } +}; diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp deleted file mode 100644 index 0ad9094a..00000000 --- a/src/activities/network/CalibreWirelessActivity.cpp +++ /dev/null @@ -1,756 +0,0 @@ -#include "CalibreWirelessActivity.h" - -#include -#include -#include -#include - -#include - -#include "MappedInputManager.h" -#include "ScreenComponents.h" -#include "fontIds.h" -#include "util/StringUtils.h" - -namespace { -constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678}; -constexpr uint16_t LOCAL_UDP_PORT = 8134; // Port to receive responses -} // namespace - -void CalibreWirelessActivity::displayTaskTrampoline(void* param) { - auto* self = static_cast(param); - self->displayTaskLoop(); -} - -void CalibreWirelessActivity::networkTaskTrampoline(void* param) { - auto* self = static_cast(param); - self->networkTaskLoop(); -} - -void CalibreWirelessActivity::onEnter() { - Activity::onEnter(); - - renderingMutex = xSemaphoreCreateMutex(); - stateMutex = xSemaphoreCreateMutex(); - - state = WirelessState::DISCOVERING; - statusMessage = "Discovering Calibre..."; - errorMessage.clear(); - calibreHostname.clear(); - calibreHost.clear(); - calibrePort = 0; - calibreAltPort = 0; - currentFilename.clear(); - currentFileSize = 0; - bytesReceived = 0; - inBinaryMode = false; - recvBuffer.clear(); - - updateRequired = true; - - // Start UDP listener for Calibre responses - udp.begin(LOCAL_UDP_PORT); - - // Create display task - xTaskCreate(&CalibreWirelessActivity::displayTaskTrampoline, "CalDisplayTask", 2048, this, 1, &displayTaskHandle); - - // Create network task with larger stack for JSON parsing - xTaskCreate(&CalibreWirelessActivity::networkTaskTrampoline, "CalNetworkTask", 12288, this, 2, &networkTaskHandle); -} - -void CalibreWirelessActivity::onExit() { - Activity::onExit(); - - // Turn off WiFi when exiting - WiFi.mode(WIFI_OFF); - - // Stop UDP listening - udp.stop(); - - // Close TCP client if connected - if (tcpClient.connected()) { - tcpClient.stop(); - } - - // Close any open file - if (currentFile) { - currentFile.close(); - } - - // Acquire stateMutex before deleting network task to avoid race condition - xSemaphoreTake(stateMutex, portMAX_DELAY); - if (networkTaskHandle) { - vTaskDelete(networkTaskHandle); - networkTaskHandle = nullptr; - } - xSemaphoreGive(stateMutex); - - // Acquire renderingMutex before deleting display task - xSemaphoreTake(renderingMutex, portMAX_DELAY); - if (displayTaskHandle) { - vTaskDelete(displayTaskHandle); - displayTaskHandle = nullptr; - } - vSemaphoreDelete(renderingMutex); - renderingMutex = nullptr; - - vSemaphoreDelete(stateMutex); - stateMutex = nullptr; -} - -void CalibreWirelessActivity::loop() { - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { - onComplete(); - return; - } -} - -void CalibreWirelessActivity::displayTaskLoop() { - while (true) { - if (updateRequired) { - updateRequired = false; - xSemaphoreTake(renderingMutex, portMAX_DELAY); - render(); - xSemaphoreGive(renderingMutex); - } - vTaskDelay(50 / portTICK_PERIOD_MS); - } -} - -void CalibreWirelessActivity::networkTaskLoop() { - while (true) { - xSemaphoreTake(stateMutex, portMAX_DELAY); - const auto currentState = state; - xSemaphoreGive(stateMutex); - - switch (currentState) { - case WirelessState::DISCOVERING: - listenForDiscovery(); - break; - - case WirelessState::CONNECTING: - case WirelessState::WAITING: - case WirelessState::RECEIVING: - handleTcpClient(); - break; - - case WirelessState::COMPLETE: - case WirelessState::DISCONNECTED: - case WirelessState::ERROR: - // Just wait, user will exit - vTaskDelay(100 / portTICK_PERIOD_MS); - break; - } - - vTaskDelay(10 / portTICK_PERIOD_MS); - } -} - -void CalibreWirelessActivity::listenForDiscovery() { - // Broadcast "hello" on all UDP discovery ports to find Calibre - for (const uint16_t port : UDP_PORTS) { - udp.beginPacket("255.255.255.255", port); - udp.write(reinterpret_cast("hello"), 5); - udp.endPacket(); - } - - // Wait for Calibre's response - vTaskDelay(500 / portTICK_PERIOD_MS); - - // Check for response - const int packetSize = udp.parsePacket(); - if (packetSize > 0) { - char buffer[256]; - const int len = udp.read(buffer, sizeof(buffer) - 1); - if (len > 0) { - buffer[len] = '\0'; - - // Parse Calibre's response format: - // "calibre wireless device client (on hostname);port,content_server_port" - // or just the hostname and port info - std::string response(buffer); - - // Try to extract host and port - // Format: "calibre wireless device client (on HOSTNAME);PORT,..." - size_t onPos = response.find("(on "); - size_t closePos = response.find(')'); - size_t semiPos = response.find(';'); - size_t commaPos = response.find(',', semiPos); - - if (semiPos != std::string::npos) { - // Get ports after semicolon (format: "port1,port2") - std::string portStr; - if (commaPos != std::string::npos && commaPos > semiPos) { - portStr = response.substr(semiPos + 1, commaPos - semiPos - 1); - // Get alternative port after comma - std::string altPortStr = response.substr(commaPos + 1); - // Trim whitespace and non-digits from alt port - size_t altEnd = 0; - while (altEnd < altPortStr.size() && altPortStr[altEnd] >= '0' && altPortStr[altEnd] <= '9') { - altEnd++; - } - if (altEnd > 0) { - calibreAltPort = static_cast(std::stoi(altPortStr.substr(0, altEnd))); - } - } else { - portStr = response.substr(semiPos + 1); - } - - // Trim whitespace from main port - while (!portStr.empty() && (portStr[0] == ' ' || portStr[0] == '\t')) { - portStr = portStr.substr(1); - } - - if (!portStr.empty()) { - calibrePort = static_cast(std::stoi(portStr)); - } - - // Get hostname if present, otherwise use sender IP - if (onPos != std::string::npos && closePos != std::string::npos && closePos > onPos + 4) { - calibreHostname = response.substr(onPos + 4, closePos - onPos - 4); - } - } - - // Use the sender's IP as the host to connect to - calibreHost = udp.remoteIP().toString().c_str(); - if (calibreHostname.empty()) { - calibreHostname = calibreHost; - } - - if (calibrePort > 0) { - // Connect to Calibre's TCP server - try main port first, then alt port - setState(WirelessState::CONNECTING); - setStatus("Connecting to " + calibreHostname + "..."); - - // Small delay before connecting - vTaskDelay(100 / portTICK_PERIOD_MS); - - bool connected = false; - - // Try main port first - if (tcpClient.connect(calibreHost.c_str(), calibrePort, 5000)) { - connected = true; - } - - // Try alternative port if main failed - if (!connected && calibreAltPort > 0) { - vTaskDelay(200 / portTICK_PERIOD_MS); - if (tcpClient.connect(calibreHost.c_str(), calibreAltPort, 5000)) { - connected = true; - } - } - - if (connected) { - setState(WirelessState::WAITING); - setStatus("Connected to " + calibreHostname + "\nWaiting for commands..."); - } else { - // Don't set error yet, keep trying discovery - setState(WirelessState::DISCOVERING); - setStatus("Discovering Calibre...\n(Connection failed, retrying)"); - calibrePort = 0; - calibreAltPort = 0; - } - } - } - } -} - -void CalibreWirelessActivity::handleTcpClient() { - if (!tcpClient.connected()) { - setState(WirelessState::DISCONNECTED); - setStatus("Calibre disconnected"); - return; - } - - if (inBinaryMode) { - receiveBinaryData(); - return; - } - - std::string message; - if (readJsonMessage(message)) { - // Parse opcode from JSON array format: [opcode, {...}] - // Find the opcode (first number after '[') - size_t start = message.find('['); - if (start != std::string::npos) { - start++; - size_t end = message.find(',', start); - if (end != std::string::npos) { - const int opcodeInt = std::stoi(message.substr(start, end - start)); - if (opcodeInt < 0 || opcodeInt >= OpCode::ERROR) { - Serial.printf("[%lu] [CAL] Invalid opcode: %d\n", millis(), opcodeInt); - sendJsonResponse(OpCode::OK, "{}"); - return; - } - const auto opcode = static_cast(opcodeInt); - - // Extract data object (everything after the comma until the last ']') - size_t dataStart = end + 1; - size_t dataEnd = message.rfind(']'); - std::string data = ""; - if (dataEnd != std::string::npos && dataEnd > dataStart) { - data = message.substr(dataStart, dataEnd - dataStart); - } - - handleCommand(opcode, data); - } - } - } -} - -bool CalibreWirelessActivity::readJsonMessage(std::string& message) { - // Read available data into buffer - int available = tcpClient.available(); - if (available > 0) { - // Limit buffer growth to prevent memory issues - if (recvBuffer.size() > 100000) { - recvBuffer.clear(); - return false; - } - // Read in chunks - char buf[1024]; - while (available > 0) { - int toRead = std::min(available, static_cast(sizeof(buf))); - int bytesRead = tcpClient.read(reinterpret_cast(buf), toRead); - if (bytesRead > 0) { - recvBuffer.append(buf, bytesRead); - available -= bytesRead; - } else { - break; - } - } - } - - if (recvBuffer.empty()) { - return false; - } - - // Find '[' which marks the start of JSON - size_t bracketPos = recvBuffer.find('['); - if (bracketPos == std::string::npos) { - // No '[' found - if buffer is getting large, something is wrong - if (recvBuffer.size() > 1000) { - recvBuffer.clear(); - } - return false; - } - - // Try to extract length from digits before '[' - // Calibre ALWAYS sends a length prefix, so if it's not valid digits, it's garbage - size_t msgLen = 0; - bool validPrefix = false; - - if (bracketPos > 0 && bracketPos <= 12) { - // Check if prefix is all digits - bool allDigits = true; - for (size_t i = 0; i < bracketPos; i++) { - char c = recvBuffer[i]; - if (c < '0' || c > '9') { - allDigits = false; - break; - } - } - if (allDigits) { - msgLen = std::stoul(recvBuffer.substr(0, bracketPos)); - validPrefix = true; - } - } - - if (!validPrefix) { - // Not a valid length prefix - discard everything up to '[' and treat '[' as start - if (bracketPos > 0) { - recvBuffer = recvBuffer.substr(bracketPos); - } - // Without length prefix, we can't reliably parse - wait for more data - // that hopefully starts with a proper length prefix - return false; - } - - // Sanity check the message length - if (msgLen > 1000000) { - recvBuffer = recvBuffer.substr(bracketPos + 1); // Skip past this '[' and try again - return false; - } - - // Check if we have the complete message - size_t totalNeeded = bracketPos + msgLen; - if (recvBuffer.size() < totalNeeded) { - // Not enough data yet - wait for more - return false; - } - - // Extract the message - message = recvBuffer.substr(bracketPos, msgLen); - - // Keep the rest in buffer (may contain binary data or next message) - if (recvBuffer.size() > totalNeeded) { - recvBuffer = recvBuffer.substr(totalNeeded); - } else { - recvBuffer.clear(); - } - - return true; -} - -void CalibreWirelessActivity::sendJsonResponse(const OpCode opcode, const std::string& data) { - // Format: length + [opcode, {data}] - std::string json = "[" + std::to_string(opcode) + "," + data + "]"; - const std::string lengthPrefix = std::to_string(json.length()); - json.insert(0, lengthPrefix); - - tcpClient.write(reinterpret_cast(json.c_str()), json.length()); - tcpClient.flush(); -} - -void CalibreWirelessActivity::handleCommand(const OpCode opcode, const std::string& data) { - switch (opcode) { - case OpCode::GET_INITIALIZATION_INFO: - handleGetInitializationInfo(data); - break; - case OpCode::GET_DEVICE_INFORMATION: - handleGetDeviceInformation(); - break; - case OpCode::FREE_SPACE: - handleFreeSpace(); - break; - case OpCode::GET_BOOK_COUNT: - handleGetBookCount(); - break; - case OpCode::SEND_BOOK: - handleSendBook(data); - break; - case OpCode::SEND_BOOK_METADATA: - handleSendBookMetadata(data); - break; - case OpCode::DISPLAY_MESSAGE: - handleDisplayMessage(data); - break; - case OpCode::NOOP: - handleNoop(data); - break; - case OpCode::SET_CALIBRE_DEVICE_INFO: - case OpCode::SET_CALIBRE_DEVICE_NAME: - // These set metadata about the connected Calibre instance. - // We don't need this info, just acknowledge receipt. - sendJsonResponse(OpCode::OK, "{}"); - break; - case OpCode::SET_LIBRARY_INFO: - // Library metadata (name, UUID) - not needed for receiving books - sendJsonResponse(OpCode::OK, "{}"); - break; - case OpCode::SEND_BOOKLISTS: - // Calibre asking us to send our book list. We report 0 books in - // handleGetBookCount, so this is effectively a no-op. - sendJsonResponse(OpCode::OK, "{}"); - break; - case OpCode::TOTAL_SPACE: - handleFreeSpace(); - break; - default: - Serial.printf("[%lu] [CAL] Unknown opcode: %d\n", millis(), opcode); - sendJsonResponse(OpCode::OK, "{}"); - break; - } -} - -void CalibreWirelessActivity::handleGetInitializationInfo(const std::string& data) { - setState(WirelessState::WAITING); - setStatus("Connected to " + calibreHostname + - "\nWaiting for transfer...\n\nIf transfer fails, enable\n'Ignore free space' in Calibre's\nSmartDevice " - "plugin settings."); - - // Build response with device capabilities - // Format must match what Calibre expects from a smart device - std::string response = "{"; - response += "\"appName\":\"CrossPoint\","; - response += "\"acceptedExtensions\":[\"epub\"],"; - response += "\"cacheUsesLpaths\":true,"; - response += "\"canAcceptLibraryInfo\":true,"; - response += "\"canDeleteMultipleBooks\":true,"; - response += "\"canReceiveBookBinary\":true,"; - response += "\"canSendOkToSendbook\":true,"; - response += "\"canStreamBooks\":true,"; - response += "\"canStreamMetadata\":true,"; - response += "\"canUseCachedMetadata\":true,"; - // ccVersionNumber: Calibre Companion protocol version. 212 matches CC 5.4.20+. - // Using a known version ensures compatibility with Calibre's feature detection. - response += "\"ccVersionNumber\":212,"; - // coverHeight: Max cover image height. We don't process covers, so this is informational only. - response += "\"coverHeight\":800,"; - response += "\"deviceKind\":\"CrossPoint\","; - response += "\"deviceName\":\"CrossPoint\","; - response += "\"extensionPathLengths\":{\"epub\":37},"; - response += "\"maxBookContentPacketLen\":4096,"; - response += "\"passwordHash\":\"\","; - response += "\"useUuidFileNames\":false,"; - response += "\"versionOK\":true"; - response += "}"; - - sendJsonResponse(OpCode::OK, response); -} - -void CalibreWirelessActivity::handleGetDeviceInformation() { - std::string response = "{"; - response += "\"device_info\":{"; - response += "\"device_store_uuid\":\"" + getDeviceUuid() + "\","; - response += "\"device_name\":\"CrossPoint Reader\","; - response += "\"device_version\":\"" CROSSPOINT_VERSION "\""; - response += "},"; - response += "\"version\":1,"; - response += "\"device_version\":\"" CROSSPOINT_VERSION "\""; - response += "}"; - - sendJsonResponse(OpCode::OK, response); -} - -void CalibreWirelessActivity::handleFreeSpace() { - // TODO: Report actual SD card free space instead of hardcoded value - // Report 10GB free space for now - sendJsonResponse(OpCode::OK, "{\"free_space_on_device\":10737418240}"); -} - -void CalibreWirelessActivity::handleGetBookCount() { - // We report 0 books - Calibre will send books without checking for duplicates - std::string response = "{\"count\":0,\"willStream\":true,\"willScan\":false}"; - sendJsonResponse(OpCode::OK, response); -} - -void CalibreWirelessActivity::handleSendBook(const std::string& data) { - // Manually extract lpath and length from SEND_BOOK data - // Full JSON parsing crashes on large metadata, so we just extract what we need - - // Extract "lpath" field - format: "lpath": "value" - std::string lpath; - size_t lpathPos = data.find("\"lpath\""); - if (lpathPos != std::string::npos) { - size_t colonPos = data.find(':', lpathPos + 7); - if (colonPos != std::string::npos) { - size_t quoteStart = data.find('"', colonPos + 1); - if (quoteStart != std::string::npos) { - size_t quoteEnd = data.find('"', quoteStart + 1); - if (quoteEnd != std::string::npos) { - lpath = data.substr(quoteStart + 1, quoteEnd - quoteStart - 1); - } - } - } - } - - // Extract top-level "length" field - must track depth to skip nested objects - // The metadata contains nested "length" fields (e.g., cover image length) - size_t length = 0; - int depth = 0; - for (size_t i = 0; i < data.size(); i++) { - char c = data[i]; - if (c == '{' || c == '[') { - depth++; - } else if (c == '}' || c == ']') { - depth--; - } else if (depth == 1 && c == '"') { - // At top level, check if this is "length" - if (i + 9 < data.size() && data.substr(i, 8) == "\"length\"") { - // Found top-level "length" - extract the number after ':' - size_t colonPos = data.find(':', i + 8); - if (colonPos != std::string::npos) { - size_t numStart = colonPos + 1; - while (numStart < data.size() && (data[numStart] == ' ' || data[numStart] == '\t')) { - numStart++; - } - size_t numEnd = numStart; - while (numEnd < data.size() && data[numEnd] >= '0' && data[numEnd] <= '9') { - numEnd++; - } - if (numEnd > numStart) { - length = std::stoul(data.substr(numStart, numEnd - numStart)); - break; - } - } - } - } - } - - if (lpath.empty() || length == 0) { - sendJsonResponse(OpCode::ERROR, "{\"message\":\"Invalid book data\"}"); - return; - } - - // Extract filename from lpath - std::string filename = lpath; - const size_t lastSlash = filename.rfind('/'); - if (lastSlash != std::string::npos) { - filename = filename.substr(lastSlash + 1); - } - - // Sanitize and create full path - currentFilename = "/" + StringUtils::sanitizeFilename(filename); - if (!StringUtils::checkFileExtension(currentFilename, ".epub")) { - currentFilename += ".epub"; - } - currentFileSize = length; - bytesReceived = 0; - - setState(WirelessState::RECEIVING); - setStatus("Receiving: " + filename); - - // Open file for writing - if (!SdMan.openFileForWrite("CAL", currentFilename.c_str(), currentFile)) { - setError("Failed to create file"); - sendJsonResponse(OpCode::ERROR, "{\"message\":\"Failed to create file\"}"); - return; - } - - // Send OK to start receiving binary data - sendJsonResponse(OpCode::OK, "{}"); - - // Switch to binary mode - inBinaryMode = true; - binaryBytesRemaining = length; - - // Check if recvBuffer has leftover data (binary file data that arrived with the JSON) - if (!recvBuffer.empty()) { - size_t toWrite = std::min(recvBuffer.size(), binaryBytesRemaining); - size_t written = currentFile.write(reinterpret_cast(recvBuffer.data()), toWrite); - bytesReceived += written; - binaryBytesRemaining -= written; - recvBuffer = recvBuffer.substr(toWrite); - updateRequired = true; - } -} - -void CalibreWirelessActivity::handleSendBookMetadata(const std::string& data) { - // We receive metadata after the book - just acknowledge - sendJsonResponse(OpCode::OK, "{}"); -} - -void CalibreWirelessActivity::handleDisplayMessage(const std::string& data) { - // Calibre may send messages to display - // Check messageKind - 1 means password error - if (data.find("\"messageKind\":1") != std::string::npos) { - setError("Password required"); - } - sendJsonResponse(OpCode::OK, "{}"); -} - -void CalibreWirelessActivity::handleNoop(const std::string& data) { - // Check for ejecting flag - if (data.find("\"ejecting\":true") != std::string::npos) { - setState(WirelessState::DISCONNECTED); - setStatus("Calibre disconnected"); - } - sendJsonResponse(OpCode::NOOP, "{}"); -} - -void CalibreWirelessActivity::receiveBinaryData() { - const int available = tcpClient.available(); - if (available == 0) { - // Check if connection is still alive - if (!tcpClient.connected()) { - currentFile.close(); - inBinaryMode = false; - setError("Transfer interrupted"); - } - return; - } - - uint8_t buffer[1024]; - const size_t toRead = std::min(sizeof(buffer), binaryBytesRemaining); - const size_t bytesRead = tcpClient.read(buffer, toRead); - - if (bytesRead > 0) { - currentFile.write(buffer, bytesRead); - bytesReceived += bytesRead; - binaryBytesRemaining -= bytesRead; - updateRequired = true; - - if (binaryBytesRemaining == 0) { - // Transfer complete - currentFile.flush(); - currentFile.close(); - inBinaryMode = false; - - setState(WirelessState::WAITING); - setStatus("Received: " + currentFilename + "\nWaiting for more..."); - - // Send OK to acknowledge completion - sendJsonResponse(OpCode::OK, "{}"); - } - } -} - -void CalibreWirelessActivity::render() const { - renderer.clearScreen(); - - const auto pageWidth = renderer.getScreenWidth(); - const auto pageHeight = renderer.getScreenHeight(); - - // Draw header - renderer.drawCenteredText(UI_12_FONT_ID, 30, "Calibre Wireless", true, EpdFontFamily::BOLD); - - // Draw IP address - const std::string ipAddr = WiFi.localIP().toString().c_str(); - renderer.drawCenteredText(UI_10_FONT_ID, 60, ("IP: " + ipAddr).c_str()); - - // Draw status message - int statusY = pageHeight / 2 - 40; - - // Split status message by newlines and draw each line - std::string status = statusMessage; - size_t pos = 0; - while ((pos = status.find('\n')) != std::string::npos) { - renderer.drawCenteredText(UI_10_FONT_ID, statusY, status.substr(0, pos).c_str()); - statusY += 25; - status = status.substr(pos + 1); - } - if (!status.empty()) { - renderer.drawCenteredText(UI_10_FONT_ID, statusY, status.c_str()); - statusY += 25; - } - - // Draw progress if receiving - if (state == WirelessState::RECEIVING && currentFileSize > 0) { - const int barWidth = pageWidth - 100; - constexpr int barHeight = 20; - constexpr int barX = 50; - const int barY = statusY + 20; - ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, bytesReceived, currentFileSize); - } - - // Draw error if present - if (!errorMessage.empty()) { - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight - 120, errorMessage.c_str()); - } - - // Draw button hints - const auto labels = mappedInput.mapLabels("Back", "", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - - renderer.displayBuffer(); -} - -std::string CalibreWirelessActivity::getDeviceUuid() const { - // Generate a consistent UUID based on MAC address - uint8_t mac[6]; - WiFi.macAddress(mac); - - char uuid[37]; - snprintf(uuid, sizeof(uuid), "%02x%02x%02x%02x-%02x%02x-4000-8000-%02x%02x%02x%02x%02x%02x", mac[0], mac[1], mac[2], - mac[3], mac[4], mac[5], mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); - - return std::string(uuid); -} - -void CalibreWirelessActivity::setState(WirelessState newState) { - xSemaphoreTake(stateMutex, portMAX_DELAY); - state = newState; - xSemaphoreGive(stateMutex); - updateRequired = true; -} - -void CalibreWirelessActivity::setStatus(const std::string& message) { - statusMessage = message; - updateRequired = true; -} - -void CalibreWirelessActivity::setError(const std::string& message) { - errorMessage = message; - setState(WirelessState::ERROR); -} diff --git a/src/activities/network/CalibreWirelessActivity.h b/src/activities/network/CalibreWirelessActivity.h deleted file mode 100644 index ae2b1767..00000000 --- a/src/activities/network/CalibreWirelessActivity.h +++ /dev/null @@ -1,135 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include -#include - -#include -#include - -#include "activities/Activity.h" - -/** - * CalibreWirelessActivity implements Calibre's "wireless device" protocol. - * This allows Calibre desktop to send books directly to the device over WiFi. - * - * Protocol specification sourced from Calibre's smart device driver: - * https://github.com/kovidgoyal/calibre/blob/master/src/calibre/devices/smart_device_app/driver.py - * - * Protocol overview: - * 1. Device broadcasts "hello" on UDP ports 54982, 48123, 39001, 44044, 59678 - * 2. Calibre responds with its TCP server address - * 3. Device connects to Calibre's TCP server - * 4. Calibre sends JSON commands with length-prefixed messages - * 5. Books are transferred as binary data after SEND_BOOK command - */ -class CalibreWirelessActivity final : public Activity { - // Calibre wireless device states - enum class WirelessState { - DISCOVERING, // Listening for Calibre server broadcasts - CONNECTING, // Establishing TCP connection - WAITING, // Connected, waiting for commands - RECEIVING, // Receiving a book file - COMPLETE, // Transfer complete - DISCONNECTED, // Calibre disconnected - ERROR // Connection/transfer error - }; - - // Calibre protocol opcodes (from calibre/devices/smart_device_app/driver.py) - enum OpCode : uint8_t { - OK = 0, - SET_CALIBRE_DEVICE_INFO = 1, - SET_CALIBRE_DEVICE_NAME = 2, - GET_DEVICE_INFORMATION = 3, - TOTAL_SPACE = 4, - FREE_SPACE = 5, - GET_BOOK_COUNT = 6, - SEND_BOOKLISTS = 7, - SEND_BOOK = 8, - GET_INITIALIZATION_INFO = 9, - BOOK_DONE = 11, - NOOP = 12, // Was incorrectly 18 - DELETE_BOOK = 13, - GET_BOOK_FILE_SEGMENT = 14, - GET_BOOK_METADATA = 15, - SEND_BOOK_METADATA = 16, - DISPLAY_MESSAGE = 17, - CALIBRE_BUSY = 18, - SET_LIBRARY_INFO = 19, - ERROR = 20, - }; - - TaskHandle_t displayTaskHandle = nullptr; - TaskHandle_t networkTaskHandle = nullptr; - SemaphoreHandle_t renderingMutex = nullptr; - SemaphoreHandle_t stateMutex = nullptr; - bool updateRequired = false; - - WirelessState state = WirelessState::DISCOVERING; - const std::function onComplete; - - // UDP discovery - WiFiUDP udp; - - // TCP connection (we connect to Calibre) - WiFiClient tcpClient; - std::string calibreHost; - uint16_t calibrePort = 0; - uint16_t calibreAltPort = 0; // Alternative port (content server) - std::string calibreHostname; - - // Transfer state - std::string currentFilename; - size_t currentFileSize = 0; - size_t bytesReceived = 0; - std::string statusMessage; - std::string errorMessage; - - // Protocol state - bool inBinaryMode = false; - size_t binaryBytesRemaining = 0; - FsFile currentFile; - std::string recvBuffer; // Buffer for incoming data (like KOReader) - - static void displayTaskTrampoline(void* param); - static void networkTaskTrampoline(void* param); - [[noreturn]] void displayTaskLoop(); - [[noreturn]] void networkTaskLoop(); - void render() const; - - // Network operations - void listenForDiscovery(); - void handleTcpClient(); - bool readJsonMessage(std::string& message); - void sendJsonResponse(OpCode opcode, const std::string& data); - void handleCommand(OpCode opcode, const std::string& data); - void receiveBinaryData(); - - // Protocol handlers - void handleGetInitializationInfo(const std::string& data); - void handleGetDeviceInformation(); - void handleFreeSpace(); - void handleGetBookCount(); - void handleSendBook(const std::string& data); - void handleSendBookMetadata(const std::string& data); - void handleDisplayMessage(const std::string& data); - void handleNoop(const std::string& data); - - // Utility - std::string getDeviceUuid() const; - void setState(WirelessState newState); - void setStatus(const std::string& message); - void setError(const std::string& message); - - public: - explicit CalibreWirelessActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::function& onComplete) - : Activity("CalibreWireless", renderer, mappedInput), onComplete(onComplete) {} - void onEnter() override; - void onExit() override; - void loop() override; - bool preventAutoSleep() override { return true; } - bool skipLoopDelay() override { return true; } -}; diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 35ad58ba..c6af1497 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -12,6 +12,7 @@ #include "MappedInputManager.h" #include "NetworkModeSelectionActivity.h" #include "WifiSelectionActivity.h" +#include "activities/network/CalibreConnectActivity.h" #include "fontIds.h" namespace { @@ -125,8 +126,13 @@ void CrossPointWebServerActivity::onExit() { } void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) { - Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), - mode == NetworkMode::JOIN_NETWORK ? "Join Network" : "Create Hotspot"); + const char* modeName = "Join Network"; + if (mode == NetworkMode::CONNECT_CALIBRE) { + modeName = "Connect to Calibre"; + } else if (mode == NetworkMode::CREATE_HOTSPOT) { + modeName = "Create Hotspot"; + } + Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), modeName); networkMode = mode; isApMode = (mode == NetworkMode::CREATE_HOTSPOT); @@ -134,6 +140,18 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) // Exit mode selection subactivity exitActivity(); + if (mode == NetworkMode::CONNECT_CALIBRE) { + exitActivity(); + enterNewActivity(new CalibreConnectActivity(renderer, mappedInput, [this] { + exitActivity(); + state = WebServerActivityState::MODE_SELECTION; + enterNewActivity(new NetworkModeSelectionActivity( + renderer, mappedInput, [this](const NetworkMode nextMode) { onNetworkModeSelected(nextMode); }, + [this]() { onGoBack(); })); + })); + return; + } + if (mode == NetworkMode::JOIN_NETWORK) { // STA mode - launch WiFi selection Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis()); diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index 775a2474..a1189a57 100644 --- a/src/activities/network/CrossPointWebServerActivity.h +++ b/src/activities/network/CrossPointWebServerActivity.h @@ -23,7 +23,7 @@ enum class WebServerActivityState { /** * CrossPointWebServerActivity is the entry point for file transfer functionality. * It: - * - First presents a choice between "Join a Network" (STA) and "Create Hotspot" (AP) + * - First presents a choice between "Join a Network" (STA), "Connect to Calibre", and "Create Hotspot" (AP) * - For STA mode: Launches WifiSelectionActivity to connect to an existing network * - For AP mode: Creates an Access Point that clients can connect to * - Starts the CrossPointWebServer when connected diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp index ad05f5b8..50767084 100644 --- a/src/activities/network/NetworkModeSelectionActivity.cpp +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -6,10 +6,13 @@ #include "fontIds.h" namespace { -constexpr int MENU_ITEM_COUNT = 2; -const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Create Hotspot"}; -const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {"Connect to an existing WiFi network", - "Create a WiFi network others can join"}; +constexpr int MENU_ITEM_COUNT = 3; +const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Connect to Calibre", "Create Hotspot"}; +const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = { + "Connect to an existing WiFi network", + "Use Calibre wireless device transfers", + "Create a WiFi network others can join", +}; } // namespace void NetworkModeSelectionActivity::taskTrampoline(void* param) { @@ -58,7 +61,12 @@ void NetworkModeSelectionActivity::loop() { // Handle confirm button - select current option if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - const NetworkMode mode = (selectedIndex == 0) ? NetworkMode::JOIN_NETWORK : NetworkMode::CREATE_HOTSPOT; + NetworkMode mode = NetworkMode::JOIN_NETWORK; + if (selectedIndex == 1) { + mode = NetworkMode::CONNECT_CALIBRE; + } else if (selectedIndex == 2) { + mode = NetworkMode::CREATE_HOTSPOT; + } onModeSelected(mode); return; } diff --git a/src/activities/network/NetworkModeSelectionActivity.h b/src/activities/network/NetworkModeSelectionActivity.h index b9f2e1ee..1b93b825 100644 --- a/src/activities/network/NetworkModeSelectionActivity.h +++ b/src/activities/network/NetworkModeSelectionActivity.h @@ -8,11 +8,12 @@ #include "../Activity.h" // Enum for network mode selection -enum class NetworkMode { JOIN_NETWORK, CREATE_HOTSPOT }; +enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT }; /** * NetworkModeSelectionActivity presents the user with a choice: * - "Join a Network" - Connect to an existing WiFi network (STA mode) + * - "Connect to Calibre" - Use Calibre wireless device transfers * - "Create Hotspot" - Create an Access Point that others can connect to (AP mode) * * The onModeSelected callback is called with the user's choice. diff --git a/src/activities/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp index 4f614ffc..d1df9d0e 100644 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ b/src/activities/settings/CalibreSettingsActivity.cpp @@ -1,20 +1,17 @@ #include "CalibreSettingsActivity.h" #include -#include #include #include "CrossPointSettings.h" #include "MappedInputManager.h" -#include "activities/network/CalibreWirelessActivity.h" -#include "activities/network/WifiSelectionActivity.h" #include "activities/util/KeyboardEntryActivity.h" #include "fontIds.h" namespace { -constexpr int MENU_ITEMS = 2; -const char* menuNames[MENU_ITEMS] = {"Calibre Web URL", "Connect as Wireless Device"}; +constexpr int MENU_ITEMS = 3; +const char* menuNames[MENU_ITEMS] = {"OPDS Server URL", "Username", "Password"}; } // namespace void CalibreSettingsActivity::taskTrampoline(void* param) { @@ -80,10 +77,10 @@ void CalibreSettingsActivity::handleSelection() { xSemaphoreTake(renderingMutex, portMAX_DELAY); if (selectedIndex == 0) { - // Calibre Web URL + // OPDS Server URL exitActivity(); enterNewActivity(new KeyboardEntryActivity( - renderer, mappedInput, "Calibre Web URL", SETTINGS.opdsServerUrl, 10, + renderer, mappedInput, "OPDS Server URL", SETTINGS.opdsServerUrl, 10, 127, // maxLength false, // not password [this](const std::string& url) { @@ -98,26 +95,41 @@ void CalibreSettingsActivity::handleSelection() { updateRequired = true; })); } else if (selectedIndex == 1) { - // Wireless Device - launch the activity (handles WiFi connection internally) + // Username exitActivity(); - if (WiFi.status() != WL_CONNECTED) { - enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](bool connected) { - exitActivity(); - if (connected) { - enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] { - exitActivity(); - updateRequired = true; - })); - } else { + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Username", SETTINGS.opdsUsername, 10, + 63, // maxLength + false, // not password + [this](const std::string& username) { + strncpy(SETTINGS.opdsUsername, username.c_str(), sizeof(SETTINGS.opdsUsername) - 1); + SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0'; + SETTINGS.saveToFile(); + exitActivity(); updateRequired = true; - } - })); - } else { - enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] { - exitActivity(); - updateRequired = true; - })); - } + }, + [this]() { + exitActivity(); + updateRequired = true; + })); + } else if (selectedIndex == 2) { + // Password + exitActivity(); + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Password", SETTINGS.opdsPassword, 10, + 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'; + SETTINGS.saveToFile(); + exitActivity(); + updateRequired = true; + }, + [this]() { + exitActivity(); + updateRequired = true; + })); } xSemaphoreGive(renderingMutex); @@ -141,24 +153,32 @@ void CalibreSettingsActivity::render() { const auto pageWidth = renderer.getScreenWidth(); // Draw header - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, 15, "OPDS Browser", true, EpdFontFamily::BOLD); + + // Draw info text about Calibre + renderer.drawCenteredText(UI_10_FONT_ID, 40, "For Calibre, add /opds to your URL"); // Draw selection highlight - renderer.fillRect(0, 60 + selectedIndex * 30 - 2, pageWidth - 1, 30); + renderer.fillRect(0, 70 + selectedIndex * 30 - 2, pageWidth - 1, 30); // Draw menu items for (int i = 0; i < MENU_ITEMS; i++) { - const int settingY = 60 + i * 30; + const int settingY = 70 + i * 30; const bool isSelected = (i == selectedIndex); renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected); - // Draw status for URL setting + // Draw status for each setting + const char* status = "[Not Set]"; if (i == 0) { - const char* status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]"; - const auto width = renderer.getTextWidth(UI_10_FONT_ID, status); - renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected); + status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]"; + } else if (i == 1) { + status = (strlen(SETTINGS.opdsUsername) > 0) ? "[Set]" : "[Not Set]"; + } else if (i == 2) { + status = (strlen(SETTINGS.opdsPassword) > 0) ? "[Set]" : "[Not Set]"; } + const auto width = renderer.getTextWidth(UI_10_FONT_ID, status); + renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected); } // Draw button hints diff --git a/src/activities/settings/CalibreSettingsActivity.h b/src/activities/settings/CalibreSettingsActivity.h index 77b9218c..49695c62 100644 --- a/src/activities/settings/CalibreSettingsActivity.h +++ b/src/activities/settings/CalibreSettingsActivity.h @@ -8,8 +8,8 @@ #include "activities/ActivityWithSubactivity.h" /** - * Submenu for Calibre settings. - * Shows Calibre Web URL and Calibre Wireless Device options. + * Submenu for OPDS Browser settings. + * Shows OPDS Server URL and HTTP authentication options. */ class CalibreSettingsActivity final : public ActivityWithSubactivity { public: diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index efa0b9e1..a04038d3 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -41,7 +41,7 @@ const SettingInfo settingsList[settingsCount] = { {"1 min", "5 min", "10 min", "15 min", "30 min"}), SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), - SettingInfo::Action("Calibre Settings"), + SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Check for updates")}; } // namespace @@ -139,7 +139,7 @@ void SettingsActivity::toggleCurrentSetting() { SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; } } else if (setting.type == SettingType::ACTION) { - if (strcmp(setting.name, "Calibre Settings") == 0) { + if (strcmp(setting.name, "OPDS Browser") == 0) { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 23ba36ba..1427ae2f 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; @@ -28,6 +30,9 @@ size_t wsUploadSize = 0; size_t wsUploadReceived = 0; unsigned long wsUploadStartTime = 0; bool wsUploadInProgress = false; +String wsLastCompleteName; +size_t wsLastCompleteSize = 0; +unsigned long wsLastCompleteAt = 0; } // namespace // File listing page template - now using generated headers: @@ -85,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(); }); @@ -108,6 +114,10 @@ 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 +155,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 +178,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 +204,40 @@ 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(); + } + } + } + } +} + +CrossPointWebServer::WsUploadStatus CrossPointWebServer::getWsUploadStatus() const { + WsUploadStatus status; + status.inProgress = wsUploadInProgress; + status.received = wsUploadReceived; + status.total = wsUploadSize; + status.filename = wsUploadFileName.c_str(); + status.lastCompleteName = wsLastCompleteName.c_str(); + status.lastCompleteSize = wsLastCompleteSize; + status.lastCompleteAt = wsLastCompleteAt; + return status; } void CrossPointWebServer::handleRoot() const { @@ -335,6 +384,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; @@ -781,6 +893,10 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* wsUploadFile.close(); wsUploadInProgress = false; + wsLastCompleteName = wsUploadFileName; + wsLastCompleteSize = wsUploadSize; + wsLastCompleteAt = millis(); + unsigned long elapsed = millis() - wsUploadStartTime; float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0; diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index ecc2d3d2..36030292 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -2,7 +2,10 @@ #include #include +#include +#include +#include #include // Structure to hold file information @@ -15,6 +18,16 @@ struct FileInfo { class CrossPointWebServer { public: + struct WsUploadStatus { + bool inProgress = false; + size_t received = 0; + size_t total = 0; + std::string filename; + std::string lastCompleteName; + size_t lastCompleteSize = 0; + unsigned long lastCompleteAt = 0; + }; + CrossPointWebServer(); ~CrossPointWebServer(); @@ -25,11 +38,13 @@ 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; } + WsUploadStatus getWsUploadStatus() const; + // Get the port number uint16_t getPort() const { return port; } @@ -40,6 +55,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); @@ -56,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 c4de3a05..e7fd4526 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -4,9 +4,12 @@ #include #include #include +#include +#include #include +#include "CrossPointSettings.h" #include "util/UrlUtils.h" bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { @@ -27,6 +30,13 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + // Add Basic HTTP auth if credentials are configured + if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { + 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(); if (httpCode != HTTP_CODE_OK) { Serial.printf("[%lu] [HTTP] Fetch failed: %d\n", millis(), httpCode); @@ -61,6 +71,13 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + // Add Basic HTTP auth if credentials are configured + if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { + 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(); if (httpCode != HTTP_CODE_OK) { Serial.printf("[%lu] [HTTP] Download failed: %d\n", millis(), httpCode);