add reliable BLE telemetry transport

This commit is contained in:
Jay
2026-08-18 06:24:10 -04:00
parent 00f52ecf0f
commit 7617010d8e
26 changed files with 1499 additions and 47 deletions
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Capture acknowledged TRK1 telemetry from the Trikke BLE service."""
from __future__ import annotations
import argparse
import asyncio
import csv
import os
import signal
from contextlib import ExitStack
from datetime import datetime
from pathlib import Path
from trikke_ble import BleFrameReassembler, encode_ack
from trikke_protocol import (
CSV_COLUMNS,
PACKET_TYPE_METADATA,
Frame,
IntegrityTracker,
Metadata,
StreamParser,
sample_to_csv_row,
)
DEVICE_NAME = "TrikkeSensor"
DATA_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c11"
ACK_UUID = "7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c12"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--address", help="BLE address/identifier; scan by name when omitted")
parser.add_argument("--name", default=DEVICE_NAME)
parser.add_argument("--output", type=Path, help="validated binary .trk output")
parser.add_argument("--csv", type=Path, help="decoded CSV output")
return parser.parse_args()
async def capture(args: argparse.Namespace) -> int:
try:
from bleak import BleakClient, BleakScanner
from bleak.exc import BleakError
except ImportError:
print("BLE capture requires bleak: python3 -m pip install -r requirements.txt")
return 2
stem = datetime.now().strftime("ble_%Y%m%d_%H%M%S")
output = args.output or Path("captures") / f"{stem}.trk"
csv_output = args.csv or output.with_suffix(".csv")
output.parent.mkdir(parents=True, exist_ok=True)
csv_output.parent.mkdir(parents=True, exist_ok=True)
device = args.address
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for signum in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(signum, stop.set)
except NotImplementedError:
pass
fragments: asyncio.Queue[bytes] = asyncio.Queue(maxsize=512)
callback_drop_count = 0
def on_fragment(_characteristic: object, data: bytearray) -> None:
payload = bytes(data)
def enqueue() -> None:
nonlocal callback_drop_count
try:
fragments.put_nowait(payload)
except asyncio.QueueFull:
callback_drop_count += 1
loop.call_soon_threadsafe(enqueue)
reassembler = BleFrameReassembler()
parser = StreamParser()
integrity = IntegrityTracker()
metadata: Metadata | None = None
pending_frames: list[Frame] = []
last_persisted_sequence: int | None = None
last_persisted_raw: bytes | None = None
sample_count = 0
frame_count = 0
with ExitStack() as stack:
raw_capture = stack.enter_context(output.open("wb"))
decoded = stack.enter_context(csv_output.open("w", encoding="utf-8", newline=""))
writer = csv.writer(decoded)
writer.writerow(CSV_COLUMNS)
print(f"Recording to {output} and {csv_output}; press Ctrl-C to stop")
while not stop.is_set():
try:
if device is None:
print(f"Scanning for {args.name}...")
device = await BleakScanner.find_device_by_name(
args.name, timeout=5.0
)
if device is None:
await asyncio.sleep(0.5)
continue
print(f"Connecting to {device}...")
async with BleakClient(device) as client:
reassembler.reset()
while not fragments.empty():
fragments.get_nowait()
await client.start_notify(DATA_UUID, on_fragment)
print("BLE connected and subscribed")
while not stop.is_set() and client.is_connected:
try:
fragment = await asyncio.wait_for(
fragments.get(), timeout=0.25
)
except TimeoutError:
continue
assembled = reassembler.feed(fragment)
if assembled is None:
continue
frames = parser.feed(assembled)
if len(frames) != 1 or frames[0].raw != assembled:
continue
frame = frames[0]
if (
frame.packet_sequence == last_persisted_sequence
and frame.raw == last_persisted_raw
):
await client.write_gatt_char(
ACK_UUID,
encode_ack(frame.packet_sequence),
response=True,
)
continue
raw_capture.write(frame.raw)
raw_capture.flush()
os.fsync(raw_capture.fileno())
integrity.observe(frame)
if frame.packet_type == PACKET_TYPE_METADATA:
metadata = frame.metadata
for pending in pending_frames:
for sample in pending.samples:
writer.writerow(sample_to_csv_row(
sample,
metadata,
pending.loop_overrun_count,
))
sample_count += 1
pending_frames.clear()
elif metadata is None:
pending_frames.append(frame)
else:
for sample in frame.samples:
writer.writerow(sample_to_csv_row(
sample, metadata, frame.loop_overrun_count
))
sample_count += 1
decoded.flush()
# The binary stream is authoritative and fsynced before
# ACK. A lost ACK is safe: replay is deduped above.
last_persisted_sequence = frame.packet_sequence
last_persisted_raw = frame.raw
await client.write_gatt_char(
ACK_UUID,
encode_ack(frame.packet_sequence),
response=True,
)
frame_count += 1
if client.is_connected:
await client.stop_notify(DATA_UUID)
except (BleakError, OSError) as error:
if not stop.is_set():
print(f"BLE interrupted ({error}); reconnecting")
if args.address is None:
device = None
if not stop.is_set():
await asyncio.sleep(0.5)
print(
f"Stopped after {frame_count} frames and {sample_count} samples; "
f"fragment_rejects={reassembler.rejected_fragment_count}, "
f"callback_drops={callback_drop_count}, "
f"packet_gaps={integrity.packet_gap_count}, "
f"sample_gaps={integrity.sample_gap_count}, "
f"crc_errors={parser.crc_errors}, "
f"trailing_partial_bytes={parser.buffered_bytes}"
)
print(
f"Status totals: accel_stale={integrity.accel_stale_count}, "
f"accel_overrun={integrity.accel_overrun_count}, "
f"gyro_stale={integrity.gyro_stale_count}, "
f"gyro_overrun={integrity.gyro_overrun_count}, "
f"dropped={integrity.final_dropped_sample_count}, "
f"acquisition_loop_overruns={integrity.final_loop_overrun_count}"
)
if integrity.final_status is not None:
status = integrity.final_status
print(
"Cause totals: "
f"sensor_read_failures={status.sensor_read_failure_count}, "
f"queue_overflows={status.queue_overflow_count}, "
f"transport_begin_retries={status.transport_begin_retry_count}, "
f"transport_disconnects={status.transport_disconnect_count}, "
f"transport_send_failures={status.transport_send_failure_count}, "
f"transport_replays={status.transport_replay_count}, "
f"transport_invalid_acks={status.transport_invalid_ack_count}"
)
return 0 if metadata is not None else 4
def main() -> int:
return asyncio.run(capture(parse_args()))
if __name__ == "__main__":
raise SystemExit(main())