strix/tools/ble_check.py

85 lines
3.5 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Checks the STRIX firmware's Bluetooth LE API (arduino/strix/API.md) from this computer:
finds the device, sends commands over the command characteristic, reads the replies from the
reply notifications (including `watch gcp` output), and reads and streams the entropy characteristic."""
import argparse
import asyncio
import json
import sys
from bleak import BleakClient, BleakScanner
SERVICE = "53545249-5800-4000-8000-000000000001"
COMMAND = "53545249-5800-4000-8000-000000000002"
REPLY = "53545249-5800-4000-8000-000000000003"
ENTROPY = "53545249-5800-4000-8000-000000000004"
class Replies:
"""Reassembles reply notifications into newline-terminated lines."""
def __init__(self):
self.buffer = b""
self.lines: asyncio.Queue[str] = asyncio.Queue()
def handle(self, _sender, data: bytearray) -> None:
self.buffer += bytes(data)
while b"\n" in self.buffer:
line, self.buffer = self.buffer.split(b"\n", 1)
self.lines.put_nowait(line.decode(errors="replace").strip())
async def command(client: BleakClient, replies: Replies, text: str) -> str:
await client.write_gatt_char(COMMAND, text.encode(), response=True)
return await asyncio.wait_for(replies.lines.get(), timeout=10)
async def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--timeout", type=float, default=15, help="scan time in seconds")
args = parser.parse_args()
device = await BleakScanner.find_device_by_filter(
lambda d, adv: (d.name or "") == "STRIX" or SERVICE in adv.service_uuids, timeout=args.timeout)
if device is None:
sys.exit("no STRIX device found")
print(f"found {device.name} at {device.address}")
async with BleakClient(device) as client:
replies = Replies()
await client.start_notify(REPLY, replies.handle)
for text in ["id", "health", "ble", "entropy 32"]:
reply = await command(client, replies, text)
print(f"{text:<12} -> {reply[:160]}")
stats = json.loads(await command(client, replies, "stats"))
print(f"{'stats':<12} -> trial {stats.get('trial')}, combined total z "
f"{stats['channels'][-1]['total']['z'] if 'channels' in stats else None}")
print(f"{'watch gcp':<12} -> {await command(client, replies, 'watch gcp')}")
records = [json.loads(await asyncio.wait_for(replies.lines.get(), timeout=3)) for _ in range(2)]
await client.write_gatt_char(COMMAND, b"watch off", response=True)
await asyncio.sleep(1.5)
while not replies.lines.empty():
replies.lines.get_nowait() # the watch reply and any second pushed before it
print(f"{'gcp seconds':<12} -> {[r['second'] for r in records]}, healthy {records[-1]['healthy']}, "
f"sample sums {records[-1]['sample']['sums']}")
first = await client.read_gatt_char(ENTROPY)
second = await client.read_gatt_char(ENTROPY)
print(f"entropy read -> {len(first)} bytes, then {len(second)} bytes, "
f"{'different' if first != second else 'IDENTICAL'}")
chunks = []
await client.start_notify(ENTROPY, lambda _s, data: chunks.append(bytes(data)))
await asyncio.sleep(3)
await client.stop_notify(ENTROPY)
total = sum(len(c) for c in chunks)
print(f"entropy stream -> {len(chunks)} notifications, {total} bytes in 3 s "
f"({total / 3:.0f} B/s), {len(set(chunks))} distinct")
if __name__ == "__main__":
asyncio.run(main())