Initial public release
This commit is contained in:
+892
@@ -0,0 +1,892 @@
|
||||
"""Persistent low-latency BLE channel for streaming live keystrokes to the
|
||||
M5Stack during macro recording.
|
||||
|
||||
The variable-sync client in ble_server.py is exchange-driven: connect,
|
||||
swap one round of frames, disconnect. Live recording needs the opposite —
|
||||
a connection that stays up for the entire recording session, with the
|
||||
host emitting many small write-without-response frames as the user types.
|
||||
|
||||
Architecture:
|
||||
- Owned by widgets/macro_recorder.py.
|
||||
- The M5Stack auto-advertises the live service whenever it is idle
|
||||
(no routine running). This client scans for that advertisement,
|
||||
connects, subscribes to LIVE_KEYS_NOTIFY, exchanges a START
|
||||
handshake, then accepts send_event() calls from the Tk thread.
|
||||
- send_event() is fire-and-forget: it queues onto the asyncio loop;
|
||||
writes use BLE WRITE_NO_RESPONSE so they cost one L2CAP frame and
|
||||
no ACK round-trip.
|
||||
- Disconnect detection bubbles up via on_status("disconnected").
|
||||
|
||||
Security model:
|
||||
BLE link-layer access is intentionally open — no pairing, no PIN.
|
||||
That's deliberate so the host machine (running Python, possibly a
|
||||
different physical computer than the one the M5Stack is plugged
|
||||
into) can connect at any time without out-of-band setup.
|
||||
|
||||
Confidentiality and integrity come from the application layer:
|
||||
every frame in both directions is AES-256-GCM, with the device-tag
|
||||
string ("M5Stack|<MAC>") bound as AAD. The 32-byte key lives on the
|
||||
device's LittleFS partition and on the host's `<repo>/config/.blekeyfile`
|
||||
(see ble_keystore.py). The key was originally pulled from the device
|
||||
over USB during initial profile setup; once provisioned, neither
|
||||
side ever transmits it.
|
||||
|
||||
Consequence: any BLE-range device can OPEN a connection to the
|
||||
M5Stack, but every frame it sends fails GCM auth (no key) and is
|
||||
dropped silently in onLiveWriteReceived. Replay-protection (per-MAC
|
||||
session_id + monotonic seq) blocks captured frames being replayed
|
||||
even by a key-holder, scoped to the current device boot session.
|
||||
|
||||
This is the same security model the existing variable-sync channel
|
||||
uses; live recording inherits it unchanged.
|
||||
|
||||
Frame protocol (each direction, after AES-GCM unwrap of ble_frame.py):
|
||||
|
||||
byte 0: msg_type
|
||||
0x01 = START (host -> device, no body)
|
||||
0x02 = KEYS (host -> device, body = event_count + events)
|
||||
0x03 = STOP (host -> device, no body)
|
||||
0x10 = ACK (device -> host)
|
||||
0x11 = ERROR (device -> host)
|
||||
0x12 = HELLO (device -> host, first frame after subscribe)
|
||||
bytes 1..8: session_id (uint64 little-endian)
|
||||
bytes 9..16:seq (uint64 little-endian)
|
||||
byte 17: body per msg_type
|
||||
|
||||
KEYS body:
|
||||
byte 17: event_count (1..16)
|
||||
bytes 18..: event_count × { uint8 action, uint8 hid_code, uint32 t_ms_le }
|
||||
action: 0=DOWN, 1=UP
|
||||
t_ms_le: host-clock ms since the first event of this
|
||||
live session (uint32 little-endian). Device
|
||||
uses this to preserve typing cadence on
|
||||
emission (see live_keystroke.h).
|
||||
|
||||
ACK body:
|
||||
byte 17..24: ref_seq (uint64 LE) — seq of the frame being acknowledged
|
||||
|
||||
ERROR body:
|
||||
byte 17: err_code (1=BUFFER_FULL, 2=NOT_LIVE_MODE, 3=HID_FAILURE,
|
||||
4=BAD_MSG)
|
||||
bytes 18..25: ref_seq (uint64 LE)
|
||||
|
||||
Replay protection: same (session_id, seq) shape as the variable-sync
|
||||
channel, validated via ble_replay.ReplayState.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
|
||||
import ble_keystore
|
||||
import ble_replay
|
||||
import ble_debug_log as _bled
|
||||
from ble_frame import build_frame, parse_frame_auto, DEVICE_TAG_PREFIX
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _dbg(kind: str, **fields) -> None:
|
||||
"""Mirror live-mode lifecycle into the shared .ble_debug.log so the
|
||||
file survives across runs and is grep-able alongside the var-sync
|
||||
events. Each event is tagged so it's easy to filter from var-sync."""
|
||||
try:
|
||||
_bled.event("live_" + kind, **fields)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---- Message type bytes ----
|
||||
MSG_START = 0x01
|
||||
MSG_KEYS = 0x02
|
||||
MSG_STOP = 0x03
|
||||
MSG_IDENTIFY = 0x04 # host -> device: show/hide the Bluetooth identify logo
|
||||
MSG_MOUSE = 0x05 # host -> device: absolute pointer {buttons, x_u16, y_u16, wheel_i8}
|
||||
MSG_LABEL = 0x06 # host -> device: friendly label (UTF-8 body)
|
||||
MSG_ACK = 0x10
|
||||
MSG_ERROR = 0x11
|
||||
MSG_HELLO = 0x12
|
||||
|
||||
ACTION_DOWN = 0
|
||||
ACTION_UP = 1
|
||||
|
||||
ERR_LABELS = {
|
||||
1: "BUFFER_FULL",
|
||||
2: "NOT_LIVE_MODE",
|
||||
3: "HID_FAILURE",
|
||||
4: "BAD_MSG",
|
||||
}
|
||||
|
||||
# Status strings published via on_status callback.
|
||||
ST_IDLE = "idle"
|
||||
ST_SCANNING = "scanning"
|
||||
ST_CONNECTING = "connecting"
|
||||
ST_CONNECTED = "connected" # subscribed and START acked
|
||||
ST_DISCONNECTED = "disconnected"
|
||||
ST_ERROR = "error"
|
||||
|
||||
# How long to scan for the device before giving up one cycle.
|
||||
SCAN_TIMEOUT_S = 4
|
||||
# How long to wait for the device's hello frame after subscribing.
|
||||
HELLO_TIMEOUT_S = 8
|
||||
# How long to wait for the START ack before giving up.
|
||||
START_ACK_TIMEOUT_S = 5
|
||||
# Worker queue capacity. Keystrokes that overflow are dropped on the host
|
||||
# side (with a warning) rather than waiting and growing latency.
|
||||
SEND_QUEUE_MAX = 512
|
||||
|
||||
|
||||
def _pack_header(msg_type: int, session_id: int, seq: int) -> bytes:
|
||||
return struct.pack("<BQQ", msg_type, session_id & 0xFFFFFFFFFFFFFFFF,
|
||||
seq & 0xFFFFFFFFFFFFFFFF)
|
||||
|
||||
|
||||
def _parse_header(plain: bytes):
|
||||
if len(plain) < 17:
|
||||
return None
|
||||
msg_type, sid, seq = struct.unpack("<BQQ", plain[:17])
|
||||
return msg_type, sid, seq, plain[17:]
|
||||
|
||||
|
||||
class BLELiveKeystrokeClient:
|
||||
"""Persistent BLE link for live keystroke streaming.
|
||||
|
||||
Lifecycle:
|
||||
c = BLELiveKeystrokeClient()
|
||||
c.start(on_status=cb, on_error=cb) # scan + connect + START
|
||||
c.send_event(action, hid_code) # called from Tk thread
|
||||
c.stop() # STOP + disconnect
|
||||
|
||||
All callbacks run on the asyncio worker thread — bounce through
|
||||
Tk's `after(0, ...)` to touch widgets.
|
||||
"""
|
||||
|
||||
def __init__(self, target_address: str | None = None):
|
||||
"""
|
||||
target_address — optional BLE MAC (Bleak's d.address, e.g.
|
||||
"AA:BB:CC:DD:EE:FF"). When set, the scanner only accepts a
|
||||
match on this address. Used by the multi-device manager
|
||||
(ble_multi.py) to pin each client to a distinct device so
|
||||
N>1 clients don't fight for the same advertisement.
|
||||
None = match the first live-mode device found (existing
|
||||
single-device behavior).
|
||||
"""
|
||||
self._target_address = target_address.upper() if target_address else None
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._client = None
|
||||
self._key: bytes | None = None
|
||||
self._device_tag: str | None = None
|
||||
self._replay = ble_replay.ReplayState()
|
||||
# Counts consecutive frames that failed AES-GCM auth — surfaced
|
||||
# to the UI as "key mismatch" after a few in a row so the user
|
||||
# gets a clear error instead of silent failure.
|
||||
self._auth_fail_count = 0
|
||||
# Host clock anchor (time.monotonic_ns of the first send_event of
|
||||
# the current live session). Per-event timestamps are computed
|
||||
# relative to this so the device can preserve typing cadence.
|
||||
self._session_t0_ns: int | None = None
|
||||
# Per-session running totals exposed to the UI.
|
||||
self.events_sent = 0
|
||||
self.bytes_sent = 0
|
||||
# Whether the device should be showing its Bluetooth "identify"
|
||||
# logo. Latched here so it survives reconnects and is (re)sent the
|
||||
# moment a session reaches the connected state.
|
||||
self._identify_on = False
|
||||
# Friendly label to display on the device; latched so it's re-sent
|
||||
# on every (re)connect (a device reboot loses it from RAM).
|
||||
self._device_label = ""
|
||||
# Last mouse button mask we sent — used to decide which mouse frames
|
||||
# warrant a reliable (response=True) write vs a fire-and-forget one.
|
||||
self._last_mouse_buttons = 0
|
||||
# asyncio.Queue of pending outgoing payloads (List of (action, hid))
|
||||
# The worker batches whatever is ready when it wakes up so a burst
|
||||
# of keystrokes pays one BLE radio cycle instead of N.
|
||||
self._send_q: asyncio.Queue | None = None
|
||||
# Set when the worker observes a clean disconnect.
|
||||
self._disconnected_event: asyncio.Event | None = None
|
||||
# Set when the device acks our most recent START.
|
||||
self._start_acked: asyncio.Event | None = None
|
||||
self._on_status = None
|
||||
self._on_error = None
|
||||
# Used to tag errors emitted from the worker with the seq they reference
|
||||
# so the UI can correlate (mostly diagnostic).
|
||||
self._last_status = ST_IDLE
|
||||
|
||||
# ---- Public API (Tk thread) ----
|
||||
|
||||
def start(self, on_status=None, on_error=None) -> None:
|
||||
"""Begin scanning and connecting. Non-blocking.
|
||||
|
||||
on_status(status_str) — fires on every state transition.
|
||||
on_error(err_code:int, ref_seq:int|None, label:str)
|
||||
"""
|
||||
if self._running:
|
||||
return
|
||||
self._on_status = on_status
|
||||
self._on_error = on_error
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._thread_main, daemon=True,
|
||||
name="BLELiveKeystrokeClient")
|
||||
self._thread.start()
|
||||
|
||||
def stop(self, timeout: float = 4.0) -> None:
|
||||
"""Send STOP if connected, tear down the worker thread."""
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
loop = self._loop
|
||||
if loop and loop.is_running():
|
||||
# Wake the worker so it observes _running=False and exits cleanly.
|
||||
loop.call_soon_threadsafe(self._wake_for_shutdown)
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=timeout)
|
||||
self._thread = None
|
||||
self._loop = None
|
||||
self._client = None
|
||||
|
||||
def send_event(self, action: int, hid_code: int) -> bool:
|
||||
"""Queue one (action, hid_code) event for transmission.
|
||||
|
||||
Timestamps are captured HERE (Tk thread, on the actual keypress)
|
||||
so BLE/asyncio jitter doesn't pollute the recorded cadence. The
|
||||
timestamp is host-monotonic ms relative to the first event of
|
||||
the session.
|
||||
|
||||
Returns True on enqueue, False if the channel is not open or
|
||||
the queue is full. Safe to call from the Tk thread.
|
||||
"""
|
||||
if self._last_status != ST_CONNECTED:
|
||||
return False
|
||||
now_ns = time.monotonic_ns()
|
||||
if self._session_t0_ns is None:
|
||||
self._session_t0_ns = now_ns
|
||||
t_ms = (now_ns - self._session_t0_ns) // 1_000_000
|
||||
if t_ms > 0xFFFFFFFF:
|
||||
t_ms = 0xFFFFFFFF
|
||||
return self.send_event_with_t(action, hid_code, int(t_ms))
|
||||
|
||||
def send_event_with_t(self, action: int, hid_code: int,
|
||||
t_ms: int) -> bool:
|
||||
"""Like send_event but the caller supplies the timestamp. Used
|
||||
by the Replay path so each event carries its ORIGINAL
|
||||
recorded timestamp instead of "now"."""
|
||||
loop = self._loop
|
||||
q = self._send_q
|
||||
if loop is None or q is None or not self._running:
|
||||
return False
|
||||
if self._last_status != ST_CONNECTED:
|
||||
return False
|
||||
try:
|
||||
loop.call_soon_threadsafe(self._enqueue_send_threadsafe,
|
||||
int(action) & 0xFF,
|
||||
int(hid_code) & 0xFF,
|
||||
int(t_ms) & 0xFFFFFFFF)
|
||||
return True
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
def reset_session_clock(self) -> None:
|
||||
"""Forget the current session anchor so the next send_event
|
||||
becomes t=0. Called when a new recording session begins."""
|
||||
self._session_t0_ns = None
|
||||
|
||||
def set_identify(self, on: bool) -> None:
|
||||
"""Ask the device to show (on=True) or hide (on=False) its
|
||||
Bluetooth identify logo. Safe to call from the Tk thread.
|
||||
|
||||
The desire is latched in self._identify_on so it's (re)applied on
|
||||
every (re)connect; if we're already connected we also push the
|
||||
change immediately. If we're not connected yet, the session sends
|
||||
the current flag the moment it comes up."""
|
||||
self._identify_on = bool(on)
|
||||
loop = self._loop
|
||||
q = self._send_q
|
||||
if loop is None or q is None or not self._running:
|
||||
return
|
||||
if self._last_status != ST_CONNECTED:
|
||||
return # will be applied on connect by _session
|
||||
try:
|
||||
loop.call_soon_threadsafe(self._enqueue_identify_threadsafe,
|
||||
bool(on))
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def _enqueue_identify_threadsafe(self, on: bool) -> None:
|
||||
if self._send_q is None:
|
||||
return
|
||||
try:
|
||||
self._send_q.put_nowait(("identify", bool(on)))
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
def send_mouse(self, buttons: int, x: float, y: float,
|
||||
wheel: int = 0) -> bool:
|
||||
"""Send one absolute pointer update. ``x``/``y`` are normalized
|
||||
screen coordinates in [0, 1] (so the remote cursor never desyncs —
|
||||
every report fully specifies the position). ``buttons`` is a bitmask
|
||||
(bit0 left, bit1 right, bit2 middle). ``wheel`` is a relative tick.
|
||||
Safe to call from the Tk thread."""
|
||||
loop = self._loop
|
||||
q = self._send_q
|
||||
if loop is None or q is None or not self._running:
|
||||
return False
|
||||
if self._last_status != ST_CONNECTED:
|
||||
return False
|
||||
xi = int(max(0.0, min(1.0, x)) * 32767)
|
||||
yi = int(max(0.0, min(1.0, y)) * 32767)
|
||||
try:
|
||||
loop.call_soon_threadsafe(self._enqueue_mouse_threadsafe,
|
||||
int(buttons) & 0xFF, xi, yi, int(wheel))
|
||||
return True
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
def _enqueue_mouse_threadsafe(self, buttons: int, x: int, y: int,
|
||||
wheel: int) -> None:
|
||||
if self._send_q is None:
|
||||
return
|
||||
try:
|
||||
self._send_q.put_nowait(("mouse", buttons, x, y, wheel))
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
def set_device_label(self, label: str) -> None:
|
||||
"""Push a friendly label to show on the device's screen. Latched so
|
||||
it survives reconnects (re-sent by _session on connect)."""
|
||||
self._device_label = label or ""
|
||||
loop = self._loop
|
||||
q = self._send_q
|
||||
if loop is None or q is None or not self._running:
|
||||
return
|
||||
if self._last_status != ST_CONNECTED:
|
||||
return
|
||||
try:
|
||||
loop.call_soon_threadsafe(self._enqueue_label_threadsafe,
|
||||
self._device_label)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def _enqueue_label_threadsafe(self, label: str) -> None:
|
||||
if self._send_q is None:
|
||||
return
|
||||
try:
|
||||
self._send_q.put_nowait(("label", label))
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
return self._last_status == ST_CONNECTED
|
||||
|
||||
def status(self) -> str:
|
||||
return self._last_status
|
||||
|
||||
# ---- Worker thread / asyncio glue ----
|
||||
|
||||
def _wake_for_shutdown(self) -> None:
|
||||
# Drop a sentinel on the queue so the writer task unblocks.
|
||||
if self._send_q is not None:
|
||||
try:
|
||||
self._send_q.put_nowait(None)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
def _enqueue_send_threadsafe(self, action: int, hid_code: int,
|
||||
t_ms: int) -> None:
|
||||
if self._send_q is None:
|
||||
return
|
||||
try:
|
||||
self._send_q.put_nowait((action, hid_code, t_ms))
|
||||
except asyncio.QueueFull:
|
||||
print(f"[live] send queue full — dropping event "
|
||||
f"(action={action} hid=0x{hid_code:02X})")
|
||||
_dbg("send_queue_full", action=action, hid=hid_code)
|
||||
|
||||
def _thread_main(self) -> None:
|
||||
try:
|
||||
from bleak import BleakClient, BleakScanner # noqa
|
||||
except ImportError:
|
||||
self._set_status(ST_ERROR)
|
||||
log.error("bleak not installed — pip install bleak")
|
||||
return
|
||||
self._BleakClient = BleakClient
|
||||
self._BleakScanner = BleakScanner
|
||||
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
try:
|
||||
self._loop.run_until_complete(self._run())
|
||||
except RuntimeError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
log.warning("live worker error: %s", exc, exc_info=True)
|
||||
finally:
|
||||
try:
|
||||
self._loop.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _run(self) -> None:
|
||||
"""Outer worker loop. Repeats scan → connect → session until the
|
||||
caller calls stop(). The M5Stack advertises the live service
|
||||
automatically whenever it is idle (no routine running, no USB
|
||||
upload in progress), so the device is normally already
|
||||
discoverable. The loop also recovers mid-session drops
|
||||
(interference / range) by re-scanning and reconnecting; the device
|
||||
keeps listening across drops so recording resumes seamlessly."""
|
||||
# We scan for LIVE_SERVICE_UUID specifically — the var-sync
|
||||
# client uses SERVICE_UUID, and the device advertises one or
|
||||
# the other depending on whether it's in live mode. This keeps
|
||||
# the two clients from racing for the same BLE connection.
|
||||
from ble_server import LIVE_SERVICE_UUID
|
||||
|
||||
self._send_q = asyncio.Queue(maxsize=SEND_QUEUE_MAX)
|
||||
self._start_acked = asyncio.Event()
|
||||
|
||||
# Per-MAC keystore can supply the right key once we read the
|
||||
# device tag from the first frame. We pre-load the legacy
|
||||
# single-key file as a "any unknown device" fallback so
|
||||
# existing single-device setups continue working.
|
||||
self._key = ble_keystore.load_key()
|
||||
# Note: no `if self._key is None: return` here — multi-device
|
||||
# users may have only per-MAC keys, no legacy file. We resolve
|
||||
# the actual key per-frame based on the tag in the frame.
|
||||
|
||||
print("[live] worker started, scanning for live-mode device...")
|
||||
_dbg("worker_start", scan_uuid=LIVE_SERVICE_UUID)
|
||||
backoff_s = 0.5
|
||||
while self._running:
|
||||
try:
|
||||
await self._one_cycle(LIVE_SERVICE_UUID)
|
||||
except Exception as exc:
|
||||
print(f"[live] cycle error: {exc!r}")
|
||||
_dbg("cycle_error", error=repr(exc))
|
||||
self._set_status(ST_DISCONNECTED)
|
||||
if not self._running:
|
||||
break
|
||||
await asyncio.sleep(backoff_s)
|
||||
backoff_s = min(backoff_s * 1.5, 3.0)
|
||||
# Reset transient signalling for the next cycle.
|
||||
self._start_acked = asyncio.Event()
|
||||
print("[live] worker exiting")
|
||||
_dbg("worker_exit")
|
||||
|
||||
async def _one_cycle(self, service_uuid: str) -> None:
|
||||
"""One scan-connect-session pass. Returns whether or not it
|
||||
succeeded — caller decides whether to retry."""
|
||||
self._set_status(ST_SCANNING)
|
||||
_dbg("scan_start", timeout_s=SCAN_TIMEOUT_S, uuid=service_uuid)
|
||||
|
||||
# Collect ALL device sightings (with their advertised service
|
||||
# UUIDs) during the scan window, so the log shows what was
|
||||
# actually nearby. The filter we return controls connection.
|
||||
all_sightings: dict = {}
|
||||
|
||||
def _filter(d, adv):
|
||||
try:
|
||||
addr = getattr(d, "address", None)
|
||||
if addr and addr not in all_sightings:
|
||||
all_sightings[addr] = {
|
||||
"name": getattr(d, "name", None),
|
||||
"uuids": list(adv.service_uuids or []),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
if service_uuid not in (adv.service_uuids or []):
|
||||
return False
|
||||
# If we were pinned to a specific BLE MAC (multi-device
|
||||
# manager spawns one client per known device), only match
|
||||
# that exact address. This lets multiple BLELiveKeystrokeClients
|
||||
# coexist without racing for the first advertisement.
|
||||
if self._target_address is not None:
|
||||
addr = getattr(d, "address", "")
|
||||
if not addr or addr.upper() != self._target_address:
|
||||
return False
|
||||
return True
|
||||
|
||||
try:
|
||||
device = await self._BleakScanner.find_device_by_filter(
|
||||
_filter, timeout=SCAN_TIMEOUT_S,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[live] scan failed: {exc!r}")
|
||||
_dbg("scan_error", error=repr(exc))
|
||||
self._set_status(ST_DISCONNECTED)
|
||||
return
|
||||
if not device:
|
||||
self._set_status(ST_SCANNING)
|
||||
_dbg("scan_idle", sighted=all_sightings)
|
||||
return
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
print(f"[live] found device {getattr(device, 'name', '?')} "
|
||||
f"({getattr(device, 'address', '?')}), connecting...")
|
||||
_dbg("scan_found",
|
||||
name=getattr(device, "name", None),
|
||||
address=getattr(device, "address", None))
|
||||
self._set_status(ST_CONNECTING)
|
||||
# Fresh per-cycle disconnect event so a previous cycle's set()
|
||||
# doesn't immediately tear down this one.
|
||||
self._disconnected_event = asyncio.Event()
|
||||
|
||||
def on_disc(_client):
|
||||
try:
|
||||
if self._loop and self._loop.is_running():
|
||||
self._loop.call_soon_threadsafe(
|
||||
self._disconnected_event.set)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
async with self._BleakClient(device, timeout=15.0,
|
||||
disconnected_callback=on_disc) as client:
|
||||
print("[live] BLE connected, starting session")
|
||||
_dbg("connected", address=getattr(device, "address", None))
|
||||
self._client = client
|
||||
await self._session(client)
|
||||
except asyncio.TimeoutError:
|
||||
print("[live] connect timed out")
|
||||
_dbg("connect_timeout")
|
||||
except Exception as exc:
|
||||
print(f"[live] connect/session error: {exc!r}")
|
||||
_dbg("connect_error", error=repr(exc))
|
||||
finally:
|
||||
self._client = None
|
||||
_dbg("session_end")
|
||||
if self._last_status == ST_CONNECTED:
|
||||
self._set_status(ST_DISCONNECTED)
|
||||
|
||||
async def _session(self, client) -> None:
|
||||
from ble_server import LIVE_KEYS_WRITE_UUID, LIVE_KEYS_NOTIFY_UUID
|
||||
|
||||
hello_evt: asyncio.Future = self._loop.create_future()
|
||||
|
||||
def handle_notify(_char, data: bytearray):
|
||||
raw = bytes(data)
|
||||
result = parse_frame_auto(raw)
|
||||
if result is None:
|
||||
# Fully malformed (no tag, bad length). Drop silently.
|
||||
_dbg("malformed_frame", raw_len=len(raw))
|
||||
return
|
||||
tag, plain = result
|
||||
if plain is None:
|
||||
# Tag was valid but no key worked. Either we don't have
|
||||
# a key for this device (it was reflashed / never had a
|
||||
# profile uploaded over USB), or the stored key is
|
||||
# stale. Surface KEY_MISMATCH after a few in a row so
|
||||
# the user gets a clear remediation.
|
||||
self._auth_fail_count += 1
|
||||
_dbg("auth_fail", count=self._auth_fail_count,
|
||||
raw_len=len(raw), tag=tag)
|
||||
if self._auth_fail_count == 3 and self._on_error:
|
||||
try:
|
||||
self._on_error(0, None, "KEY_MISMATCH")
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
# First good frame nails down the per-device key for
|
||||
# outgoing writes too.
|
||||
self._auth_fail_count = 0
|
||||
mac = tag[len(DEVICE_TAG_PREFIX):]
|
||||
resolved = ble_keystore.load_key_for_mac_or_default(mac)
|
||||
if resolved is not None:
|
||||
self._key = resolved
|
||||
hdr = _parse_header(plain)
|
||||
if hdr is None:
|
||||
return
|
||||
msg_type, sid, seq, body = hdr
|
||||
# Validate replay
|
||||
ok, _reason = self._replay.accept_received(mac, sid, seq)
|
||||
if not ok:
|
||||
print(f"[live] replay reject seq={seq} sid={sid}")
|
||||
return
|
||||
if msg_type == MSG_HELLO:
|
||||
self._device_tag = tag
|
||||
if not hello_evt.done():
|
||||
hello_evt.set_result(True)
|
||||
return
|
||||
if msg_type == MSG_ACK:
|
||||
if len(body) >= 8:
|
||||
ref_seq, = struct.unpack("<Q", body[:8])
|
||||
# The START ack is the only one we explicitly wait on.
|
||||
if not self._start_acked.is_set():
|
||||
self._start_acked.set()
|
||||
return
|
||||
if msg_type == MSG_ERROR:
|
||||
if len(body) >= 9:
|
||||
err = body[0]
|
||||
ref_seq, = struct.unpack("<Q", body[1:9])
|
||||
else:
|
||||
err = body[0] if body else 0
|
||||
ref_seq = None
|
||||
label = ERR_LABELS.get(err, f"ERR_{err}")
|
||||
log.warning("live: device error %s ref_seq=%s", label, ref_seq)
|
||||
if self._on_error:
|
||||
try:
|
||||
self._on_error(err, ref_seq, label)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
await client.start_notify(LIVE_KEYS_NOTIFY_UUID, handle_notify)
|
||||
print("[live] subscribed to LIVE_KEYS_NOTIFY")
|
||||
_dbg("subscribed")
|
||||
except Exception as exc:
|
||||
print(f"[live] start_notify failed: {exc!r}")
|
||||
_dbg("start_notify_failed", error=repr(exc))
|
||||
self._set_status(ST_ERROR)
|
||||
return
|
||||
|
||||
# ---- Wait for hello ----
|
||||
try:
|
||||
await asyncio.wait_for(hello_evt, timeout=HELLO_TIMEOUT_S)
|
||||
print("[live] received hello")
|
||||
_dbg("hello_ok")
|
||||
except asyncio.TimeoutError:
|
||||
print(f"[live] hello timeout after {HELLO_TIMEOUT_S}s")
|
||||
_dbg("hello_timeout", timeout_s=HELLO_TIMEOUT_S)
|
||||
self._set_status(ST_ERROR)
|
||||
return
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
# ---- Send START ----
|
||||
if not await self._send_control(client, LIVE_KEYS_WRITE_UUID, MSG_START):
|
||||
print("[live] START write failed")
|
||||
_dbg("start_write_failed")
|
||||
self._set_status(ST_ERROR)
|
||||
return
|
||||
_dbg("start_sent")
|
||||
try:
|
||||
await asyncio.wait_for(self._start_acked.wait(),
|
||||
timeout=START_ACK_TIMEOUT_S)
|
||||
print("[live] START ack received")
|
||||
_dbg("start_ack")
|
||||
except asyncio.TimeoutError:
|
||||
print(f"[live] START ack timeout after {START_ACK_TIMEOUT_S}s")
|
||||
_dbg("start_ack_timeout", timeout_s=START_ACK_TIMEOUT_S)
|
||||
self._set_status(ST_ERROR)
|
||||
return
|
||||
|
||||
self._set_status(ST_CONNECTED)
|
||||
print("[live] session ready — recording can begin")
|
||||
_dbg("session_ready")
|
||||
|
||||
# Re-apply any pending identify request now that we're connected
|
||||
# (e.g. set_identify(True) was called while we were still scanning).
|
||||
if self._identify_on:
|
||||
self._enqueue_identify_threadsafe(True)
|
||||
# Re-send the device label so it survives reconnects / reboots.
|
||||
if self._device_label:
|
||||
self._enqueue_label_threadsafe(self._device_label)
|
||||
|
||||
# ---- Stream loop ----
|
||||
try:
|
||||
await self._stream_loop(client, LIVE_KEYS_WRITE_UUID)
|
||||
finally:
|
||||
# ---- Send STOP best-effort ----
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._send_control(client, LIVE_KEYS_WRITE_UUID, MSG_STOP),
|
||||
timeout=1.5)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await client.stop_notify(LIVE_KEYS_NOTIFY_UUID)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _stream_loop(self, client, write_uuid: str) -> None:
|
||||
"""Drain self._send_q to BLE writes. One BLE write per drain cycle —
|
||||
we coalesce whatever is queued at wake-up to amortize radio time.
|
||||
|
||||
Uses Write WITH response (response=True) for delivery guarantees.
|
||||
Write Without Response can silently drop frames under congestion
|
||||
— confirmed by dropped-keystroke reports during live recording.
|
||||
The added ~15 ms per write is invisible behind the 100 ms
|
||||
device-side replay buffer.
|
||||
"""
|
||||
# Each event is now 6 bytes (action,hid,t_ms_u32). With the 17-byte
|
||||
# plain header + 1-byte count, a 16-event frame is 17+1+16*6 = 114
|
||||
# bytes plaintext, well under the negotiated MTU even on Windows.
|
||||
MAX_BATCH = 16
|
||||
q = self._send_q
|
||||
disc = self._disconnected_event
|
||||
|
||||
while self._running and not disc.is_set():
|
||||
try:
|
||||
first = await q.get()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
if first is None:
|
||||
# Shutdown sentinel
|
||||
break
|
||||
# Control item (e.g. identify) is tagged with a str first
|
||||
# element; keystroke items are (int action, int hid, int t).
|
||||
if isinstance(first[0], str):
|
||||
await self._handle_control_item(client, write_uuid, first)
|
||||
continue
|
||||
batch = [first]
|
||||
pending_identify = None
|
||||
while len(batch) < MAX_BATCH:
|
||||
try:
|
||||
nxt = q.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
if nxt is None:
|
||||
self._running = False
|
||||
break
|
||||
if isinstance(nxt[0], str):
|
||||
# A control item slipped in mid-batch — flush the
|
||||
# keystrokes first, then handle it after the send.
|
||||
pending_identify = nxt
|
||||
break
|
||||
batch.append(nxt)
|
||||
|
||||
# Build KEYS frame (binary, per protocol in module docstring)
|
||||
seq = self._replay.next_send_seq()
|
||||
sid = self._replay.host_session_id()
|
||||
body = bytes([len(batch)]) + b"".join(
|
||||
struct.pack("<BBI", a & 0xFF, h & 0xFF, t & 0xFFFFFFFF)
|
||||
for (a, h, t) in batch
|
||||
)
|
||||
plain = _pack_header(MSG_KEYS, sid, seq) + body
|
||||
try:
|
||||
frame = build_frame(self._key, self._device_tag, plain)
|
||||
except Exception as exc:
|
||||
print(f"[live] build_frame failed: {exc!r}")
|
||||
continue
|
||||
try:
|
||||
await client.write_gatt_char(write_uuid, frame, response=True)
|
||||
self.events_sent += len(batch)
|
||||
self.bytes_sent += len(frame)
|
||||
except Exception as exc:
|
||||
print(f"[live] write failed: {exc!r}")
|
||||
_dbg("write_failed", error=repr(exc))
|
||||
# Treat as disconnect — the on_disc callback will also fire,
|
||||
# but bail out promptly either way.
|
||||
break
|
||||
|
||||
if pending_identify is not None:
|
||||
await self._handle_control_item(client, write_uuid,
|
||||
pending_identify)
|
||||
|
||||
async def _handle_control_item(self, client, write_uuid: str,
|
||||
item: tuple) -> None:
|
||||
"""Dispatch a tagged control item pulled from the send queue."""
|
||||
kind = item[0]
|
||||
if kind == "identify":
|
||||
await self._send_identify(client, write_uuid, bool(item[1]))
|
||||
elif kind == "mouse":
|
||||
await self._send_mouse(client, write_uuid,
|
||||
item[1], item[2], item[3], item[4])
|
||||
elif kind == "label":
|
||||
await self._send_label(client, write_uuid, item[1])
|
||||
|
||||
async def _send_mouse(self, client, write_uuid: str,
|
||||
buttons: int, x: int, y: int, wheel: int) -> bool:
|
||||
"""Send an absolute pointer report. Pure-move frames go out
|
||||
fire-and-forget (write-without-response) since absolute positions
|
||||
are self-correcting; button changes and scrolls use a reliable
|
||||
write so a click / wheel tick is never lost."""
|
||||
seq = self._replay.next_send_seq()
|
||||
sid = self._replay.host_session_id()
|
||||
w = max(-127, min(127, int(wheel)))
|
||||
body = struct.pack("<BHHb", buttons & 0xFF, x & 0xFFFF, y & 0xFFFF, w)
|
||||
plain = _pack_header(MSG_MOUSE, sid, seq) + body
|
||||
try:
|
||||
frame = build_frame(self._key,
|
||||
self._device_tag or self._scan_tag(), plain)
|
||||
except Exception:
|
||||
return False
|
||||
reliable = (buttons != self._last_mouse_buttons) or (w != 0)
|
||||
self._last_mouse_buttons = buttons
|
||||
try:
|
||||
await client.write_gatt_char(write_uuid, frame, response=reliable)
|
||||
self.bytes_sent += len(frame)
|
||||
return True
|
||||
except Exception as exc:
|
||||
_dbg("mouse_write_failed", error=repr(exc))
|
||||
return False
|
||||
|
||||
async def _send_label(self, client, write_uuid: str, label: str) -> bool:
|
||||
seq = self._replay.next_send_seq()
|
||||
sid = self._replay.host_session_id()
|
||||
body = (label or "").encode("utf-8")[:38]
|
||||
plain = _pack_header(MSG_LABEL, sid, seq) + body
|
||||
try:
|
||||
frame = build_frame(self._key,
|
||||
self._device_tag or self._scan_tag(), plain)
|
||||
except Exception as exc:
|
||||
log.warning("live: build_frame(label) failed: %s", exc)
|
||||
return False
|
||||
try:
|
||||
await client.write_gatt_char(write_uuid, frame, response=True)
|
||||
_dbg("label_sent", label=label)
|
||||
return True
|
||||
except Exception as exc:
|
||||
_dbg("label_write_failed", error=repr(exc))
|
||||
return False
|
||||
|
||||
async def _send_identify(self, client, write_uuid: str,
|
||||
on: bool) -> bool:
|
||||
"""Tell the device to show (on) or hide (off) its Bluetooth
|
||||
identify logo. Same AES-GCM envelope as every other frame."""
|
||||
seq = self._replay.next_send_seq()
|
||||
sid = self._replay.host_session_id()
|
||||
plain = (_pack_header(MSG_IDENTIFY, sid, seq)
|
||||
+ bytes([1 if on else 0]))
|
||||
try:
|
||||
frame = build_frame(self._key,
|
||||
self._device_tag or self._scan_tag(), plain)
|
||||
except Exception as exc:
|
||||
log.warning("live: build_frame(identify) failed: %s", exc)
|
||||
return False
|
||||
try:
|
||||
await client.write_gatt_char(write_uuid, frame, response=True)
|
||||
_dbg("identify_sent", on=on)
|
||||
return True
|
||||
except Exception as exc:
|
||||
_dbg("identify_write_failed", error=repr(exc))
|
||||
return False
|
||||
|
||||
async def _send_control(self, client, write_uuid: str,
|
||||
msg_type: int) -> bool:
|
||||
"""Send a control frame (START/STOP) with response=True so the
|
||||
device's receive callback runs before we move on. Returns True on
|
||||
wire-level success.
|
||||
"""
|
||||
seq = self._replay.next_send_seq()
|
||||
sid = self._replay.host_session_id()
|
||||
plain = _pack_header(msg_type, sid, seq)
|
||||
try:
|
||||
frame = build_frame(self._key, self._device_tag or self._scan_tag(),
|
||||
plain)
|
||||
except Exception as exc:
|
||||
log.warning("live: build_frame(control) failed: %s", exc)
|
||||
return False
|
||||
try:
|
||||
await client.write_gatt_char(write_uuid, frame, response=True)
|
||||
return True
|
||||
except Exception as exc:
|
||||
log.warning("live: control write failed: %s", exc)
|
||||
return False
|
||||
|
||||
def _scan_tag(self) -> str:
|
||||
# Should never be reached — hello sets _device_tag before control
|
||||
# frames go out. Fallback to a sentinel so build_frame doesn't blow
|
||||
# up on None during error paths.
|
||||
return DEVICE_TAG_PREFIX + "00:00:00:00:00:00"
|
||||
|
||||
def _set_status(self, status: str) -> None:
|
||||
if status == self._last_status:
|
||||
return
|
||||
self._last_status = status
|
||||
if self._on_status:
|
||||
try:
|
||||
self._on_status(status)
|
||||
except Exception as exc:
|
||||
log.warning("live: on_status callback error: %s", exc)
|
||||
Reference in New Issue
Block a user