This commit is contained in:
Justin Mitchell 2026-01-19 21:50:44 +10:00 committed by GitHub
commit 5e546a27bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1413 additions and 945 deletions

View File

@ -60,6 +60,17 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con
> [!TIP]
> Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details.
### 3.4.1 Calibre Wireless Transfers
CrossPoint supports sending books from Calibre using the CrossPoint Reader device plugin.
1. Install the plugin in Calibre:
- Open Calibre → Preferences → Plugins → Load plugin from file.
- Select `calibre-plugin/crosspoint_reader/CrossPointReaderCalibrePlugin.zip`.
2. On the device: File Transfer → Connect to Calibre → Join a network.
3. Make sure your computer is on the same WiFi network.
4. In Calibre, click "Send to device" to transfer books.
### 3.5 Settings
The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust:
@ -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

View File

@ -0,0 +1,25 @@
# CrossPoint Reader Calibre Plugin
This plugin adds CrossPoint Reader as a wireless device in Calibre. It uploads
EPUB files over WebSocket to the CrossPoint web server.
Protocol:
- Connect to ws://<host>:<port>/
- Send: START:<filename>:<size>:<path>
- Wait for READY
- Send binary frames with file content
- Wait for DONE (or ERROR:<message>)
Default settings:
- Auto-discover device via UDP
- Host fallback: 192.168.4.1
- Port: 81
- Upload path: /
Install:
1. Zip the contents of `calibre-plugin/crosspoint_reader/plugin`.
2. In Calibre: Preferences > Plugins > Load plugin from file.
3. The device should appear in Calibre once it is discoverable on the network.
No configuration needed. The plugin auto-discovers the device via UDP and
falls back to 192.168.4.1:81.

View File

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

View File

@ -0,0 +1,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)

View File

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

View File

@ -0,0 +1,17 @@
import time
_LOG = []
_MAX_LINES = 200
def add_log(message):
timestamp = time.strftime('%H:%M:%S')
line = f'[{timestamp}] {message}'
_LOG.append(line)
if len(_LOG) > _MAX_LINES:
_LOG[:len(_LOG) - _MAX_LINES] = []
def get_log_text():
return '\n'.join(_LOG)

View File

@ -0,0 +1,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: <empty>')
if msg.startswith('ERROR'):
raise WebSocketError(msg)
if msg != 'READY':
raise WebSocketError('Unexpected response: ' + msg)
sent = 0
with open(filepath, 'rb') as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
client.send_binary(chunk)
sent += len(chunk)
if progress_cb:
progress_cb(sent, size)
client.drain_messages()
# Wait for DONE or ERROR
while True:
msg = client.read_text()
client._log('Received', msg)
if msg == 'DONE':
return
if msg.startswith('ERROR'):
raise WebSocketError(msg)
finally:
client.close()

View File

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

View File

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

View File

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

View File

@ -498,8 +498,8 @@ void HomeActivity::render() {
// Build menu items dynamically
std::vector<const char*> 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;

View File

@ -0,0 +1,276 @@
#include "CalibreConnectActivity.h"
#include <ESPmDNS.h>
#include <GfxRenderer.h>
#include <WiFi.h>
#include <esp_task_wdt.h>
#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<CalibreConnectActivity*>(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<WifiSelectionActivity*>(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);
}

View File

@ -0,0 +1,55 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <memory>
#include <string>
#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<void()> onComplete;
std::unique_ptr<CrossPointWebServer> 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<void()>& 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(); }
};

View File

@ -1,756 +0,0 @@
#include "CalibreWirelessActivity.h"
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <WiFi.h>
#include <cstring>
#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<CalibreWirelessActivity*>(param);
self->displayTaskLoop();
}
void CalibreWirelessActivity::networkTaskTrampoline(void* param) {
auto* self = static_cast<CalibreWirelessActivity*>(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<const uint8_t*>("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<uint16_t>(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<uint16_t>(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<OpCode>(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<int>(sizeof(buf)));
int bytesRead = tcpClient.read(reinterpret_cast<uint8_t*>(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<const uint8_t*>(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<const uint8_t*>(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);
}

View File

@ -1,135 +0,0 @@
#pragma once
#include <SDCardManager.h>
#include <WiFiClient.h>
#include <WiFiUdp.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#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<void()> 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<void()>& 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; }
};

View File

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

View File

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

View File

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

View File

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

View File

@ -1,20 +1,17 @@
#include "CalibreSettingsActivity.h"
#include <GfxRenderer.h>
#include <WiFi.h>
#include <cstring>
#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

View File

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

View File

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

View File

@ -16,6 +16,8 @@ namespace {
// Note: Items starting with "." are automatically hidden
const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"};
constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]);
constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678};
constexpr uint16_t LOCAL_UDP_PORT = 8134;
// Static pointer for WebSocket callback (WebSocketsServer requires C-style callback)
CrossPointWebServer* wsInstance = nullptr;
@ -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<const uint8_t*>(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;

View File

@ -2,7 +2,10 @@
#include <WebServer.h>
#include <WebSocketsServer.h>
#include <WiFiUdp.h>
#include <memory>
#include <string>
#include <vector>
// 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;

View File

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