mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-05 07:07:38 +03:00
move calibre plugin to separate repo
This commit is contained in:
parent
5da8faca4f
commit
c4740bd5fd
Binary file not shown.
@ -1,25 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
from .driver import CrossPointDevice
|
|
||||||
|
|
||||||
|
|
||||||
class CrossPointReaderDevice(CrossPointDevice):
|
|
||||||
pass
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,367 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,294 +0,0 @@
|
|||||||
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()
|
|
||||||
@ -14,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance;
|
|||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
||||||
// Increment this when adding new persisted settings fields
|
// Increment this when adding new persisted settings fields
|
||||||
constexpr uint8_t SETTINGS_COUNT = 21;
|
constexpr uint8_t SETTINGS_COUNT = 22;
|
||||||
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
|||||||
@ -103,7 +103,7 @@ void CategorySettingsActivity::toggleCurrentSetting() {
|
|||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
}));
|
}));
|
||||||
xSemaphoreGive(renderingMutex);
|
xSemaphoreGive(renderingMutex);
|
||||||
} else if (strcmp(setting.name, "Calibre Settings") == 0) {
|
} else if (strcmp(setting.name, "OPDS Browser") == 0) {
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {
|
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user