trainlog/tools/trainlog_bt_agent.py

427 lines
15 KiB
Python
Executable file

#!/usr/bin/env python3
"""Persistent BlueZ RFCOMM bridge for Trainlog generation byte transport."""
from __future__ import annotations
import argparse
import dbus
import dbus.service
import dbus.mainloop.glib
import hashlib
import json
import os
import signal
import socket
import struct
import threading
import uuid
from pathlib import Path
from gi.repository import GLib
FRAME_FORMAT = "trainlog-bt-frame"
FRAME_VERSION = 1
FILE_PROTOCOL = "trainlog-bt-files-v1"
DEFAULT_UUID = "f0d1c0de-7a11-4f62-9b7c-545241494e4c"
DEFAULT_CHANNEL = 23
MAX_HEADER = 64 * 1024
MAX_PAYLOAD = 256 * 1024 * 1024
PROFILE_PATH = "/com/labfytools/trainlog/profile"
class BluetoothTransportError(RuntimeError):
pass
def canonical_header(value: dict) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
def recv_exact(stream: socket.socket, size: int) -> bytes:
if size < 0 or size > MAX_PAYLOAD:
raise BluetoothTransportError("invalid Bluetooth payload size")
chunks = bytearray()
while len(chunks) < size:
part = stream.recv(min(64 * 1024, size - len(chunks)))
if not part:
raise BluetoothTransportError("Bluetooth connection closed")
chunks.extend(part)
return bytes(chunks)
def recv_frame(stream: socket.socket) -> tuple[dict, bytes]:
raw_length = recv_exact(stream, 4)
header_length = struct.unpack(">I", raw_length)[0]
if header_length <= 0 or header_length > MAX_HEADER:
raise BluetoothTransportError("invalid Bluetooth header size")
try:
header = json.loads(recv_exact(stream, header_length).decode("utf-8"))
except (UnicodeError, json.JSONDecodeError) as error:
raise BluetoothTransportError("invalid Bluetooth header") from error
if not isinstance(header, dict):
raise BluetoothTransportError("invalid Bluetooth header")
if header.get("format") != FRAME_FORMAT or header.get("version") != FRAME_VERSION:
raise BluetoothTransportError("unsupported Bluetooth frame")
payload_length = header.get("payload_size", 0)
if type(payload_length) is not int or payload_length < 0 or payload_length > MAX_PAYLOAD:
raise BluetoothTransportError("invalid Bluetooth payload size")
payload = recv_exact(stream, payload_length) if payload_length else b""
expected = header.get("payload_sha256")
if payload:
if not isinstance(expected, str) or len(expected) != 64:
raise BluetoothTransportError("missing Bluetooth payload digest")
if hashlib.sha256(payload).hexdigest() != expected:
raise BluetoothTransportError("Bluetooth payload digest mismatch")
elif expected not in (None, ""):
raise BluetoothTransportError("unexpected Bluetooth payload digest")
return header, payload
def send_frame(stream: socket.socket, header: dict, payload: bytes = b"") -> None:
if len(payload) > MAX_PAYLOAD:
raise BluetoothTransportError("Bluetooth payload exceeds bound")
value = dict(header)
value["format"] = FRAME_FORMAT
value["version"] = FRAME_VERSION
value["payload_size"] = len(payload)
if payload:
value["payload_sha256"] = hashlib.sha256(payload).hexdigest()
raw = canonical_header(value)
if len(raw) > MAX_HEADER:
raise BluetoothTransportError("Bluetooth header exceeds bound")
stream.sendall(struct.pack(">I", len(raw)))
stream.sendall(raw)
if payload:
stream.sendall(payload)
def normalized_address(value: str) -> str:
pieces = value.strip().upper().split(":")
if len(pieces) != 6 or any(len(piece) != 2 for piece in pieces):
raise BluetoothTransportError("invalid configured Bluetooth address")
try:
if any(int(piece, 16) > 255 for piece in pieces):
raise ValueError
except ValueError as error:
raise BluetoothTransportError("invalid configured Bluetooth address") from error
return ":".join(pieces)
def runtime_socket_path(config: dict) -> Path:
configured = config.get("bluetooth_socket")
if configured:
path = Path(configured)
if not path.is_absolute():
raise BluetoothTransportError("bluetooth_socket must be absolute")
return path
runtime = os.environ.get("XDG_RUNTIME_DIR")
if not runtime:
raise BluetoothTransportError("XDG_RUNTIME_DIR is unavailable")
return Path(runtime) / "trainlog-bt.sock"
def load_config(path: Path) -> dict:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict) or value.get("format") != "trainlog-sync-orchestrator-config":
raise BluetoothTransportError("invalid Trainlog sync configuration")
peer = value.get("expected_peer_id")
if not isinstance(peer, str) or not peer.startswith("peer_"):
raise BluetoothTransportError("invalid expected peer")
address = value.get("bluetooth_device_address")
if not isinstance(address, str):
raise BluetoothTransportError("Bluetooth device address is not configured")
value["bluetooth_device_address"] = normalized_address(address)
return value
class ConnectionState:
def __init__(self, expected_address: str, expected_peer: str):
self.expected_address = expected_address
self.expected_peer = expected_peer
self._condition = threading.Condition()
self._stream: socket.socket | None = None
self._peer_id: str | None = None
self._operation_lock = threading.Lock()
def close(self) -> None:
with self._condition:
stream = self._stream
self._stream = None
self._peer_id = None
self._condition.notify_all()
if stream is not None:
try:
stream.shutdown(socket.SHUT_RDWR)
except OSError:
pass
stream.close()
def install(self, stream: socket.socket) -> None:
stream.settimeout(15)
try:
header, payload = recv_frame(stream)
if payload or header.get("type") != "hello":
raise BluetoothTransportError("Android Bluetooth hello is missing")
if header.get("protocol") != FILE_PROTOCOL:
raise BluetoothTransportError("Android Bluetooth protocol mismatch")
peer = header.get("peer_id")
if peer != self.expected_peer:
raise BluetoothTransportError("Android Bluetooth peer identity mismatch")
send_frame(
stream,
{
"type": "hello_ack",
"protocol": FILE_PROTOCOL,
"peer_id": peer,
},
)
stream.settimeout(60)
except Exception:
stream.close()
raise
with self._condition:
old = self._stream
self._stream = stream
self._peer_id = peer
self._condition.notify_all()
if old is not None and old is not stream:
try:
old.close()
except OSError:
pass
def transact(self, operation: str, peer: str, payload: bytes) -> tuple[dict, bytes]:
if peer != self.expected_peer:
raise BluetoothTransportError("expected Android peer mismatch")
if operation not in {"pull", "push"}:
raise BluetoothTransportError("unsupported Bluetooth operation")
with self._operation_lock:
with self._condition:
stream = self._stream
connected_peer = self._peer_id
if stream is None or connected_peer != peer:
raise BluetoothTransportError("expected Android peer not connected")
request_id = "bt_" + str(uuid.uuid4())
try:
send_frame(
stream,
{
"type": operation,
"request_id": request_id,
"protocol": FILE_PROTOCOL,
"peer_id": peer,
},
payload,
)
header, response = recv_frame(stream)
if header.get("type") != "result" or header.get("request_id") != request_id:
raise BluetoothTransportError("uncorrelated Bluetooth response")
if header.get("result") != "ok":
diagnostic = header.get("diagnostic")
raise BluetoothTransportError(
diagnostic if isinstance(diagnostic, str) and diagnostic
else "Android Bluetooth operation failed"
)
if operation == "pull" and not response:
raise BluetoothTransportError("Android Bluetooth pull returned no archive")
if operation == "push" and response:
raise BluetoothTransportError("Android Bluetooth push returned unexpected payload")
return header, response
except Exception:
self.close()
raise
class TrainlogProfile(dbus.service.Object):
def __init__(self, bus: dbus.SystemBus, state: ConnectionState):
super().__init__(bus, PROFILE_PATH)
self.bus = bus
self.state = state
def _device_address(self, device: str) -> str:
proxy = self.bus.get_object("org.bluez", device)
properties = dbus.Interface(proxy, "org.freedesktop.DBus.Properties")
return normalized_address(str(properties.Get("org.bluez.Device1", "Address")))
@dbus.service.method("org.bluez.Profile1", in_signature="", out_signature="")
def Release(self):
self.state.close()
@dbus.service.method("org.bluez.Profile1", in_signature="oha{sv}", out_signature="")
def NewConnection(self, device, fd, properties):
del properties
address = self._device_address(str(device))
raw_fd = fd.take()
stream = socket.socket(fileno=raw_fd)
if address != self.state.expected_address:
stream.close()
raise dbus.exceptions.DBusException(
"org.bluez.Error.Rejected", "unauthorized Bluetooth device"
)
def establish() -> None:
try:
self.state.install(stream)
except Exception:
try:
stream.close()
except OSError:
pass
threading.Thread(target=establish, name="trainlog-bt-hello", daemon=True).start()
@dbus.service.method("org.bluez.Profile1", in_signature="o", out_signature="")
def RequestDisconnection(self, device):
del device
self.state.close()
@dbus.service.method("org.bluez.Profile1", in_signature="", out_signature="")
def Cancel(self):
self.state.close()
class LocalAdapterServer:
def __init__(self, path: Path, state: ConnectionState):
self.path = path
self.state = state
self.stop = threading.Event()
self.listener: socket.socket | None = None
def serve(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
try:
self.path.unlink()
except FileNotFoundError:
pass
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.listener = listener
listener.bind(str(self.path))
os.chmod(self.path, 0o600)
listener.listen(4)
listener.settimeout(1)
while not self.stop.is_set():
try:
client, _ = listener.accept()
except socket.timeout:
continue
except OSError:
break
threading.Thread(
target=self._client,
args=(client,),
name="trainlog-bt-local",
daemon=True,
).start()
try:
listener.close()
finally:
try:
self.path.unlink()
except FileNotFoundError:
pass
def _client(self, client: socket.socket) -> None:
with client:
client.settimeout(60)
try:
header, payload = recv_frame(client)
if header.get("type") != "adapter_request":
raise BluetoothTransportError("invalid local Bluetooth adapter request")
operation = header.get("operation")
peer = header.get("peer_id")
if not isinstance(operation, str) or not isinstance(peer, str):
raise BluetoothTransportError("invalid local Bluetooth adapter request")
_, response = self.state.transact(operation, peer, payload)
send_frame(
client,
{
"type": "adapter_result",
"operation": operation,
"result": "ok",
},
response,
)
except Exception as error:
try:
send_frame(
client,
{
"type": "adapter_result",
"result": "error",
"diagnostic": str(error)[:1024],
},
)
except Exception:
pass
def shutdown(self) -> None:
self.stop.set()
if self.listener is not None:
try:
self.listener.close()
except OSError:
pass
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True, type=Path)
parser.add_argument("--uuid", default=DEFAULT_UUID)
parser.add_argument("--channel", type=int, default=DEFAULT_CHANNEL)
args = parser.parse_args()
config = load_config(args.config)
expected_address = config["bluetooth_device_address"]
expected_peer = config["expected_peer_id"]
socket_path = runtime_socket_path(config)
if args.channel < 1 or args.channel > 30:
raise SystemExit("RFCOMM channel must be in 1..30")
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
state = ConnectionState(expected_address, expected_peer)
profile = TrainlogProfile(bus, state)
manager = dbus.Interface(
bus.get_object("org.bluez", "/org/bluez"),
"org.bluez.ProfileManager1",
)
manager.RegisterProfile(
PROFILE_PATH,
args.uuid,
{
"Name": "Trainlog Sync",
"Role": "server",
"Channel": dbus.UInt16(args.channel),
"RequireAuthentication": dbus.Boolean(True),
"RequireAuthorization": dbus.Boolean(False),
},
)
local = LocalAdapterServer(socket_path, state)
thread = threading.Thread(target=local.serve, name="trainlog-bt-unix", daemon=True)
thread.start()
loop = GLib.MainLoop()
def stop(_signum, _frame):
local.shutdown()
state.close()
loop.quit()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
try:
loop.run()
finally:
local.shutdown()
state.close()
try:
manager.UnregisterProfile(PROFILE_PATH)
except dbus.DBusException:
pass
thread.join(timeout=2)
return 0
if __name__ == "__main__":
raise SystemExit(main())