From a725eaa1f5e03681303fa2c5d2bd3eb475ba6200 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Fri, 25 Sep 2026 10:04:09 -0400 Subject: [PATCH 01/19] feat(ble): add BLE publisher, SSE shot stream and phone orientation modules Ported unchanged from jake-fishtech/openflight@feat/iOS-ble (b053194): the Bless GATT publisher and v1 frames, the SSE shot broker, phone orientation validation, the BLE doc and an advertising hardware test. shot_v1.json now lives in tests/fixtures. Co-authored-by: Jake Fishman Co-Authored-By: Claude Opus 5.5 --- docs/ios-ble.md | 329 ++++++++++++++++ scripts/hardware-test/test_ble_advertise.py | 197 ++++++++++ src/openflight/ble/__init__.py | 25 ++ src/openflight/ble/protocol.py | 167 +++++++++ src/openflight/ble/publisher.py | 395 ++++++++++++++++++++ src/openflight/phone_orientation.py | 178 +++++++++ src/openflight/shot_stream.py | 180 +++++++++ tests/fixtures/shot_v1.json | 15 + tests/test_ble_protocol.py | 171 +++++++++ tests/test_ble_publisher.py | 362 ++++++++++++++++++ tests/test_shot_stream.py | 203 ++++++++++ 11 files changed, 2222 insertions(+) create mode 100644 docs/ios-ble.md create mode 100755 scripts/hardware-test/test_ble_advertise.py create mode 100644 src/openflight/ble/__init__.py create mode 100644 src/openflight/ble/protocol.py create mode 100644 src/openflight/ble/publisher.py create mode 100644 src/openflight/phone_orientation.py create mode 100644 src/openflight/shot_stream.py create mode 100644 tests/fixtures/shot_v1.json create mode 100644 tests/test_ble_protocol.py create mode 100644 tests/test_ble_publisher.py create mode 100644 tests/test_shot_stream.py diff --git a/docs/ios-ble.md b/docs/ios-ble.md new file mode 100644 index 000000000..4a8d1d9cd --- /dev/null +++ b/docs/ios-ble.md @@ -0,0 +1,329 @@ +# iOS app connection + +> **BLE blocker — check the Raspberry Pi kernel first:** Raspberry Pi kernel +> `6.18.34+rpt-rpi-2712` has a confirmed regression that rejects every BLE +> advertisement. Run `uname -r` on the Pi. If it reports that version, use the +> Wi-Fi transport or boot a working kernel such as 6.12.x; there is no userspace +> workaround. See the [full diagnosis](#known-bad-raspberry-pi-kernel-61834rpt-rpi-2712). + +OpenFlight sends each completed shot from a Raspberry Pi to the included SwiftUI +app over one of two local transports, chosen with the picker at the top of the +app. Both carry the identical versioned payload described below, so the app +behaves the same either way. + +| Transport | Pi setup | Use it when | +|---|---|---| +| **Bluetooth** | start with `--ble` | No Wi-Fi at all, or the phone is not on the Pi's network | +| **Wi-Fi** | always on | The phone and Pi share a network, or Bluetooth advertising is unavailable | + +Wi-Fi needs no flag: it streams from the same HTTP server that serves the +browser UI, and exposes nothing the browser UI does not already broadcast. + +## Requirements + +- Raspberry Pi running Raspberry Pi OS (working Bluetooth only for the BLE transport) +- iPhone running iOS 17 or newer +- Mac with Xcode 16 or newer to build the app +- The normal OpenFlight radar setup + +The app uses only Apple frameworks and has no package dependencies. The Pi +uses [Bless](https://github.com/kevincar/bless) to expose a small GATT server +through BlueZ. + +## Run the Pi + +The interactive setup script installs the optional BLE dependency on new +installations: + +```bash +./scripts/setup/setup.sh +``` + +For an existing checkout, install it and start OpenFlight with BLE enabled: + +```bash +uv sync --extra ble +scripts/start-kiosk.sh --ble +``` + +BLE startup and delivery errors are isolated from shot recording. If Bluetooth +is unavailable, the browser UI and session logger continue to work. + +## Wi-Fi transport + +The server streams shots as [Server-Sent Events](https://developer.mozilla.org/docs/Web/API/Server-sent_events) +at `/api/shots/stream`. Check it from any machine on the network before +involving a phone: + +```bash +curl -N http://raspberrypi.local:8080/api/shots/stream +``` + +A connection opens with a `: ping` comment, replays the most recent shot if +there is one, then emits one `event: shot` message per shot with a heartbeat +every 15 seconds while idle: + +```text +: ping + +event: shot +data: {"ball_speed_mph":151.4,"club":"driver",...,"schema_version":1} +``` + +In the app, pick **Wi-Fi** and enter the Pi's address. `raspberrypi.local:8080` +is the default and works on a stock Raspberry Pi OS install, which publishes its +hostname over mDNS; if you renamed the Pi, use `.local:8080` or its IP. +The port defaults to 8080 when you leave it off. The app reconnects on its own +with backoff, and iOS asks once for permission to talk to devices on the local +network. + +The server accepts up to eight simultaneous stream clients and answers `503` +beyond that, so a forgotten `curl` cannot crowd out a phone. + +## Build and run the iOS app + +For complete Xcode, signing, physical-device, simulator, testing, and +troubleshooting instructions, start with [`ios/README.md`](../ios/README.md). + +1. Open `ios/OpenFlight.xcodeproj` in Xcode. +2. Select the `OpenFlight` target, choose your development team, and use a + unique bundle identifier if Xcode requests one. +3. Connect an iPhone, select it as the run destination, and press Run. +4. Accept the Bluetooth permission prompt. +5. Start OpenFlight on the Pi, adding `--ble` if you want the Bluetooth + transport. + +Over Bluetooth the app scans only for the OpenFlight service, connects +automatically, and subscribes to shot and control notifications. Over Wi-Fi it +opens the shot stream and keeps it open. Either way, hit a shot and its metrics +should replace the empty dashboard. The most recent shot is replayed when a +phone connects, so a newly connected phone does not have to wait for another +shot. + +## Select the club from the iPhone + +Use **Club for next shot** on the dashboard to select any supported wood, +hybrid, iron, or wedge. OpenFlight applies the club to subsequent shots and +confirms the change before the app updates its saved selection. The app sends +the change over the currently selected transport: + +- Bluetooth uses the framed control characteristic described below. +- Wi-Fi sends `POST /api/club` with `{"club":"7-iron"}`. + +The browser UI and simulator integrations use the same server operation, so a +phone club change affects the same launch, spin, and carry processing state. + +> The iOS Simulator can run the automated tests, but CoreBluetooth does not +> provide a useful end-to-end BLE hardware test there. Use a physical iPhone +> and Raspberry Pi for manual connection testing. + +## Calibrate TI radar tilt with the iPhone + +The dashboard's **Calibrate TI Radar** button opens a guided mount-angle tool. +It sends the measurement over whichever transport is selected on the dashboard. + +1. Start OpenFlight with the IWR6843 enabled. Add `--ble` for Bluetooth, or + connect the phone to the Pi's network for Wi-Fi. +2. Remove the phone case. Hold the phone upright in portrait with its back flat + against a straight reference surface parallel to the TI antenna face. Keep + the screen facing the target and avoid resting on the camera bump. +3. Keep the radar and phone still while the app averages 120 gravity samples + over about two seconds. +4. Confirm left/right roll is within 3 degrees, then tap **Apply Calibration**. + +While the phone is moving, the angle cards are labeled **Live sensor reading** +and show the latest Core Motion gravity angles without the two-second averaging +lag. Once the sample window passes the stability and roll checks, the cards turn +green and switch to the **Stable 2-second average** that will actually be sent +to OpenFlight. + +The app sends the averaged gravity vector, calculated mount tilt and roll, +sample count, and stability statistics through the BLE control characteristic +or `POST /api/calibration/iwr6843/orientation`. Both paths call the same server +operation. The Pi independently recomputes the angles from gravity and rejects +inconsistent or unstable measurements. If the optional enclosure LIS3DH is +active, OpenFlight subtracts its current calibrated enclosure pitch so the +saved value remains the TI antenna's angle relative to the enclosure. Otherwise, +the measured phone tilt is used directly. + +The applied value takes effect immediately and is saved at +`~/.config/openflight/iwr6843_phone_orientation.json`. It is restored on startup +unless an explicit `--iwr6843-tilt-deg` value is supplied, which always wins. +Session logs record the change with source `ios_companion`. + +This process measures pitch and verifies roll. It deliberately does not change +`--iwr6843-azimuth-offset-deg`: an accelerometer cannot establish yaw relative +to the target line, and phone compass readings near radar electronics are not a +precision substitute for target-line alignment. + +## Wire protocol + +Both transports carry the same JSON event. Only the framing differs: Wi-Fi sends +it whole in one SSE `data:` line, while BLE splits it across notifications. + +Over BLE, OpenFlight advertises one service with a shot notification and a +bidirectional control characteristic: + +| Attribute | UUID | +|---|---| +| Shot service | `B6F633F2-E6E3-45AE-84B4-968ECCA2D9C7` | +| Shot notification | `2B28F67E-9011-41D2-98ED-562B47D7A5E4` | +| Control write + notification | `7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11` | + +Each event is compact UTF-8 JSON with `schema_version: 1`. Optional +measurements are present as `null` when the active hardware could not produce +them. The version-one fields are: + +```text +schema_version, event_id, timestamp, club, ball_speed_mph, +club_speed_mph, smash_factor, estimated_carry_yards, +launch_angle_vertical, launch_angle_horizontal, spin_rpm, +club_path_deg, spin_axis_deg +``` + +Over BLE the JSON is split into conservative 20-byte notifications. Every notification +has a five-byte, big-endian header followed by up to 15 payload bytes: + +| Byte(s) | Meaning | +|---|---| +| 0 | Frame version (`1`) | +| 1–2 | Unsigned 16-bit message sequence | +| 3 | Zero-based fragment index | +| 4 | Total fragment count | +| 5–19 | JSON payload fragment | + +Consumers should group frames by sequence, ignore duplicate fragment indexes, +order fragments by index, and decode only after all fragments arrive. The +shared contract fixture is +`ios/OpenFlightTests/Fixtures/shot_v1.json`; both Python and Swift tests decode +that file. + +Control writes and responses use the same framing. A command contains +`schema_version`, a unique `request_id`, a `type`, and a JSON `payload`. The Pi +notifies a response with the matching `request_id`, `ok`, and either `result` or +`error`. Version one supports `set_club` and +`iwr6843_orientation_calibration`. + +## Delivery behavior + +- Shot processing never waits for either transport, and a failure in one cannot + affect the other, the browser UI, or session logging. +- Each connected client gets a bounded queue of eight unsent events; the oldest + queued event is dropped if that client cannot keep up. One stalled phone + cannot slow down another. +- Disconnecting clears that client's queue but retains the latest completed shot + for replay on the next connection. +- The iOS app ignores a replayed event when its `event_id` is already visible. + +## Security and scope + +Version one intentionally has no application authentication or encryption layer +on either transport. Enable BLE only where nearby Bluetooth devices receiving +shots and issuing club or calibration commands is acceptable, and treat the +Wi-Fi API as accessible to anything on the same network — the same assumption +the browser UI already makes. Phone-assisted calibration can update and persist +TI mount tilt, so use either transport only in a trusted environment. + +Add authenticated pairing before expanding the control channel to sensitive or +destructive operations. + +## Troubleshooting + +**The Wi-Fi transport will not connect.** + +- Confirm the address with `curl -N http://:8080/api/shots/stream` from a + computer on the same network. If curl works and the app does not, the problem + is on the phone, not the Pi. +- If `.local` does not resolve, try the Pi's IP address; some networks + block mDNS. +- Accept the iOS local network permission prompt. Deny it once and the app + cannot reach the Pi until you re-enable it in Settings, Privacy & Security, + Local Network. +- Keep the phone on the same network as the Pi; this transport does not traverse + routers or VPNs. + +**The app stays on “Looking for OpenFlight.”** + +- Confirm OpenFlight was started with `--ble`. +- Run `bluetoothctl show` on the Pi and confirm `Powered: yes`. +- Keep the app in the foreground for the initial connection. +- Restart OpenFlight after changing the Pi Bluetooth configuration. + +**The Pi logs `DBusError: Failed to register advertisement`.** + +BlueZ returns that one message for every advertising failure, so check what +bluetoothd actually rejected: + +```bash +journalctl -u bluetooth -n 20 --no-pager +``` + +`Failed to add advertisement: Invalid Parameters (0x0d)` means the kernel +refused the advertisement. Register the advertisement one property group at a +time to find out which part it objects to: + +```bash +uv run python scripts/hardware-test/test_ble_advertise.py +``` + +The probe prints which layer is implicated. For the parameter-level detail, +capture the management interface while it runs: + +```bash +sudo btmon -w /tmp/ble-adv.btsnoop +``` + +### Known bad: Raspberry Pi kernel 6.18.34+rpt-rpi-2712 + +On this kernel every advertisement is rejected, including one carrying no data +at all. `Add Extended Advertising Parameters (0x0054)` succeeds and reports 31 +bytes available for both advertising and scan response data, and then `Add +Extended Advertising Data (0x0055)` fails with `Invalid Parameters (0x0d)` for +a zero-byte payload: + +```text +@ MGMT Event: Command Complete Add Extended Advertising Parameters (0x0054) + Status: Success (0x00) + Available adv data len: 31 + Available scan rsp data len: 31 +@ MGMT Command: Add Extended Advertising Data (0x0055) + Advertising data length: 0 + Scan response length: 0 +@ MGMT Event: Command Status Add Extended Advertising Data (0x0055) + Status: Invalid Parameters (0x0d) +``` + +Nothing in userspace can shrink a zero-byte payload, so no OpenFlight or Bless +setting works around this. Boot a kernel without the regression (6.12.x is +reported to work) and rerun the probe. Until then, use the Wi-Fi transport +above: it needs no Bluetooth and delivers the identical payload. + +**The Pi logs that Bluetooth is unavailable.** + +- Run `uv sync --extra ble`. +- Confirm the BlueZ service is running with `systemctl status bluetooth`. +- Confirm the user running OpenFlight can access the system D-Bus and Bluetooth + adapter. + +**The app connects but no new shot appears.** + +- Confirm the browser UI received the shot; BLE publishes only completed shot + events. +- Look for `[BLE]` warnings in the OpenFlight terminal. +- Tap Retry in the app to disconnect, scan, and subscribe again. + +## Automated tests + +```bash +uv run pytest tests/test_ble_protocol.py tests/test_ble_publisher.py \ + tests/test_shot_stream.py tests/test_phone_orientation_calibration.py \ + tests/test_control_commands.py -v + +xcodebuild test \ + -project ios/OpenFlight.xcodeproj \ + -scheme OpenFlight \ + -destination "platform=iOS Simulator,id=PASTE-SIMULATOR-UUID-HERE" \ + CODE_SIGNING_ALLOWED=NO +``` + +List valid simulator UUIDs first with `xcrun simctl list devices available`. diff --git a/scripts/hardware-test/test_ble_advertise.py b/scripts/hardware-test/test_ble_advertise.py new file mode 100755 index 000000000..525eeea32 --- /dev/null +++ b/scripts/hardware-test/test_ble_advertise.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Probe which BLE advertising properties this Pi's Bluetooth controller accepts. + +BlueZ reports every advertising failure as the same D-Bus error, "Failed to +register advertisement", so the real cause has to come from elsewhere. This +script registers the OpenFlight advertisement one property group at a time and +reports exactly which group BlueZ or the kernel rejects. + + uv run python scripts/hardware-test/test_ble_advertise.py + +Run ``journalctl -u bluetooth -n 20 --no-pager`` afterwards to see bluetoothd's +own reason for any failure. Two failures are common on Raspberry Pi hardware: + +* ``Invalid Parameters (0x0d)`` after adding TxPower/MinInterval/MaxInterval: + the controller has no LE Extended Advertising, so the kernel refuses the + MGMT_ADV_PARAM_* flags BlueZ derives from those properties. +* ``Invalid Parameters (0x0d)`` after adding LocalName: advertising data does + not fit in the legacy 31-byte budget alongside the 128-bit service UUID. +""" + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src")) + +# pylint: disable=wrong-import-position,invalid-name +from dbus_next import BusType # noqa: E402 +from dbus_next.aio import MessageBus # noqa: E402 +from dbus_next.constants import PropertyAccess # noqa: E402 +from dbus_next.errors import DBusError # noqa: E402 +from dbus_next.service import ServiceInterface, dbus_property, method # noqa: E402 + +from openflight.ble.protocol import SERVICE_UUID # noqa: E402 + +ADAPTER_PATH = "/org/bluez/hci0" +LOCAL_NAME = "OpenFlight" + +# Advertising data budget for a legacy (non-extended) advertisement. +LEGACY_AD_BUDGET = 31 +AD_ELEMENT_OVERHEAD = 2 # one length byte plus one AD type byte + + +class ProbeAdvertisement(ServiceInterface): + """Minimal org.bluez.LEAdvertisement1 mirroring Bless 0.3.0's properties.""" + + def __init__(self, path: str): + self.path = path + super().__init__("org.bluez.LEAdvertisement1") + + @method() + def Release(self): # noqa: N802 + """Called by BlueZ when it drops the advertisement.""" + + @dbus_property(access=PropertyAccess.READ) + def Type(self) -> "s": # type: ignore[valid-type] # noqa: F821 N802 + return "peripheral" + + @dbus_property(access=PropertyAccess.READ) + def ServiceUUIDs(self) -> "as": # type: ignore[valid-type] # noqa: F722 N802 + return [SERVICE_UUID] + + @dbus_property(access=PropertyAccess.READ) + def LocalName(self) -> "s": # type: ignore[valid-type] # noqa: F821 N802 + return LOCAL_NAME + + @dbus_property(access=PropertyAccess.READ) + def TxPower(self) -> "n": # type: ignore[valid-type] # noqa: F821 N802 + return 20 + + @dbus_property(access=PropertyAccess.READ) + def MinInterval(self) -> "u": # type: ignore[valid-type] # noqa: F821 N802 + return 100 + + @dbus_property(access=PropertyAccess.READ) + def MaxInterval(self) -> "u": # type: ignore[valid-type] # noqa: F821 N802 + return 100 + + +OPTIONAL_PROPERTIES = ("ServiceUUIDs", "LocalName", "TxPower", "MinInterval", "MaxInterval") + +# Each variant lists the properties hidden from BlueZ. Later variants add back +# what earlier ones withheld, so the first failure names the culprit. The first +# variant carries no advertising data at all, which separates "this controller +# rejects everything" from "this payload is too large" from "this property is +# unsupported". +VARIANTS = ( + ("no advertising data", OPTIONAL_PROPERTIES), + ("service UUID only", ("LocalName", "TxPower", "MinInterval", "MaxInterval")), + ("service UUID + LocalName", ("TxPower", "MinInterval", "MaxInterval")), + ("service UUID + LocalName + TxPower", ("MinInterval", "MaxInterval")), + ("Bless 0.3.0 defaults (all properties)", ()), +) + + +def _apply_variant(hidden: tuple[str, ...]) -> None: + for name in OPTIONAL_PROPERTIES: + getattr(ProbeAdvertisement, name).disabled = name in hidden + + +def _estimate_ad_length(hidden: tuple[str, ...]) -> int: + length = 0 + if "ServiceUUIDs" not in hidden: + length += AD_ELEMENT_OVERHEAD + 16 # complete list of 128-bit service UUIDs + if "LocalName" not in hidden: + length += AD_ELEMENT_OVERHEAD + len(LOCAL_NAME) + return length + + +async def _describe_adapter(bus: MessageBus) -> bool: + introspection = await bus.introspect("org.bluez", ADAPTER_PATH) + proxy = bus.get_proxy_object("org.bluez", ADAPTER_PATH, introspection) + adapter = proxy.get_interface("org.bluez.Adapter1") + powered = await adapter.get_powered() + print(f"Adapter {ADAPTER_PATH}: powered={powered}") + + manager = proxy.get_interface("org.bluez.LEAdvertisingManager1") + supported = await manager.get_supported_instances() + active = await manager.get_active_instances() + print(f"Advertising instances: {active} active, {supported} free") + if not powered: + print("\nAdapter is powered off. Run 'bluetoothctl power on' and retry.") + return bool(powered) + + +async def _try_variant(bus: MessageBus, index: int, label: str, hidden: tuple[str, ...]) -> bool: + _apply_variant(hidden) + advertisement = ProbeAdvertisement(f"/org/openflight/probe/advertisement{index}") + bus.export(advertisement.path, advertisement) + + introspection = await bus.introspect("org.bluez", ADAPTER_PATH) + proxy = bus.get_proxy_object("org.bluez", ADAPTER_PATH, introspection) + manager = proxy.get_interface("org.bluez.LEAdvertisingManager1") + + estimate = _estimate_ad_length(hidden) + budget = f"~{estimate}/{LEGACY_AD_BUDGET} bytes of advertising data" + try: + await manager.call_register_advertisement(advertisement.path, {}) + except DBusError as exc: + print(f" [FAIL] {label} ({budget}): {exc.text}") + bus.unexport(advertisement.path, advertisement) + return False + + print(f" [ OK ] {label} ({budget})") + await asyncio.sleep(0.5) + try: + await manager.call_unregister_advertisement(advertisement.path) + except DBusError as exc: + print(f" (could not unregister: {exc.text})") + bus.unexport(advertisement.path, advertisement) + return True + + +async def main() -> int: + bus = await MessageBus(bus_type=BusType.SYSTEM).connect() + try: + if not await _describe_adapter(bus): + return 1 + + print("\nRegistering advertisement variants:") + results = [] + for index, (label, hidden) in enumerate(VARIANTS, start=1): + results.append((label, await _try_variant(bus, index, label, hidden))) + + print() + if all(ok for _, ok in results): + print("All variants accepted. Bless should advertise as-is on this Pi.") + return 0 + + first_failure = next(label for label, ok in results if not ok) + print(f"First rejected variant: {first_failure}") + if first_failure == "no advertising data": + print("An advertisement carrying no data was rejected, so neither the") + print("payload size nor any single property is the cause. Suspect the") + print("adapter, BlueZ, or the kernel's advertising path itself.") + elif first_failure == "service UUID only": + print("18 bytes of advertising data were rejected while an empty") + print("advertisement was accepted, which points at data-length") + print("validation rather than at an unsupported property.") + elif first_failure == "service UUID + LocalName": + print("The service UUID and the name do not both fit; shorten the name") + print("passed to BleShotPublisher in server.py.") + else: + print("An advertising parameter this controller cannot honor is the") + print("cause. OpenFlight already hides TxPower/MinInterval/MaxInterval.") + print("\nCompare against bluetoothd's own reason:") + print(" journalctl -u bluetooth -n 20 --no-pager") + return 1 + finally: + bus.disconnect() + + +if __name__ == "__main__": + try: + sys.exit(asyncio.run(main())) + except KeyboardInterrupt: + sys.exit(130) diff --git a/src/openflight/ble/__init__.py b/src/openflight/ble/__init__.py new file mode 100644 index 000000000..474002f7b --- /dev/null +++ b/src/openflight/ble/__init__.py @@ -0,0 +1,25 @@ +"""Bluetooth Low Energy shot publishing for OpenFlight.""" + +from .protocol import ( + CONTROL_CHARACTERISTIC_UUID, + FRAME_SIZE, + SERVICE_UUID, + SHOT_CHARACTERISTIC_UUID, + FragmentReassembler, + build_shot_event, + encode_shot_event, + fragment_payload, +) +from .publisher import BleShotPublisher + +__all__ = [ + "BleShotPublisher", + "CONTROL_CHARACTERISTIC_UUID", + "FRAME_SIZE", + "FragmentReassembler", + "SERVICE_UUID", + "SHOT_CHARACTERISTIC_UUID", + "build_shot_event", + "encode_shot_event", + "fragment_payload", +] diff --git a/src/openflight/ble/protocol.py b/src/openflight/ble/protocol.py new file mode 100644 index 000000000..ca2a5cc39 --- /dev/null +++ b/src/openflight/ble/protocol.py @@ -0,0 +1,167 @@ +"""Versioned OpenFlight shot payload and BLE framing helpers.""" + +from __future__ import annotations + +import json +import math +import struct +import uuid +from typing import Iterable, Mapping + +SERVICE_UUID = "B6F633F2-E6E3-45AE-84B4-968ECCA2D9C7" +SHOT_CHARACTERISTIC_UUID = "2B28F67E-9011-41D2-98ED-562B47D7A5E4" +CONTROL_CHARACTERISTIC_UUID = "7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11" + +SCHEMA_VERSION = 1 +FRAME_VERSION = 1 +FRAME_SIZE = 20 +_HEADER = struct.Struct(">BHBB") +HEADER_SIZE = _HEADER.size +FRAGMENT_PAYLOAD_SIZE = FRAME_SIZE - HEADER_SIZE +MAX_FRAGMENT_COUNT = 255 +MAX_MESSAGE_SIZE = FRAGMENT_PAYLOAD_SIZE * MAX_FRAGMENT_COUNT + +_OPTIONAL_SHOT_FIELDS = ( + "club_speed_mph", + "smash_factor", + "launch_angle_vertical", + "launch_angle_horizontal", + "spin_rpm", + "club_path_deg", + "spin_axis_deg", +) + + +def build_club_event(club: str) -> dict: + """Build the V1 event broadcast whenever the authoritative club changes.""" + if not isinstance(club, str) or not club: + raise ValueError("Club must be a non-empty string") + return { + "schema_version": SCHEMA_VERSION, + "type": "club_changed", + "club": club, + } + + +def encode_club_event(club: str) -> bytes: + """Encode a club-state event as deterministic, compact UTF-8 JSON.""" + return json.dumps( + build_club_event(club), + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def build_shot_event(shot_data: Mapping, *, event_id: str | None = None) -> dict: + """Build the stable, display-focused V1 payload from ``shot_to_dict`` output.""" + event = { + "schema_version": SCHEMA_VERSION, + "event_id": event_id or str(uuid.uuid4()), + "timestamp": shot_data["timestamp"], + "club": shot_data["club"], + "ball_speed_mph": shot_data["ball_speed_mph"], + "estimated_carry_yards": shot_data["estimated_carry_yards"], + } + event.update({field: shot_data.get(field) for field in _OPTIONAL_SHOT_FIELDS}) + return event + + +def encode_shot_event(shot_data: Mapping, *, event_id: str | None = None) -> bytes: + """Encode a shot event as deterministic, compact UTF-8 JSON.""" + event = build_shot_event(shot_data, event_id=event_id) + return json.dumps( + event, + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def fragment_payload(payload: bytes, *, sequence: int) -> list[bytes]: + """Split a message into conservative 20-byte BLE notification frames.""" + if not payload: + raise ValueError("BLE payload must not be empty") + if not 0 <= sequence <= 0xFFFF: + raise ValueError("BLE sequence must fit in an unsigned 16-bit integer") + + fragment_count = math.ceil(len(payload) / FRAGMENT_PAYLOAD_SIZE) + if fragment_count > MAX_FRAGMENT_COUNT: + raise ValueError( + f"BLE payload is {len(payload)} bytes; maximum is {MAX_MESSAGE_SIZE} bytes" + ) + + frames = [] + for index in range(fragment_count): + start = index * FRAGMENT_PAYLOAD_SIZE + chunk = payload[start : start + FRAGMENT_PAYLOAD_SIZE] + frames.append(_HEADER.pack(FRAME_VERSION, sequence, index, fragment_count) + chunk) + return frames + + +def parse_fragment(frame: bytes) -> tuple[int, int, int, bytes]: + """Return ``(sequence, index, count, payload)`` after validating one frame.""" + if len(frame) < HEADER_SIZE or len(frame) > FRAME_SIZE: + raise ValueError("BLE frame has an invalid size") + version, sequence, index, fragment_count = _HEADER.unpack(frame[:HEADER_SIZE]) + if version != FRAME_VERSION: + raise ValueError(f"unsupported BLE frame version: {version}") + if fragment_count == 0 or index >= fragment_count: + raise ValueError("BLE frame has invalid fragment metadata") + return sequence, index, fragment_count, frame[HEADER_SIZE:] + + +def reassemble_fragments(frames: Iterable[bytes]) -> bytes: + """Reassemble a complete message; duplicate fragments are harmless.""" + sequence = None + fragment_count = None + fragments: dict[int, bytes] = {} + + for frame in frames: + frame_sequence, index, frame_count, payload = parse_fragment(frame) + if sequence is None: + sequence = frame_sequence + fragment_count = frame_count + elif frame_sequence != sequence or frame_count != fragment_count: + raise ValueError("BLE frames belong to different messages") + fragments[index] = payload + + if fragment_count is None or len(fragments) != fragment_count: + raise ValueError("BLE message is incomplete") + return b"".join(fragments[index] for index in range(fragment_count)) + + +class FragmentReassembler: + """Incrementally reassemble one message, replacing stale partial messages.""" + + def __init__(self): + self._sequence: int | None = None + self._fragment_count: int | None = None + self._fragments: dict[int, bytes] = {} + + def reset(self) -> None: + """Discard the current incomplete message.""" + self._sequence = None + self._fragment_count = None + self._fragments = {} + + def append(self, frame: bytes) -> bytes | None: + """Append one frame and return the complete payload when available.""" + sequence, index, fragment_count, payload = parse_fragment(frame) + if self._sequence != sequence: + self.reset() + self._sequence = sequence + self._fragment_count = fragment_count + elif self._fragment_count != fragment_count: + self.reset() + raise ValueError("BLE frames disagree about fragment count") + + self._fragments[index] = payload + if len(self._fragments) != fragment_count: + return None + + message = b"".join(self._fragments[item] for item in range(fragment_count)) + self.reset() + return message diff --git a/src/openflight/ble/publisher.py b/src/openflight/ble/publisher.py new file mode 100644 index 000000000..7b7a9a121 --- /dev/null +++ b/src/openflight/ble/publisher.py @@ -0,0 +1,395 @@ +"""Non-blocking BLE GATT publisher backed by Bless/BlueZ.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import threading +from collections.abc import Callable +from typing import Any, Mapping + +from .protocol import ( + CONTROL_CHARACTERISTIC_UUID, + SCHEMA_VERSION, + SERVICE_UUID, + SHOT_CHARACTERISTIC_UUID, + FragmentReassembler, + encode_club_event, + encode_shot_event, + fragment_payload, +) + +logger = logging.getLogger(__name__) + + +class BleShotPublisher: + """Publish completed shots without coupling the radar thread to Bluetooth.""" + + def __init__( + self, + *, + name: str = "OpenFlight", + queue_size: int = 8, + fragment_interval_s: float = 0.01, + command_handler: Callable[[str, Mapping], tuple[dict, int]] | None = None, + ): + if queue_size < 1: + raise ValueError("BLE queue size must be at least one") + self.name = name + self.queue_size = queue_size + self.fragment_interval_s = fragment_interval_s + self.command_handler = command_handler + + self._state_lock = threading.Lock() + self._thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._queue: asyncio.Queue[bytes] | None = None + self._stop_event: asyncio.Event | None = None + self._stop_requested = threading.Event() + self._server = None + self._latest_payload: bytes | None = None + self._subscribed = False + self._sequence = 0 + self._control_sequence = 0 + self._control_reassembler = FragmentReassembler() + self._control_send_lock: asyncio.Lock | None = None + + @property + def subscribed(self) -> bool: + """Whether at least one central is subscribed to shot notifications.""" + with self._state_lock: + return self._subscribed + + def start(self) -> None: + """Start advertising in a daemon thread; startup failures remain isolated.""" + with self._state_lock: + if self._thread and self._thread.is_alive(): + return + self._stop_requested.clear() + self._thread = threading.Thread( + target=self._run_thread, + name="openflight-ble", + daemon=True, + ) + self._thread.start() + + def stop(self) -> None: + """Stop advertising and join the BLE thread.""" + self._stop_requested.set() + with self._state_lock: + loop = self._loop + stop_event = self._stop_event + thread = self._thread + if loop and stop_event: + loop.call_soon_threadsafe(stop_event.set) + if thread and thread is not threading.current_thread(): + thread.join(timeout=5.0) + if thread.is_alive(): + logger.warning("[BLE] Publisher thread did not stop within 5 seconds") + + def publish(self, shot_data: Mapping) -> bool: + """Store the latest shot and enqueue it when a central is subscribed.""" + try: + payload = encode_shot_event(shot_data) + except (KeyError, TypeError, ValueError): + logger.warning("[BLE] Failed to encode shot payload", exc_info=True) + return False + + with self._state_lock: + self._latest_payload = payload + loop = self._loop + subscribed = self._subscribed + if loop and subscribed: + loop.call_soon_threadsafe(self._enqueue_payload, payload) + return True + + def publish_club(self, club: str) -> bool: + """Notify connected centrals that the authoritative club changed.""" + try: + payload = encode_club_event(club) + except (TypeError, ValueError): + logger.warning("[BLE] Failed to encode club payload", exc_info=True) + return False + + with self._state_lock: + loop = self._loop + subscribed = self._subscribed + if loop and subscribed: + asyncio.run_coroutine_threadsafe(self._send_control_response(payload), loop) + return True + + def _run_thread(self) -> None: + try: + asyncio.run(self._run()) + except Exception: # pylint: disable=broad-exception-caught + logger.warning( + "[BLE] Bluetooth unavailable; shot recording will continue without BLE", + exc_info=True, + ) + finally: + with self._state_lock: + self._loop = None + self._queue = None + self._stop_event = None + self._server = None + self._subscribed = False + self._control_send_lock = None + + async def _run(self) -> None: + # Bless is an optional dependency and must not affect non-BLE installs. + from bless import ( # pylint: disable=import-error,import-outside-toplevel + BlessServer, + GATTAttributePermissions, + GATTCharacteristicProperties, + ) + + loop = asyncio.get_running_loop() + queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=self.queue_size) + stop_event = asyncio.Event() + server = BlessServer( + name=self.name, + loop=loop, + on_subscribe=self._on_subscribe, + on_unsubscribe=self._on_unsubscribe, + ) + await server.add_new_service(SERVICE_UUID) + await server.add_new_characteristic( + SERVICE_UUID, + SHOT_CHARACTERISTIC_UUID, + GATTCharacteristicProperties.notify, + bytearray(), + GATTAttributePermissions.readable, + ) + await server.add_new_characteristic( + SERVICE_UUID, + CONTROL_CHARACTERISTIC_UUID, + GATTCharacteristicProperties.write | GATTCharacteristicProperties.notify, + bytearray(), + GATTAttributePermissions.readable | GATTAttributePermissions.writeable, + ) + server.write_request_func = self._on_write_request + self._install_bluez_subscription_hooks(server) + + with self._state_lock: + self._loop = loop + self._queue = queue + self._stop_event = stop_event + self._server = server + + await server.start() + logger.info("[BLE] Advertising %s", self.name) + if self._stop_requested.is_set(): + stop_event.set() + + worker = asyncio.create_task(self._delivery_worker()) + try: + await stop_event.wait() + finally: + worker.cancel() + await asyncio.gather(worker, return_exceptions=True) + await server.stop() + logger.info("[BLE] Advertising stopped") + + def _install_bluez_subscription_hooks(self, server) -> None: + """Wire callbacks that Bless 0.3.0 leaves disconnected on BlueZ. + + Bless's Linux backend accepts ``on_subscribe`` and ``on_unsubscribe`` + constructor keywords but replaces the underlying BlueZ ``StartNotify`` + and ``StopNotify`` handlers with no-ops. Hook the application object + after asynchronous server setup so delivery state follows the iOS + notification subscription. + """ + app = getattr(server, "app", None) + if app is None: + return + app.StartNotify = lambda session: self._on_subscribe(None, session) + app.StopNotify = lambda session: self._on_unsubscribe(None, session) + + def _on_subscribe(self, _characteristic, _session) -> None: + with self._state_lock: + self._subscribed = True + latest_payload = self._latest_payload + logger.info("[BLE] iOS client subscribed") + if latest_payload is not None: + self._enqueue_payload(latest_payload) + + def _on_unsubscribe(self, _characteristic, _session) -> None: + with self._state_lock: + self._subscribed = False + self._clear_queue() + logger.info("[BLE] iOS client unsubscribed") + + def _enqueue_payload(self, payload: bytes) -> None: + with self._state_lock: + queue = self._queue + subscribed = self._subscribed + if queue is None or not subscribed: + return + if queue.full(): + try: + queue.get_nowait() + queue.task_done() + logger.warning("[BLE] Delivery queue full; dropped oldest unsent shot") + except asyncio.QueueEmpty: + pass + queue.put_nowait(payload) + + def _clear_queue(self) -> None: + with self._state_lock: + queue = self._queue + if queue is None: + return + while True: + try: + queue.get_nowait() + queue.task_done() + except asyncio.QueueEmpty: + return + + async def _delivery_worker(self) -> None: + with self._state_lock: + queue = self._queue + if queue is None: + return + while True: + payload = await queue.get() + try: + await self._send_payload(payload) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[BLE] Failed to notify shot payload", exc_info=True) + finally: + queue.task_done() + + async def _send_payload(self, payload: bytes) -> None: + with self._state_lock: + server = self._server + subscribed = self._subscribed + sequence = self._sequence + self._sequence = (self._sequence + 1) & 0xFFFF + if server is None or not subscribed: + return + + characteristic = server.get_characteristic(SHOT_CHARACTERISTIC_UUID) + if characteristic is None: + raise RuntimeError("BLE shot characteristic is unavailable") + + for frame in fragment_payload(payload, sequence=sequence): + with self._state_lock: + if not self._subscribed: + return + characteristic.value = bytearray(frame) + if not server.update_value(SERVICE_UUID, SHOT_CHARACTERISTIC_UUID): + raise RuntimeError("BLE notification update failed") + await asyncio.sleep(self.fragment_interval_s) + + def _on_write_request(self, characteristic, value, **_kwargs) -> None: + """Receive one framed phone control command from the writable GATT value.""" + if str(getattr(characteristic, "uuid", "")).lower() != CONTROL_CHARACTERISTIC_UUID.lower(): + return + characteristic.value = bytearray(value) + try: + payload = self._control_reassembler.append(bytes(value)) + except ValueError: + logger.warning("[BLE] Rejected malformed control frame", exc_info=True) + self._control_reassembler.reset() + return + if payload is None: + return + + with self._state_lock: + loop = self._loop + if loop is None: + return + asyncio.run_coroutine_threadsafe(self._process_control_payload(payload), loop) + + async def _process_control_payload(self, payload: bytes) -> None: + request_id = "unknown" + try: + command = json.loads(payload) + if not isinstance(command, dict): + raise ValueError("Control command must be a JSON object") + request_id = command.get("request_id") + command_type = command.get("type") + command_payload = command.get("payload") + if command.get("schema_version") != SCHEMA_VERSION: + raise ValueError("Unsupported control schema version") + if not isinstance(request_id, str) or not request_id: + raise ValueError("Control command requires a request_id") + if not isinstance(command_type, str) or not command_type: + raise ValueError("Control command requires a type") + if not isinstance(command_payload, dict): + raise ValueError("Control command payload must be an object") + if self.command_handler is None: + raise ValueError("Phone controls are not configured on this OpenFlight server") + + result, status = await asyncio.to_thread( + self.command_handler, + command_type, + command_payload, + ) + if status < 200 or status >= 300: + error = result.get("error", f"Control command failed with status {status}") + response = self._control_response(request_id, error=str(error)) + else: + response = self._control_response(request_id, result=result) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as error: + response = self._control_response(str(request_id or "unknown"), error=str(error)) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("[BLE] Phone control command failed") + response = self._control_response( + str(request_id or "unknown"), + error="OpenFlight could not apply the phone command", + ) + + encoded = json.dumps( + response, + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + await self._send_control_response(encoded) + + @staticmethod + def _control_response( + request_id: str, + *, + result: Mapping[str, Any] | None = None, + error: str | None = None, + ) -> dict: + response = { + "schema_version": SCHEMA_VERSION, + "request_id": request_id, + "ok": error is None, + } + if error is None: + response["result"] = dict(result or {}) + else: + response["error"] = error + return response + + async def _send_control_response(self, payload: bytes) -> None: + if self._control_send_lock is None: + self._control_send_lock = asyncio.Lock() + async with self._control_send_lock: + await self._send_control_payload(payload) + + async def _send_control_payload(self, payload: bytes) -> None: + """Send one complete control message without interleaving fragments.""" + with self._state_lock: + server = self._server + subscribed = self._subscribed + sequence = self._control_sequence + self._control_sequence = (self._control_sequence + 1) & 0xFFFF + if server is None or not subscribed: + return + + characteristic = server.get_characteristic(CONTROL_CHARACTERISTIC_UUID) + if characteristic is None: + raise RuntimeError("BLE control characteristic is unavailable") + for frame in fragment_payload(payload, sequence=sequence): + characteristic.value = bytearray(frame) + if not server.update_value(SERVICE_UUID, CONTROL_CHARACTERISTIC_UUID): + raise RuntimeError("BLE control notification update failed") + await asyncio.sleep(self.fragment_interval_s) diff --git a/src/openflight/phone_orientation.py b/src/openflight/phone_orientation.py new file mode 100644 index 000000000..3768ef9d2 --- /dev/null +++ b/src/openflight/phone_orientation.py @@ -0,0 +1,178 @@ +"""Validation and persistence for phone-assisted radar orientation calibration.""" + +from __future__ import annotations + +import json +import math +import os +import tempfile +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + + +class PhoneOrientationValidationError(ValueError): + """A phone orientation payload is unsafe or internally inconsistent.""" + + +def _finite_number(payload: dict, key: str) -> float: + value = payload.get(key) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise PhoneOrientationValidationError(f"{key} must be a number") + number = float(value) + if not math.isfinite(number): + raise PhoneOrientationValidationError(f"{key} must be finite") + return number + + +@dataclass(frozen=True) +class PhoneOrientationMeasurement: + """One stable, gravity-referenced phone measurement.""" + + schema_version: int + mount_tilt_deg: float + roll_deg: float + gravity_x_g: float + gravity_y_g: float + gravity_z_g: float + tilt_stddev_deg: float + roll_stddev_deg: float + sample_count: int + measured_at: str + device_model: str + + @classmethod + def from_payload(cls, payload: Any) -> "PhoneOrientationMeasurement": + """Validate a client payload and recompute its angles from gravity.""" + if not isinstance(payload, dict): + raise PhoneOrientationValidationError("JSON body must be an object") + if payload.get("schema_version") != 1: + raise PhoneOrientationValidationError("schema_version must be 1") + + sample_count = payload.get("sample_count") + if isinstance(sample_count, bool) or not isinstance(sample_count, int): + raise PhoneOrientationValidationError("sample_count must be an integer") + if sample_count < 30: + raise PhoneOrientationValidationError("at least 30 samples are required") + + tilt_stddev = _finite_number(payload, "tilt_stddev_deg") + roll_stddev = _finite_number(payload, "roll_stddev_deg") + if not 0.0 <= tilt_stddev <= 0.5 or not 0.0 <= roll_stddev <= 0.5: + raise PhoneOrientationValidationError( + "phone must remain stable (angle standard deviation must be at most 0.5 degrees)" + ) + + gravity_x = _finite_number(payload, "gravity_x_g") + gravity_y = _finite_number(payload, "gravity_y_g") + gravity_z = _finite_number(payload, "gravity_z_g") + gravity_norm = math.sqrt( + gravity_x * gravity_x + gravity_y * gravity_y + gravity_z * gravity_z + ) + if not 0.9 <= gravity_norm <= 1.1: + raise PhoneOrientationValidationError("gravity vector must be between 0.9g and 1.1g") + + recomputed_tilt = math.degrees(math.asin(max(-1.0, min(1.0, -gravity_z / gravity_norm)))) + recomputed_roll = math.degrees(math.atan2(gravity_x, -gravity_y)) + submitted_tilt = _finite_number(payload, "mount_tilt_deg") + submitted_roll = _finite_number(payload, "roll_deg") + if abs(submitted_tilt - recomputed_tilt) > 0.25: + raise PhoneOrientationValidationError( + "mount_tilt_deg does not match the transmitted gravity vector" + ) + if abs(submitted_roll - recomputed_roll) > 0.25: + raise PhoneOrientationValidationError( + "roll_deg does not match the transmitted gravity vector" + ) + if not -30.0 <= recomputed_tilt <= 45.0: + raise PhoneOrientationValidationError("mount tilt must be between -30 and 45 degrees") + if abs(recomputed_roll) > 3.0: + raise PhoneOrientationValidationError( + "level the radar left-to-right within 3 degrees before calibrating" + ) + + measured_at = payload.get("measured_at") + if not isinstance(measured_at, str) or not measured_at.strip(): + raise PhoneOrientationValidationError("measured_at must be an ISO-8601 timestamp") + device_model = payload.get("device_model", "iPhone") + if not isinstance(device_model, str): + raise PhoneOrientationValidationError("device_model must be a string") + + return cls( + schema_version=1, + mount_tilt_deg=recomputed_tilt, + roll_deg=recomputed_roll, + gravity_x_g=gravity_x, + gravity_y_g=gravity_y, + gravity_z_g=gravity_z, + tilt_stddev_deg=tilt_stddev, + roll_stddev_deg=roll_stddev, + sample_count=sample_count, + measured_at=measured_at.strip()[:64], + device_model=device_model.strip()[:80] or "iPhone", + ) + + def to_dict(self) -> dict: + """Return a rounded JSON-safe representation.""" + data = asdict(self) + for key in ( + "mount_tilt_deg", + "roll_deg", + "tilt_stddev_deg", + "roll_stddev_deg", + ): + data[key] = round(data[key], 4) + for key in ("gravity_x_g", "gravity_y_g", "gravity_z_g"): + data[key] = round(data[key], 6) + return data + + +def save_phone_orientation_calibration(record: dict, path: Path) -> None: + """Atomically persist a validated applied-orientation record.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + json.dump(record, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + temporary_path = Path(handle.name) + os.replace(temporary_path, path) + finally: + if temporary_path is not None and temporary_path.exists(): + temporary_path.unlink() + + +def load_phone_orientation_calibration(path: Path) -> dict | None: + """Load and validate the persisted applied-orientation record.""" + path = Path(path) + if not path.exists(): + return None + with path.open(encoding="utf-8") as handle: + record = json.load(handle) + if not isinstance(record, dict) or record.get("schema_version") != 1: + raise PhoneOrientationValidationError("saved calibration schema_version must be 1") + configured_tilt = _finite_number(record, "configured_iwr_tilt_deg") + if not -45.0 <= configured_tilt <= 45.0: + raise PhoneOrientationValidationError("saved configured IWR tilt is out of range") + measurement = PhoneOrientationMeasurement.from_payload(record.get("measurement")) + normalized = dict(record) + normalized["configured_iwr_tilt_deg"] = configured_tilt + normalized["measurement"] = measurement.to_dict() + return normalized + + +__all__ = [ + "PhoneOrientationMeasurement", + "PhoneOrientationValidationError", + "load_phone_orientation_calibration", + "save_phone_orientation_calibration", +] diff --git a/src/openflight/shot_stream.py b/src/openflight/shot_stream.py new file mode 100644 index 000000000..7cd2185c4 --- /dev/null +++ b/src/openflight/shot_stream.py @@ -0,0 +1,180 @@ +"""Fan out completed shots to HTTP clients as Server-Sent Events. + +This is the Wi-Fi sibling of the BLE publisher: same versioned payload, same +bounded-queue delivery policy, same isolation from shot recording. It exists so +a phone can receive shots over the network on hardware where BLE advertising is +unavailable, and so the payload contract can be exercised with nothing but +``curl``. +""" + +from __future__ import annotations + +import logging +import queue +import threading +from typing import Iterator, Mapping + +# The wire payload is the same versioned V1 shot event the BLE transport sends, +# so both transports are validated against one contract and one test fixture. +from .ble.protocol import encode_club_event, encode_shot_event + +logger = logging.getLogger(__name__) + +SSE_MIMETYPE = "text/event-stream" +DEFAULT_HEARTBEAT_INTERVAL_S = 15.0 +DEFAULT_MAX_SUBSCRIBERS = 8 + +# An SSE comment. Clients ignore it, but it keeps idle connections from being +# reaped and lets the server notice a vanished client between shots. +HEARTBEAT_FRAME = ": ping\n\n" + + +class ShotStreamFull(RuntimeError): + """Raised when the broker already serves the maximum number of clients.""" + + +class StreamEvent(bytes): + """Encoded JSON carrying its SSE name while remaining bytes-compatible.""" + + name: str + + def __new__(cls, name: str, payload: bytes): + event = super().__new__(cls, payload) + event.name = name + return event + + +def format_event(event: StreamEvent | bytes) -> str: + """Frame one encoded event for the SSE wire protocol. + + ``encode_shot_event`` emits compact single-line JSON, so the payload never + needs to be split across multiple ``data:`` lines. + """ + name = event.name if isinstance(event, StreamEvent) else "shot" + return f"event: {name}\ndata: {event.decode('utf-8')}\n\n" + + +class ShotStreamBroker: + """Deliver encoded shot events to every connected SSE subscriber.""" + + def __init__( + self, + *, + queue_size: int = 8, + heartbeat_interval_s: float = DEFAULT_HEARTBEAT_INTERVAL_S, + max_subscribers: int = DEFAULT_MAX_SUBSCRIBERS, + ): + if queue_size < 1: + raise ValueError("Shot stream queue size must be at least one") + if heartbeat_interval_s <= 0: + raise ValueError("Shot stream heartbeat interval must be positive") + if max_subscribers < 1: + raise ValueError("Shot stream must allow at least one subscriber") + + self.queue_size = queue_size + self.heartbeat_interval_s = heartbeat_interval_s + self.max_subscribers = max_subscribers + + self._lock = threading.Lock() + self._subscribers: list[queue.Queue[StreamEvent]] = [] + self._latest_payload: bytes | None = None + + @property + def subscriber_count(self) -> int: + """How many clients are currently streaming.""" + with self._lock: + return len(self._subscribers) + + def publish(self, shot_data: Mapping) -> bool: + """Encode a shot and hand it to every subscriber; never raises upward.""" + try: + payload = encode_shot_event(shot_data) + except (KeyError, TypeError, ValueError): + logger.warning("[STREAM] Failed to encode shot payload", exc_info=True) + return False + + with self._lock: + self._latest_payload = payload + subscribers = list(self._subscribers) + event = StreamEvent("shot", payload) + for subscriber in subscribers: + self._offer(subscriber, event) + return True + + def publish_club(self, club: str) -> bool: + """Broadcast an authoritative club change to every Wi-Fi subscriber.""" + try: + event = StreamEvent("club_changed", encode_club_event(club)) + except (TypeError, ValueError): + logger.warning("[STREAM] Failed to encode club payload", exc_info=True) + return False + + with self._lock: + subscribers = list(self._subscribers) + for subscriber in subscribers: + self._offer(subscriber, event) + return True + + def subscribe(self) -> queue.Queue[StreamEvent]: + """Register a subscriber, seeded with the latest shot for replay.""" + with self._lock: + if len(self._subscribers) >= self.max_subscribers: + raise ShotStreamFull(f"Shot stream already has {self.max_subscribers} clients") + subscriber: queue.Queue[StreamEvent] = queue.Queue(maxsize=self.queue_size) + if self._latest_payload is not None: + subscriber.put_nowait(StreamEvent("shot", self._latest_payload)) + self._subscribers.append(subscriber) + count = len(self._subscribers) + logger.info("[STREAM] Client subscribed (%d streaming)", count) + return subscriber + + def unsubscribe(self, subscriber: queue.Queue[StreamEvent]) -> None: + """Drop a subscriber. Unsubscribing twice is not an error.""" + with self._lock: + if subscriber not in self._subscribers: + return + self._subscribers.remove(subscriber) + count = len(self._subscribers) + logger.info("[STREAM] Client unsubscribed (%d streaming)", count) + + def frames(self, subscriber: queue.Queue[StreamEvent]) -> Iterator[str]: + """Yield SSE frames for an already-registered subscriber. + + Registration is deliberately not folded in here: a generator body does + not run until first iteration, so a caller that needs to reject a client + before sending response headers has to call ``subscribe`` itself. + """ + try: + # Open with a heartbeat so the WSGI server flushes response headers + # at once. Without it a client learns nothing -- not even that it + # connected -- until the first shot or heartbeat, because headers + # are not written until the first chunk of the body. + yield HEARTBEAT_FRAME + while True: + try: + event = subscriber.get(timeout=self.heartbeat_interval_s) + except queue.Empty: + yield HEARTBEAT_FRAME + continue + yield format_event(event) + finally: + self.unsubscribe(subscriber) + + def _offer(self, subscriber: queue.Queue[StreamEvent], event: StreamEvent) -> None: + """Queue an event, dropping the oldest unsent event when full.""" + try: + subscriber.put_nowait(event) + return + except queue.Full: + pass + + try: + subscriber.get_nowait() + logger.warning("[STREAM] Delivery queue full; dropped oldest unsent shot") + except queue.Empty: + pass + + try: + subscriber.put_nowait(event) + except queue.Full: + logger.warning("[STREAM] Delivery queue still full; dropped shot") diff --git a/tests/fixtures/shot_v1.json b/tests/fixtures/shot_v1.json new file mode 100644 index 000000000..14493358c --- /dev/null +++ b/tests/fixtures/shot_v1.json @@ -0,0 +1,15 @@ +{ + "ball_speed_mph": 151.4, + "club": "driver", + "club_path_deg": 2.1, + "club_speed_mph": 103.2, + "estimated_carry_yards": 264, + "event_id": "B0D91F0A-7950-4D7E-9DD5-AF9777C190E1", + "launch_angle_horizontal": -1.3, + "launch_angle_vertical": 12.6, + "schema_version": 1, + "smash_factor": 1.47, + "spin_axis_deg": -3.4, + "spin_rpm": 2380, + "timestamp": "2026-07-29T19:42:10.123456" +} diff --git a/tests/test_ble_protocol.py b/tests/test_ble_protocol.py new file mode 100644 index 000000000..4670b6db0 --- /dev/null +++ b/tests/test_ble_protocol.py @@ -0,0 +1,171 @@ +"""Tests for the public OpenFlight BLE shot contract.""" + +import json +from pathlib import Path + +import pytest + +from openflight.ble.protocol import ( + CONTROL_CHARACTERISTIC_UUID, + FRAGMENT_PAYLOAD_SIZE, + FRAME_SIZE, + MAX_MESSAGE_SIZE, + FragmentReassembler, + build_shot_event, + encode_shot_event, + fragment_payload, + parse_fragment, + reassemble_fragments, +) + +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "shot_v1.json" + + +def _shot_data(): + return { + "timestamp": "2026-07-29T19:42:10.123456", + "club": "driver", + "ball_speed_mph": 151.4, + "club_speed_mph": 103.2, + "smash_factor": 1.47, + "estimated_carry_yards": 264, + "launch_angle_vertical": 12.6, + "launch_angle_horizontal": -1.3, + "spin_rpm": 2380, + "club_path_deg": 2.1, + "spin_axis_deg": -3.4, + "spin_candidates": [{"rpm": 2380}], + } + + +def test_build_shot_event_is_stable_and_display_focused(): + expected = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + event = build_shot_event(_shot_data(), event_id=expected["event_id"]) + + assert event == expected + assert "spin_candidates" not in event + + +def test_missing_measurements_are_explicit_nulls(): + shot = _shot_data() + for key in ( + "club_speed_mph", + "smash_factor", + "launch_angle_vertical", + "launch_angle_horizontal", + "spin_rpm", + "club_path_deg", + "spin_axis_deg", + ): + shot.pop(key) + + event = json.loads(encode_shot_event(shot, event_id="shot-null")) + + assert event["club_speed_mph"] is None + assert event["launch_angle_vertical"] is None + assert event["spin_rpm"] is None + assert event["spin_axis_deg"] is None + + +def test_encoding_is_compact_and_deterministic(): + encoded = encode_shot_event(_shot_data(), event_id="shot-1") + + assert b" " not in encoded + assert encoded == encode_shot_event(_shot_data(), event_id="shot-1") + + +def test_non_finite_numbers_are_rejected(): + shot = _shot_data() + shot["ball_speed_mph"] = float("nan") + + with pytest.raises(ValueError, match="Out of range float values"): + encode_shot_event(shot) + + +@pytest.mark.parametrize( + ("payload_size", "expected_frames"), + [ + (1, 1), + (FRAGMENT_PAYLOAD_SIZE, 1), + (FRAGMENT_PAYLOAD_SIZE + 1, 2), + (FRAGMENT_PAYLOAD_SIZE * 3, 3), + ], +) +def test_fragment_boundaries(payload_size, expected_frames): + frames = fragment_payload(b"x" * payload_size, sequence=42) + + assert len(frames) == expected_frames + assert all(len(frame) <= FRAME_SIZE for frame in frames) + assert reassemble_fragments(reversed(frames)) == b"x" * payload_size + + +def test_duplicate_fragment_is_harmless(): + frames = fragment_payload(b"a complete payload", sequence=7) + + assert reassemble_fragments([frames[0], frames[0], *frames[1:]]) == b"a complete payload" + + +def test_incomplete_message_is_rejected(): + frames = fragment_payload(b"x" * (FRAGMENT_PAYLOAD_SIZE + 1), sequence=8) + + with pytest.raises(ValueError, match="incomplete"): + reassemble_fragments(frames[:-1]) + + +def test_mixed_sequences_are_rejected(): + first = fragment_payload(b"first message that has chunks", sequence=1) + second = fragment_payload(b"second message", sequence=2) + + with pytest.raises(ValueError, match="different messages"): + reassemble_fragments([first[0], second[0]]) + + +def test_invalid_frame_metadata_is_rejected(): + frame = bytearray(fragment_payload(b"payload", sequence=3)[0]) + frame[4] = 0 + + with pytest.raises(ValueError, match="metadata"): + parse_fragment(bytes(frame)) + + +def test_oversized_payload_is_rejected(): + with pytest.raises(ValueError, match="maximum"): + fragment_payload(b"x" * (MAX_MESSAGE_SIZE + 1), sequence=0) + + +def test_control_characteristic_uuid_is_distinct_from_shot_notifications(): + from openflight.ble.protocol import SHOT_CHARACTERISTIC_UUID + + assert CONTROL_CHARACTERISTIC_UUID != SHOT_CHARACTERISTIC_UUID + + +def test_incremental_reassembler_completes_control_payload(): + payload = json.dumps( + { + "schema_version": 1, + "type": "iwr6843_orientation_calibration", + "request_id": "request-1", + "payload": {"mount_tilt_deg": 12.25}, + } + ).encode() + reassembler = FragmentReassembler() + + result = None + for frame in fragment_payload(payload, sequence=42): + result = reassembler.append(frame) or result + + assert result == payload + + +def test_incremental_reassembler_recovers_when_a_new_sequence_arrives(): + old_frames = fragment_payload(b"old incomplete payload", sequence=1) + new_payload = b"new complete payload" + new_frames = fragment_payload(new_payload, sequence=2) + reassembler = FragmentReassembler() + + assert reassembler.append(old_frames[0]) is None + result = None + for frame in new_frames: + result = reassembler.append(frame) or result + + assert result == new_payload diff --git a/tests/test_ble_publisher.py b/tests/test_ble_publisher.py new file mode 100644 index 000000000..a03085be2 --- /dev/null +++ b/tests/test_ble_publisher.py @@ -0,0 +1,362 @@ +"""Tests for BLE delivery isolation, buffering, and fragmentation.""" + +import asyncio +import json +import logging +import sys +import types +from enum import IntFlag + +from openflight.ble.protocol import ( + CONTROL_CHARACTERISTIC_UUID, + SHOT_CHARACTERISTIC_UUID, + fragment_payload, + reassemble_fragments, +) +from openflight.ble.publisher import BleShotPublisher + + +def _shot_data(ball_speed=150.0): + return { + "timestamp": "2026-07-29T19:42:10", + "club": "driver", + "ball_speed_mph": ball_speed, + "club_speed_mph": None, + "smash_factor": None, + "estimated_carry_yards": 250, + "launch_angle_vertical": None, + "launch_angle_horizontal": None, + "spin_rpm": None, + "club_path_deg": None, + "spin_axis_deg": None, + } + + +class _Characteristic: + value = bytearray() + + +class _Server: + def __init__(self): + self.characteristic = _Characteristic() + self.notifications = [] + + def get_characteristic(self, uuid): + assert uuid == SHOT_CHARACTERISTIC_UUID + return self.characteristic + + def update_value(self, _service_uuid, _characteristic_uuid): + self.notifications.append(bytes(self.characteristic.value)) + return True + + +class _BlueZApplication: + """Match the subscription hooks exposed by Bless's Linux backend.""" + + def __init__(self): + self.StartNotify = lambda _session: None + self.StopNotify = lambda _session: None + + +class _BlueZServer: + def __init__(self): + self.app = _BlueZApplication() + + +class _ControlCharacteristic: + uuid = CONTROL_CHARACTERISTIC_UUID + value = bytearray() + + +class _ControlServer: + def __init__(self): + self.characteristic = _ControlCharacteristic() + self.notifications = [] + + def get_characteristic(self, uuid): + assert uuid == CONTROL_CHARACTERISTIC_UUID + return self.characteristic + + def update_value(self, service_uuid, characteristic_uuid): + self.notifications.append( + (service_uuid, characteristic_uuid, bytes(self.characteristic.value)) + ) + return True + + +def test_linux_runtime_uses_bless_writeable_permission(monkeypatch): + """Bless 0.3.0 spells the attribute permission ``writeable``.""" + + class Properties(IntFlag): + notify = 1 + write = 2 + + class Permissions(IntFlag): + readable = 1 + writeable = 2 + + class FakeBlessServer: + instance = None + + def __init__(self, **_kwargs): + self.characteristics = [] + self.app = None + FakeBlessServer.instance = self + + async def add_new_service(self, _uuid): + return None + + async def add_new_characteristic( + self, service_uuid, characteristic_uuid, properties, value, permissions + ): + self.characteristics.append( + (service_uuid, characteristic_uuid, properties, value, permissions) + ) + + async def start(self): + return None + + async def stop(self): + return None + + fake_bless = types.ModuleType("bless") + fake_bless.BlessServer = FakeBlessServer + fake_bless.GATTCharacteristicProperties = Properties + fake_bless.GATTAttributePermissions = Permissions + monkeypatch.setitem(sys.modules, "bless", fake_bless) + + async def run(): + publisher = BleShotPublisher() + publisher._stop_requested.set() # pylint: disable=protected-access + await publisher._run() # pylint: disable=protected-access + + asyncio.run(run()) + + control = next( + item + for item in FakeBlessServer.instance.characteristics + if item[1] == CONTROL_CHARACTERISTIC_UUID + ) + assert control[4] == Permissions.readable | Permissions.writeable + + +def test_disconnected_publish_retains_only_latest_payload(): + publisher = BleShotPublisher() + + assert publisher.publish(_shot_data(140.0)) + assert publisher.publish(_shot_data(151.0)) + + latest = json.loads(publisher._latest_payload) # pylint: disable=protected-access + assert latest["ball_speed_mph"] == 151.0 + + +def test_invalid_shot_does_not_raise_into_caller(): + publisher = BleShotPublisher() + + assert publisher.publish({}) is False + + +def test_delivery_uses_bounded_frames_and_round_trips(): + async def run(): + publisher = BleShotPublisher(fragment_interval_s=0) + server = _Server() + publisher._server = server # pylint: disable=protected-access + publisher._subscribed = True # pylint: disable=protected-access + + payload = b'{"schema_version":1,"event_id":"shot-1","ball_speed_mph":150.0}' + await publisher._send_payload(payload) # pylint: disable=protected-access + + assert reassemble_fragments(server.notifications) == payload + assert max(map(len, server.notifications)) <= 20 + + asyncio.run(run()) + + +def test_queue_overflow_drops_oldest_unsent_payload(): + async def run(): + publisher = BleShotPublisher(queue_size=2) + publisher._queue = asyncio.Queue(maxsize=2) # pylint: disable=protected-access + publisher._subscribed = True # pylint: disable=protected-access + + publisher._enqueue_payload(b"first") # pylint: disable=protected-access + publisher._enqueue_payload(b"second") # pylint: disable=protected-access + publisher._enqueue_payload(b"third") # pylint: disable=protected-access + + assert publisher._queue.qsize() == 2 # pylint: disable=protected-access + assert publisher._queue.get_nowait() == b"second" # pylint: disable=protected-access + assert publisher._queue.get_nowait() == b"third" # pylint: disable=protected-access + + asyncio.run(run()) + + +def test_unsubscribe_clears_backlog_but_keeps_latest_payload(): + async def run(): + publisher = BleShotPublisher(queue_size=2) + publisher._queue = asyncio.Queue(maxsize=2) # pylint: disable=protected-access + publisher._subscribed = True # pylint: disable=protected-access + publisher.publish(_shot_data()) + publisher._enqueue_payload(b"queued") # pylint: disable=protected-access + + publisher._on_unsubscribe(None, None) # pylint: disable=protected-access + + assert not publisher.subscribed + assert publisher._queue.empty() # pylint: disable=protected-access + assert publisher._latest_payload is not None # pylint: disable=protected-access + + asyncio.run(run()) + + +def test_bluez_subscription_hooks_update_publisher_state_and_replay_latest(): + """Bless 0.3.0 ignores constructor subscription callbacks on Linux.""" + + async def run(): + publisher = BleShotPublisher() + publisher._queue = asyncio.Queue(maxsize=2) # pylint: disable=protected-access + publisher.publish(_shot_data(151.0)) + server = _BlueZServer() + + publisher._install_bluez_subscription_hooks(server) # pylint: disable=protected-access + server.app.StartNotify(None) + + assert publisher.subscribed + assert publisher._queue.get_nowait() == publisher._latest_payload # pylint: disable=protected-access + + server.app.StopNotify(None) + + assert not publisher.subscribed + + asyncio.run(run()) + + +def test_background_startup_failure_is_isolated(caplog): + class FailingPublisher(BleShotPublisher): + async def _run(self): + raise RuntimeError("no BlueZ adapter") + + publisher = FailingPublisher() + with caplog.at_level(logging.WARNING): + publisher.start() + publisher._thread.join(timeout=2) # pylint: disable=protected-access + + assert not publisher._thread.is_alive() # pylint: disable=protected-access + assert "shot recording will continue without BLE" in caplog.text + + +def test_control_write_reassembles_command_and_notifies_response(): + async def run(): + received = [] + + def handler(command_type, payload): + received.append((command_type, payload)) + return { + "status": "applied", + "persistent": True, + "configured_iwr_tilt_deg": 12.25, + }, 200 + + publisher = BleShotPublisher(command_handler=handler, fragment_interval_s=0) + publisher._loop = asyncio.get_running_loop() # pylint: disable=protected-access + publisher._server = _ControlServer() # pylint: disable=protected-access + publisher._subscribed = True # pylint: disable=protected-access + command = { + "schema_version": 1, + "type": "iwr6843_orientation_calibration", + "request_id": "request-1", + "payload": {"mount_tilt_deg": 12.25}, + } + + for frame in fragment_payload(json.dumps(command).encode(), sequence=7): + publisher._on_write_request( # pylint: disable=protected-access + publisher._server.characteristic, # pylint: disable=protected-access + bytearray(frame), + ) + + for _ in range(100): + if publisher._server.notifications: # pylint: disable=protected-access + break + await asyncio.sleep(0.01) + + assert received == [("iwr6843_orientation_calibration", {"mount_tilt_deg": 12.25})] + updates = publisher._server.notifications # pylint: disable=protected-access + assert {item[1] for item in updates} == {CONTROL_CHARACTERISTIC_UUID} + response = json.loads(reassemble_fragments(item[2] for item in updates)) + assert response == { + "schema_version": 1, + "request_id": "request-1", + "ok": True, + "result": { + "status": "applied", + "persistent": True, + "configured_iwr_tilt_deg": 12.25, + }, + } + + asyncio.run(run()) + + +def test_control_write_dispatches_club_selection_command(): + async def run(): + received = [] + + def handler(command_type, payload): + received.append((command_type, payload)) + return {"status": "applied", "club": payload["club"]}, 200 + + publisher = BleShotPublisher(command_handler=handler, fragment_interval_s=0) + publisher._loop = asyncio.get_running_loop() # pylint: disable=protected-access + publisher._server = _ControlServer() # pylint: disable=protected-access + publisher._subscribed = True # pylint: disable=protected-access + command = { + "schema_version": 1, + "type": "set_club", + "request_id": "club-request-1", + "payload": {"club": "7-iron"}, + } + + for frame in fragment_payload(json.dumps(command).encode(), sequence=8): + publisher._on_write_request( # pylint: disable=protected-access + publisher._server.characteristic, # pylint: disable=protected-access + bytearray(frame), + ) + + for _ in range(100): + if publisher._server.notifications: # pylint: disable=protected-access + break + await asyncio.sleep(0.01) + + assert received == [("set_club", {"club": "7-iron"})] + updates = publisher._server.notifications # pylint: disable=protected-access + response = json.loads(reassemble_fragments(item[2] for item in updates)) + assert response == { + "schema_version": 1, + "request_id": "club-request-1", + "ok": True, + "result": {"status": "applied", "club": "7-iron"}, + } + + asyncio.run(run()) + + +def test_publish_club_notifies_connected_clients_as_unsolicited_state(): + async def run(): + publisher = BleShotPublisher(fragment_interval_s=0) + publisher._loop = asyncio.get_running_loop() # pylint: disable=protected-access + publisher._server = _ControlServer() # pylint: disable=protected-access + publisher._subscribed = True # pylint: disable=protected-access + + assert publisher.publish_club("3-wood") + + for _ in range(100): + if publisher._server.notifications: # pylint: disable=protected-access + break + await asyncio.sleep(0.01) + + updates = publisher._server.notifications # pylint: disable=protected-access + payload = json.loads(reassemble_fragments(item[2] for item in updates)) + assert payload == { + "schema_version": 1, + "type": "club_changed", + "club": "3-wood", + } + + asyncio.run(run()) diff --git a/tests/test_shot_stream.py b/tests/test_shot_stream.py new file mode 100644 index 000000000..8b3570f81 --- /dev/null +++ b/tests/test_shot_stream.py @@ -0,0 +1,203 @@ +"""Tests for Server-Sent Events shot delivery, buffering, and isolation.""" + +import json +import logging +import queue + +import pytest + +from openflight.shot_stream import ( + HEARTBEAT_FRAME, + ShotStreamBroker, + ShotStreamFull, + format_event, +) + + +def _shot_data(ball_speed=150.0): + return { + "timestamp": "2026-07-29T19:42:10", + "club": "driver", + "ball_speed_mph": ball_speed, + "club_speed_mph": None, + "smash_factor": None, + "estimated_carry_yards": 250, + "launch_angle_vertical": None, + "launch_angle_horizontal": None, + "spin_rpm": None, + "club_path_deg": None, + "spin_axis_deg": None, + } + + +def _decode_frame(frame): + lines = frame.split("\n") + assert lines[0] == "event: shot" + assert lines[2] == "" + assert lines[3] == "" + return json.loads(lines[1].removeprefix("data: ")) + + +def test_rejects_nonsense_configuration(): + with pytest.raises(ValueError): + ShotStreamBroker(queue_size=0) + with pytest.raises(ValueError): + ShotStreamBroker(heartbeat_interval_s=0) + with pytest.raises(ValueError): + ShotStreamBroker(max_subscribers=0) + + +def test_event_frame_is_one_data_line_terminated_by_blank_line(): + frame = format_event(b'{"schema_version":1}') + + assert frame == 'event: shot\ndata: {"schema_version":1}\n\n' + assert frame.count("\ndata:") == 1 + + +def test_publish_reaches_every_subscriber(): + broker = ShotStreamBroker() + first = broker.subscribe() + second = broker.subscribe() + + assert broker.publish(_shot_data(151.4)) + + assert _decode_frame(format_event(first.get_nowait()))["ball_speed_mph"] == 151.4 + assert _decode_frame(format_event(second.get_nowait()))["ball_speed_mph"] == 151.4 + assert broker.subscriber_count == 2 + + +def test_club_change_reaches_every_subscriber(): + broker = ShotStreamBroker() + first = broker.subscribe() + second = broker.subscribe() + + assert broker.publish_club("3-wood") + + expected = ( + 'event: club_changed\ndata: {"club":"3-wood","schema_version":1,"type":"club_changed"}\n\n' + ) + assert format_event(first.get_nowait()) == expected + assert format_event(second.get_nowait()) == expected + + +def test_subscriber_replays_latest_shot_on_connect(): + broker = ShotStreamBroker() + broker.publish(_shot_data(140.0)) + broker.publish(_shot_data(151.4)) + + subscriber = broker.subscribe() + + replayed = _decode_frame(format_event(subscriber.get_nowait())) + assert replayed["ball_speed_mph"] == 151.4 + assert subscriber.empty() + + +def test_invalid_shot_does_not_raise_into_caller(caplog): + broker = ShotStreamBroker() + subscriber = broker.subscribe() + + with caplog.at_level(logging.WARNING): + assert broker.publish({}) is False + + assert "Failed to encode shot payload" in caplog.text + assert subscriber.empty() + + +def test_full_queue_drops_oldest_unsent_shot(caplog): + broker = ShotStreamBroker(queue_size=2) + subscriber = broker.subscribe() + + with caplog.at_level(logging.WARNING): + for speed in (100.0, 110.0, 120.0): + broker.publish(_shot_data(speed)) + + assert "dropped oldest unsent shot" in caplog.text + remaining = [ + _decode_frame(format_event(subscriber.get_nowait()))["ball_speed_mph"] for _ in range(2) + ] + assert remaining == [110.0, 120.0] + + +def test_slow_subscriber_does_not_block_a_healthy_one(): + broker = ShotStreamBroker(queue_size=1) + slow = broker.subscribe() + healthy = broker.subscribe() + + broker.publish(_shot_data(100.0)) + healthy.get_nowait() + broker.publish(_shot_data(120.0)) + + assert _decode_frame(format_event(healthy.get_nowait()))["ball_speed_mph"] == 120.0 + assert _decode_frame(format_event(slow.get_nowait()))["ball_speed_mph"] == 120.0 + + +def test_subscriber_limit_is_enforced(): + broker = ShotStreamBroker(max_subscribers=1) + broker.subscribe() + + with pytest.raises(ShotStreamFull): + broker.subscribe() + + +def test_unsubscribe_frees_a_slot_and_tolerates_repeats(): + broker = ShotStreamBroker(max_subscribers=1) + subscriber = broker.subscribe() + + broker.unsubscribe(subscriber) + broker.unsubscribe(subscriber) + + assert broker.subscriber_count == 0 + assert broker.subscribe() is not subscriber + + +def test_stream_opens_with_a_heartbeat_so_response_headers_flush(): + """A WSGI server withholds headers until the first chunk of the body.""" + broker = ShotStreamBroker(heartbeat_interval_s=30.0) + frames = broker.frames(broker.subscribe()) + + assert next(frames) == HEARTBEAT_FRAME + + frames.close() + + +def test_stream_emits_shots_then_heartbeats_while_idle(): + broker = ShotStreamBroker(heartbeat_interval_s=0.01) + broker.publish(_shot_data(151.4)) + frames = broker.frames(broker.subscribe()) + + opening = next(frames) + replayed = next(frames) + idle = next(frames) + + assert opening == HEARTBEAT_FRAME + assert _decode_frame(replayed)["ball_speed_mph"] == 151.4 + assert idle == HEARTBEAT_FRAME + assert broker.subscriber_count == 1 + + frames.close() + assert broker.subscriber_count == 0 + + +def test_stream_unsubscribes_when_client_disconnects(): + broker = ShotStreamBroker(heartbeat_interval_s=0.01) + frames = broker.frames(broker.subscribe()) + next(frames) + + frames.close() + + assert broker.subscriber_count == 0 + broker.publish(_shot_data()) + assert broker.subscriber_count == 0 + + +def test_publish_after_disconnect_still_replays_to_the_next_client(): + broker = ShotStreamBroker(heartbeat_interval_s=0.01) + frames = broker.frames(broker.subscribe()) + frames.close() + + broker.publish(_shot_data(133.0)) + subscriber = broker.subscribe() + + assert _decode_frame(format_event(subscriber.get_nowait()))["ball_speed_mph"] == 133.0 + with pytest.raises(queue.Empty): + subscriber.get_nowait() From 309425f06722ac6bd1f0d84f936db3f45d8f3bd6 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Fri, 25 Sep 2026 10:04:09 -0400 Subject: [PATCH 02/19] build(ble): add optional ble extra and opt-in kiosk sync bless==0.3.0 is a Linux-only optional extra, and [tool.uv] environments limits resolution to Linux and macOS. setup.sh installs the extra on the Pi; start-kiosk.sh syncs it only when --ble is passed, and forwards --ble to the server like any other server flag. Co-authored-by: Jake Fishman Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 16 ++++++++++++++++ scripts/setup/setup.sh | 4 ++-- scripts/start-kiosk.sh | 3 +++ tests/test_project_metadata.py | 10 ++++++++++ tests/test_start_kiosk.py | 17 +++++++++++++++++ 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3fbbc6ff4..28b87fa50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,13 @@ analysis = [ "matplotlib>=3.5.0", "scipy>=1.7.0", ] +# Bluetooth Low Energy GATT server for the optional iOS connection. +ble = [ + # The GATT server runs on the Raspberry Pi. Keeping the marker here also + # prevents universal resolvers from pulling Bless's Windows-only beta + # dependencies into macOS/Linux development environments. + "bless==0.3.0; sys_platform == 'linux'", +] [project.scripts] openflight-server = "openflight.server:main" @@ -107,6 +114,15 @@ ignore = ["E501"] # Line length handled separately # imports as one block, e.g. `from .sim import (..., PlayerState as SimPlayerState)`. combine-as-imports = true +[tool.uv] +# OpenFlight's declared platforms are Linux (the Pi) and macOS (development). +# Excluding Windows also keeps Linux-only Bless resolution from traversing its +# incompatible Windows beta dependency branch. +environments = [ + "sys_platform == 'linux'", + "sys_platform == 'darwin'", +] + [tool.pylint.main] py-version = "3.10" diff --git a/scripts/setup/setup.sh b/scripts/setup/setup.sh index 72de33bd3..8965a3da0 100755 --- a/scripts/setup/setup.sh +++ b/scripts/setup/setup.sh @@ -174,9 +174,9 @@ log "Activated virtual environment" # Install Python dependencies log "Installing Python dependencies..." if command -v uv &> /dev/null; then - uv pip install -e ".[ui,analysis]" + uv pip install -e ".[ui,analysis,ble]" else - pip install -e ".[ui,analysis]" + pip install -e ".[ui,analysis,ble]" fi # Camera dependencies are disabled for the radar-only production path. # If camera support returns, re-enable the optional camera extra in diff --git a/scripts/start-kiosk.sh b/scripts/start-kiosk.sh index d035d2e93..b5fb2fb64 100755 --- a/scripts/start-kiosk.sh +++ b/scripts/start-kiosk.sh @@ -378,6 +378,9 @@ if has_server_arg --camera-capture; then fi UV_SYNC_ARGS+=(--extra camera) fi +if has_server_arg --ble; then + UV_SYNC_ARGS+=(--extra ble) +fi uv sync "${UV_SYNC_ARGS[@]}" || show_startup_failure \ "server" \ "OpenFlight preparation failed" \ diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 5f0ff0ea7..b2d7069d1 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -47,3 +47,13 @@ def test_camera_extra_installs_portable_image_processing_dependency(): assert any(_requirement_name(dep) == "opencv-python-headless" for dep in camera_dependencies) assert not any(_requirement_name(dep) == "picamera2" for dep in camera_dependencies) + + +def test_ble_dependency_is_optional(): + """Non-Pi contributors should not need BlueZ dependencies unless BLE is enabled.""" + metadata = _pyproject() + dependencies = metadata["project"]["dependencies"] + ble_dependencies = metadata["project"]["optional-dependencies"]["ble"] + + assert not any(_requirement_name(dep) == "bless" for dep in dependencies) + assert any(_requirement_name(dep) == "bless" for dep in ble_dependencies) diff --git a/tests/test_start_kiosk.py b/tests/test_start_kiosk.py index 740796105..70b252d40 100644 --- a/tests/test_start_kiosk.py +++ b/tests/test_start_kiosk.py @@ -181,6 +181,23 @@ def test_camera_capture_uses_system_python_for_sync_and_server_start(): assert 'uv run "${UV_RUN_ARGS[@]}" "${SERVER_CMD[@]}" &' in script +def test_ble_flag_is_forwarded_to_server(): + command = _dry_run("--mock", "--ble") + + assert "--mock" in command + assert "--ble" in command + + +def test_ble_extra_is_synced_only_when_ble_is_requested(): + script = _script() + sync_block = script[ + script.index("UV_SYNC_ARGS=(--quiet)") : script.index("\nconfigure_kld7_latency\n") + ] + + assert "if has_server_arg --ble; then\n UV_SYNC_ARGS+=(--extra ble)\nfi" in sync_block + assert "--extra ble" not in script.replace(sync_block, "") + + def test_startup_applies_kld7_latency_setup_before_server_start(): script = _script() From ac7a9b1d01b9222da1be053f387097a9b5440182 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Fri, 25 Sep 2026 10:04:09 -0400 Subject: [PATCH 03/19] feat(server): publish final shots and club changes over BLE and SSE Adds --ble, /api/shots/stream, /api/club and the IWR6843 phone orientation route. Only finalized shots are published; a failed Socket.IO emit no longer stops BLE, SSE or sim delivery. All club changes share one path and broadcast club_changed, even with no monitor. --ble without Bless says Bluetooth is unavailable. Co-authored-by: Jake Fishman Co-Authored-By: Claude Opus 5.5 --- src/openflight/server.py | 310 ++++++++++++++++++-- tests/test_control_commands.py | 99 +++++++ tests/test_phone_orientation_calibration.py | 191 ++++++++++++ tests/test_phone_transport_server.py | 104 +++++++ tests/test_server.py | 161 ++++++++++ 5 files changed, 848 insertions(+), 17 deletions(-) create mode 100644 tests/test_control_commands.py create mode 100644 tests/test_phone_orientation_calibration.py create mode 100644 tests/test_phone_transport_server.py diff --git a/src/openflight/server.py b/src/openflight/server.py index 96fe07ecb..f580b1233 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -39,10 +39,17 @@ SpeedReading, set_show_raw_readings, ) +from .phone_orientation import ( + PhoneOrientationMeasurement, + PhoneOrientationValidationError, + load_phone_orientation_calibration, + save_phone_orientation_calibration, +) from .power import SUPPORTED_BATTERY_PROVIDERS, PowerMonitor, PowerStatus from .profiles import ProfileStore from .rolling_buffer.monitor import estimate_carry_with_spin, get_optimal_spin_for_ball_speed from .session_logger import get_session_logger, init_session_logger, log_session_error +from .shot_stream import SSE_MIMETYPE, ShotStreamBroker, ShotStreamFull from .sim import ( IncompleteShotError, PlayerState as SimPlayerState, @@ -135,6 +142,10 @@ def get_profile_store() -> ProfileStore: camera_replay_manager = None camera_reference_ball_tracker = None camera_ball_flight_reference_tracker = None +PHONE_ORIENTATION_CALIBRATION_PATH = ( + Path.home() / ".config" / "openflight" / "iwr6843_phone_orientation.json" +) +_iwr6843_calibration_lock = threading.Lock() # Optional LIS3DH enclosure orientation used to compensate TI mount tilt. inclinometer_service = None @@ -157,6 +168,20 @@ def get_profile_store() -> ProfileStore: # ShotNumber field and every shot comes back 501 "Bad format". sim_player_state = SimPlayerState(shot_counter=initial_shot_counter()) +# Optional Bluetooth Low Energy publisher for the iOS app. +ble_publisher = None + +# One Pi-owned selection is shared by the browser UI and every phone/tablet. +# Monitors start on driver, and successful changes update this value atomically +# before being fanned out over all enabled transports. +active_club = ClubType.DRIVER +club_selection_lock = threading.Lock() + +# Wi-Fi shot delivery for the iOS app. Always available: it exposes the same +# shots the browser UI already broadcasts over WebSocket, so it adds no reach +# beyond the existing HTTP server. +shot_stream = ShotStreamBroker() + shutdown_lock = threading.Lock() shutdown_cleanup_started = False # One active hardware job plus two waiting shots is enough for normal golf @@ -420,6 +445,8 @@ def _cleanup_hardware_for_shutdown() -> bool: _run_shutdown_step("battery monitor stop", power_monitor.stop) if camera_capture_runtime: _run_shutdown_step("camera capture stop", camera_capture_runtime.stop) + if ble_publisher: + _run_shutdown_step("BLE publisher stop", ble_publisher.stop) _run_shutdown_step("launch monitor stop", stop_monitor) @@ -957,6 +984,187 @@ def display(): return send_from_directory(_react_app_dir(), "index.html") +@app.route("/api/calibration/iwr6843/orientation", methods=["GET", "POST"]) +def api_iwr6843_orientation_calibration(): + """Read or apply a gravity-referenced phone measurement to TI mount tilt.""" + if iwr6843_runtime is None: + return {"error": "TI IWR6843 radar is not enabled"}, 409 + + if request.method == "GET": + return { + "status": "ready", + "configured_iwr_tilt_deg": round(math.degrees(iwr6843_runtime.calibration.tilt_rad), 4), + "azimuth_offset_deg": round(iwr6843_runtime.azimuth_offset_deg, 4), + "calibration": iwr6843_runtime_config.get("phone_orientation_calibration"), + } + + return apply_iwr6843_orientation_calibration(request.get_json(silent=True)) + + +def apply_iwr6843_orientation_calibration(payload): + """Validate, persist, and activate one phone orientation measurement.""" + if iwr6843_runtime is None: + return {"error": "TI IWR6843 radar is not enabled"}, 409 + + try: + measurement = PhoneOrientationMeasurement.from_payload(payload) + except PhoneOrientationValidationError as error: + return {"error": str(error)}, 400 + + enclosure_pitch_deg = None + if inclinometer_service is not None: + try: + selection = inclinometer_service.wait_for_stable(timeout_s=2.0) + except Exception as error: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Enclosure sensor failed during phone calibration: %s", error) + return {"error": "Could not read the enclosure sensor; try again"}, 409 + if selection.snapshot is None: + return { + "error": ( + "The enclosure sensor is not stable " + f"({selection.status}); keep the rig still and try again" + ) + }, 409 + enclosure_pitch_deg = float(selection.snapshot.calibrated_pitch_deg) + + configured_tilt_deg = measurement.mount_tilt_deg - (enclosure_pitch_deg or 0.0) + if not -45.0 <= configured_tilt_deg <= 45.0: + return {"error": "Derived TI-to-enclosure tilt is outside the supported range"}, 400 + + record = { + "schema_version": 1, + "source": "ios_companion", + "configured_iwr_tilt_deg": configured_tilt_deg, + "enclosure_pitch_deg": enclosure_pitch_deg, + "azimuth_offset_deg": iwr6843_runtime.azimuth_offset_deg, + "measurement": measurement.to_dict(), + "applied_at": datetime.now().astimezone().isoformat(), + } + + try: + with _iwr6843_calibration_lock: + save_phone_orientation_calibration(record, PHONE_ORIENTATION_CALIBRATION_PATH) + calibration_meta = dict(iwr6843_runtime.calibration.meta) + calibration_meta["phone_orientation_calibration"] = record + iwr6843_runtime.calibration = replace( + iwr6843_runtime.calibration, + tilt_rad=math.radians(configured_tilt_deg), + meta=calibration_meta, + ) + iwr6843_runtime_config.update( + { + "tilt_deg": configured_tilt_deg, + "tilt_source": "ios_companion", + "phone_orientation_calibration": record, + } + ) + except OSError as error: + logger.warning("[SERVER] Failed to persist phone orientation: %s", error, exc_info=True) + return {"error": "OpenFlight could not save the calibration"}, 500 + + session_logger = get_session_logger() + if session_logger: + session_logger.log_config_change( + {"iwr6843": dict(iwr6843_runtime_config)}, + source="ios_companion", + ) + response = { + "status": "applied", + "persistent": True, + "measured_mount_tilt_deg": measurement.mount_tilt_deg, + "enclosure_pitch_deg": enclosure_pitch_deg, + "configured_iwr_tilt_deg": configured_tilt_deg, + "roll_deg": measurement.roll_deg, + "azimuth_offset_deg": iwr6843_runtime.azimuth_offset_deg, + } + socketio.emit("iwr6843_orientation_calibrated", response) + logger.info( + "[SERVER] Applied iOS phone calibration: measured tilt %.3fdeg, " + "enclosure pitch %s, configured TI tilt %.3fdeg", + measurement.mount_tilt_deg, + f"{enclosure_pitch_deg:.3f}deg" if enclosure_pitch_deg is not None else "not enabled", + configured_tilt_deg, + ) + return response, 200 + + +def apply_club_selection(payload): + """Set the club used to tag and process future shots.""" + global active_club # pylint: disable=global-statement + if not isinstance(payload, dict): + return {"error": "Club selection must be a JSON object"}, 400 + club_name = payload.get("club") + try: + club = ClubType(club_name) + except (TypeError, ValueError): + valid = ", ".join(item.value for item in ClubType if item is not ClubType.UNKNOWN) + return {"error": f"Unknown club; choose one of: {valid}"}, 400 + if club is ClubType.UNKNOWN: + return {"error": "Unknown is not a selectable club"}, 400 + + with club_selection_lock: + # Without a monitor (startup, or tests) the selection is still recorded + # and broadcast, as the Socket.IO handler always did; the monitor picks + # up later changes once it exists. + if monitor is not None: + try: + monitor.set_club(club) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("[SERVER] Failed to set club to %s", club.value) + return {"error": "OpenFlight could not change the club"}, 500 + active_club = club + response = {"status": "applied", "club": club.value} + _broadcast_club_selection(club) + logger.info("[SERVER] Club changed to %s", club.value) + return response, 200 + + +def current_club_selection(_payload=None): + """Return the Pi-owned club without changing monitor state.""" + with club_selection_lock: + club_value = active_club.value + return {"status": "current", "club": club_value}, 200 + + +def _broadcast_club_selection(club: ClubType) -> None: + """Fan one authoritative club update out over every active transport.""" + club_data = {"club": club.value} + try: + socketio.emit("club_changed", club_data) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to broadcast club over WebSocket", exc_info=True) + try: + shot_stream.publish_club(club.value) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to broadcast club over Wi-Fi stream", exc_info=True) + if ble_publisher is not None: + try: + ble_publisher.publish_club(club.value) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to broadcast club over BLE", exc_info=True) + + +def dispatch_phone_control_command(command_type, payload): + """Route a versioned BLE phone command to the shared server operation.""" + handlers = { + "iwr6843_orientation_calibration": apply_iwr6843_orientation_calibration, + "set_club": apply_club_selection, + "get_club": current_club_selection, + } + handler = handlers.get(command_type) + if handler is None: + return {"error": f"Unsupported phone command: {command_type}"}, 400 + return handler(payload) + + +@app.route("/api/club", methods=["GET", "POST"]) +def api_club_selection(): + """Read or set the active club over Wi-Fi.""" + if request.method == "GET": + return current_club_selection() + return apply_club_selection(request.get_json(silent=True)) + + @app.route("/") def static_files(path): """Serve static files.""" @@ -1078,6 +1286,24 @@ def init_camera_capture( return False +def _resolve_iwr_mount_tilt( + calibration_tilt_deg: float, + *, + explicit_tilt_deg: float | None, +) -> tuple[float, str]: + """Resolve TI tilt with explicit CLI values taking highest precedence.""" + if explicit_tilt_deg is not None: + return float(explicit_tilt_deg), "command_line" + try: + saved = load_phone_orientation_calibration(PHONE_ORIENTATION_CALIBRATION_PATH) + except (OSError, json.JSONDecodeError, PhoneOrientationValidationError) as error: + logger.warning("[SERVER] Ignoring invalid saved phone calibration: %s", error) + return float(calibration_tilt_deg), "calibration_file" + if saved is not None: + return float(saved["configured_iwr_tilt_deg"]), "ios_companion" + return float(calibration_tilt_deg), "calibration_file" + + def init_iwr6843( *, port: str | None, @@ -1114,8 +1340,11 @@ def init_iwr6843( calibration = Calibration.load(calibration_path) calibration.tee_range_m = tee_range_m calibration.tee_ball_height_m = ball_height_m - if tilt_deg is not None: - calibration.tilt_rad = math.radians(tilt_deg) + resolved_tilt_deg, tilt_source = _resolve_iwr_mount_tilt( + math.degrees(calibration.tilt_rad), + explicit_tilt_deg=tilt_deg, + ) + calibration.tilt_rad = math.radians(resolved_tilt_deg) if radar_height_m is not None: calibration.meta["radar_height_m"] = radar_height_m @@ -1159,6 +1388,7 @@ def init_iwr6843( "tx_order": resolved_order, "tdm_sign_policy": iwr6843_runtime.tdm_sign_policy, "tilt_deg": math.degrees(calibration.tilt_rad), + "tilt_source": tilt_source, "radar_height_m": calibration.radar_height_m, "ball_height_m": calibration.tee_ball_height_m, "azimuth_offset_deg": azimuth_offset_deg, @@ -1471,6 +1701,24 @@ def handle_get_camera_capture_settings(): socketio.emit("camera_capture_settings", _camera_capture_settings_payload()) +@app.route("/api/shots/stream") +def shots_stream(): + """Stream completed shots to the iOS app as Server-Sent Events.""" + try: + subscriber = shot_stream.subscribe() + except ShotStreamFull as exc: + logger.warning("[SERVER] Refused shot stream client: %s", exc) + return str(exc), 503 + + response = Response(shot_stream.frames(subscriber), mimetype=SSE_MIMETYPE) + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + # Covers the case where the response is discarded without ever being + # iterated; unsubscribing twice is a no-op. + response.call_on_close(lambda: shot_stream.unsubscribe(subscriber)) + return response + + @socketio.on("set_camera_capture_settings") def handle_set_camera_capture_settings(data): """Apply live-safe camera controls and alignment-guide position.""" @@ -1782,6 +2030,7 @@ def handle_connect(): _emit_profiles() if power_monitor and power_monitor.status: socketio.emit("power_status", power_monitor.status.to_dict()) + socketio.emit("club_changed", {"club": active_club.value}) if monitor: socketio.emit("session_state", _session_state_payload(include_runtime_meta=True)) socketio.emit("trigger_status", _get_trigger_status()) @@ -1802,14 +2051,7 @@ def handle_get_trigger_status(): @socketio.on("set_club") def handle_set_club(data): """Handle club selection change.""" - club_name = data.get("club", "driver") - try: - club = ClubType(club_name) - if monitor: - monitor.set_club(club) - socketio.emit("club_changed", {"club": club.value}) - except ValueError: - pass + apply_club_selection(data) def _payload_dict(data) -> dict: @@ -2223,6 +2465,7 @@ def _sim_on_status(target: str, event) -> None: def _sim_on_inbound(target: str, event) -> None: """Apply an inbound simulator event (player/club update, error, ack).""" + global active_club # pylint: disable=global-statement if isinstance(event, PlayerUpdate): sim_player_state.apply(event) club_value = sim_player_state.club.value @@ -2236,12 +2479,17 @@ def _sim_on_inbound(target: str, event) -> None: sl.log_sim_player(target=target, handed=sim_player_state.handed, club=club_value) # The monitor owns current-club state for shot tagging and carry/spin # model selection; keep it in sync with the sim's canonical club. - if monitor is not None: - try: - monitor.set_club(sim_player_state.club) - except Exception: # pylint: disable=broad-except - logger.exception("[sim] monitor.set_club failed") - socketio.emit("club_changed", {"club": club_value}) + with club_selection_lock: + monitor_updated = True + if monitor is not None: + try: + monitor.set_club(sim_player_state.club) + except Exception: # pylint: disable=broad-except + logger.exception("[sim] monitor.set_club failed") + monitor_updated = False + if monitor_updated: + active_club = sim_player_state.club + _broadcast_club_selection(active_club) elif isinstance(event, SimError): logger.warning("[sim] ← %s error: %s", target, event.message) socketio.emit("sim_status", {"target": target, "state": "error", "message": event.message}) @@ -3254,6 +3502,7 @@ def _finalize_shot_detected( ) # Emit shot with launch angle data included + shot_data = None try: shot_data = shot_to_dict(shot) stats = monitor.get_session_stats() if monitor else {} @@ -3277,7 +3526,21 @@ def _finalize_shot_detected( context={"stage": f"emit_{emit_event}", "ball_speed_mph": shot.ball_speed_mph}, exc=e, ) - return + + # Bluetooth transport is deliberately independent of WebSocket delivery. + if shot_data is not None and ble_publisher is not None: + try: + ble_publisher.publish(shot_data) + except Exception as e: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to queue BLE shot: %s", e, exc_info=True) + + # Wi-Fi transport is likewise independent; a stalled client cannot affect + # shot recording or the browser UI. + if shot_data is not None: + try: + shot_stream.publish(shot_data) + except Exception as e: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to queue streamed shot: %s", e, exc_info=True) # Forward to simulator connectors (optional) _forward_shot_to_simulators(shot) @@ -4377,6 +4640,11 @@ def main(): "Off by default.", ) _add_ballistics_arguments(parser) + parser.add_argument( + "--ble", + action="store_true", + help="Advertise completed shots over Bluetooth LE for the OpenFlight iOS app", + ) parser.add_argument( "--trigger", choices=["sound", "speed"], @@ -4976,6 +5244,14 @@ def main(): print(f"Battery monitoring: ENABLED ({battery_provider})") startup_status.ready("battery", "Power monitor ready") + global ble_publisher # pylint: disable=global-statement + if args.ble: + from .ble import BleShotPublisher # pylint: disable=import-outside-toplevel + + ble_publisher = BleShotPublisher(command_handler=dispatch_phone_control_command) + ble_publisher.start() + print("Bluetooth LE enabled (advertising as OpenFlight)") + # Simulator connectors (off unless --sim). Started after the monitor exists # so inbound club updates can call monitor.set_club(). global sim_connectors # pylint: disable=global-statement diff --git a/tests/test_control_commands.py b/tests/test_control_commands.py new file mode 100644 index 000000000..9adc42d04 --- /dev/null +++ b/tests/test_control_commands.py @@ -0,0 +1,99 @@ +"""Tests for transport-independent phone control commands.""" + +from openflight import server as server_module +from openflight.launch_monitor import ClubType + + +class _Monitor: + def __init__(self): + self.clubs = [] + + def set_club(self, club): + self.clubs.append(club) + + +class _ClubPublisher: + def __init__(self): + self.clubs = [] + + def publish_club(self, club): + self.clubs.append(club) + return True + + +def test_apply_club_selection_updates_monitor_and_broadcasts(monkeypatch): + monitor = _Monitor() + stream = _ClubPublisher() + ble = _ClubPublisher() + emitted = [] + monkeypatch.setattr(server_module, "monitor", monitor) + monkeypatch.setattr(server_module, "shot_stream", stream) + monkeypatch.setattr(server_module, "ble_publisher", ble) + monkeypatch.setattr(server_module, "active_club", ClubType.DRIVER) + monkeypatch.setattr( + server_module.socketio, "emit", lambda event, data: emitted.append((event, data)) + ) + + response, status = server_module.apply_club_selection({"club": "7-iron"}) + + assert status == 200 + assert response == {"status": "applied", "club": "7-iron"} + assert monitor.clubs == [ClubType.IRON_7] + assert emitted == [("club_changed", {"club": "7-iron"})] + assert server_module.active_club is ClubType.IRON_7 + assert stream.clubs == ["7-iron"] + assert ble.clubs == ["7-iron"] + + +def test_apply_club_selection_rejects_unknown_club(monkeypatch): + monitor = _Monitor() + monkeypatch.setattr(server_module, "monitor", monitor) + + response, status = server_module.apply_club_selection({"club": "putter"}) + + assert status == 400 + assert "Unknown club" in response["error"] + assert monitor.clubs == [] + + +def test_wifi_club_endpoint_uses_shared_selection_logic(monkeypatch): + monitor = _Monitor() + monkeypatch.setattr(server_module, "monitor", monitor) + + response = server_module.app.test_client().post( + "/api/club", + json={"club": "pw"}, + ) + + assert response.status_code == 200 + assert response.get_json() == {"status": "applied", "club": "pw"} + assert monitor.clubs == [ClubType.PW] + + +def test_wifi_club_endpoint_returns_authoritative_selection(monkeypatch): + monkeypatch.setattr(server_module, "active_club", ClubType.WOOD_3) + + response = server_module.app.test_client().get("/api/club") + + assert response.status_code == 200 + assert response.get_json() == {"status": "current", "club": "3-wood"} + + +def test_control_dispatch_routes_club_command(monkeypatch): + monitor = _Monitor() + monkeypatch.setattr(server_module, "monitor", monitor) + + response, status = server_module.dispatch_phone_control_command("set_club", {"club": "3-wood"}) + + assert status == 200 + assert response["club"] == "3-wood" + assert monitor.clubs == [ClubType.WOOD_3] + + +def test_control_dispatch_returns_authoritative_club(monkeypatch): + monkeypatch.setattr(server_module, "active_club", ClubType.WOOD_5) + + response, status = server_module.dispatch_phone_control_command("get_club", {}) + + assert status == 200 + assert response == {"status": "current", "club": "5-wood"} diff --git a/tests/test_phone_orientation_calibration.py b/tests/test_phone_orientation_calibration.py new file mode 100644 index 000000000..b3b82c215 --- /dev/null +++ b/tests/test_phone_orientation_calibration.py @@ -0,0 +1,191 @@ +"""Phone-assisted IWR6843 mount-orientation calibration.""" + +import json +import math +from types import SimpleNamespace + +import pytest + +from openflight import server +from openflight.iwr6843.calibration import Calibration +from openflight.phone_orientation import ( + PhoneOrientationMeasurement, + PhoneOrientationValidationError, + load_phone_orientation_calibration, +) + + +def _payload(*, tilt_deg: float = 12.0, roll_deg: float = 0.0) -> dict: + tilt_rad = math.radians(tilt_deg) + roll_rad = math.radians(roll_deg) + horizontal_gravity = math.cos(tilt_rad) + return { + "schema_version": 1, + "mount_tilt_deg": tilt_deg, + "roll_deg": roll_deg, + "gravity_x_g": math.sin(roll_rad) * horizontal_gravity, + "gravity_y_g": -math.cos(roll_rad) * horizontal_gravity, + "gravity_z_g": -math.sin(tilt_rad), + "tilt_stddev_deg": 0.08, + "roll_stddev_deg": 0.07, + "sample_count": 120, + "measured_at": "2026-08-07T15:30:00Z", + "device_model": "iPhone", + } + + +def _runtime(*, tilt_deg: float = 10.4, azimuth_offset_deg: float = 1.5): + calibration = Calibration.identity() + calibration.tilt_rad = math.radians(tilt_deg) + return SimpleNamespace( + calibration=calibration, + azimuth_offset_deg=azimuth_offset_deg, + ) + + +def test_measurement_recomputes_orientation_from_gravity(): + measurement = PhoneOrientationMeasurement.from_payload(_payload(tilt_deg=12.25, roll_deg=-1.5)) + + assert measurement.mount_tilt_deg == pytest.approx(12.25) + assert measurement.roll_deg == pytest.approx(-1.5) + assert measurement.sample_count == 120 + + +@pytest.mark.parametrize( + ("change", "message"), + [ + ({"sample_count": 10}, "at least 30"), + ({"tilt_stddev_deg": 0.8}, "stable"), + (_payload(roll_deg=4.0), "level the radar left-to-right"), + ({"mount_tilt_deg": 20.0}, "gravity vector"), + ], +) +def test_measurement_rejects_untrustworthy_samples(change, message): + payload = _payload() + payload.update(change) + + with pytest.raises(PhoneOrientationValidationError, match=message): + PhoneOrientationMeasurement.from_payload(payload) + + +def test_phone_calibration_requires_an_enabled_ti_radar(monkeypatch, tmp_path): + monkeypatch.setattr(server, "iwr6843_runtime", None) + monkeypatch.setattr(server, "PHONE_ORIENTATION_CALIBRATION_PATH", tmp_path / "orientation.json") + + response = server.app.test_client().post( + "/api/calibration/iwr6843/orientation", + json=_payload(), + ) + + assert response.status_code == 409 + assert "not enabled" in response.get_json()["error"] + assert not (tmp_path / "orientation.json").exists() + + +def test_phone_calibration_applies_persists_and_logs_tilt(monkeypatch, tmp_path): + runtime = _runtime() + original_calibration = runtime.calibration + config = {"enabled": True, "azimuth_offset_deg": runtime.azimuth_offset_deg} + changes = [] + session = SimpleNamespace( + log_config_change=lambda value, source: changes.append((value, source)) + ) + path = tmp_path / "orientation.json" + monkeypatch.setattr(server, "iwr6843_runtime", runtime) + monkeypatch.setattr(server, "iwr6843_runtime_config", config) + monkeypatch.setattr(server, "inclinometer_service", None) + monkeypatch.setattr(server, "PHONE_ORIENTATION_CALIBRATION_PATH", path) + monkeypatch.setattr(server, "get_session_logger", lambda: session) + + response = server.app.test_client().post( + "/api/calibration/iwr6843/orientation", + json=_payload(tilt_deg=12.25), + ) + + assert response.status_code == 200 + body = response.get_json() + assert body["status"] == "applied" + assert body["configured_iwr_tilt_deg"] == pytest.approx(12.25) + assert body["azimuth_offset_deg"] == 1.5 + assert body["persistent"] is True + assert runtime.calibration is not original_calibration + assert math.degrees(runtime.calibration.tilt_rad) == pytest.approx(12.25) + assert math.degrees(original_calibration.tilt_rad) == pytest.approx(10.4) + assert runtime.azimuth_offset_deg == 1.5 + assert config["tilt_deg"] == pytest.approx(12.25) + assert config["tilt_source"] == "ios_companion" + assert changes[-1][1] == "ios_companion" + + persisted = load_phone_orientation_calibration(path) + assert persisted["configured_iwr_tilt_deg"] == pytest.approx(12.25) + assert persisted["measurement"]["device_model"] == "iPhone" + + +def test_phone_calibration_subtracts_live_enclosure_pitch(monkeypatch, tmp_path): + runtime = _runtime() + snapshot = SimpleNamespace(calibrated_pitch_deg=1.75) + selection = SimpleNamespace(snapshot=snapshot, status="stable") + sensor = SimpleNamespace(wait_for_stable=lambda timeout_s: selection) + monkeypatch.setattr(server, "iwr6843_runtime", runtime) + monkeypatch.setattr(server, "iwr6843_runtime_config", {"enabled": True}) + monkeypatch.setattr(server, "inclinometer_service", sensor) + monkeypatch.setattr(server, "PHONE_ORIENTATION_CALIBRATION_PATH", tmp_path / "orientation.json") + monkeypatch.setattr(server, "get_session_logger", lambda: None) + + response = server.app.test_client().post( + "/api/calibration/iwr6843/orientation", + json=_payload(tilt_deg=12.25), + ) + + assert response.status_code == 200 + body = response.get_json() + assert body["measured_mount_tilt_deg"] == pytest.approx(12.25) + assert body["enclosure_pitch_deg"] == pytest.approx(1.75) + assert body["configured_iwr_tilt_deg"] == pytest.approx(10.5) + assert math.degrees(runtime.calibration.tilt_rad) == pytest.approx(10.5) + + +def test_phone_calibration_fails_safe_when_enclosure_sensor_is_unstable(monkeypatch, tmp_path): + runtime = _runtime() + original_tilt = runtime.calibration.tilt_rad + selection = SimpleNamespace(snapshot=None, status="moving") + sensor = SimpleNamespace(wait_for_stable=lambda timeout_s: selection) + path = tmp_path / "orientation.json" + monkeypatch.setattr(server, "iwr6843_runtime", runtime) + monkeypatch.setattr(server, "iwr6843_runtime_config", {"enabled": True}) + monkeypatch.setattr(server, "inclinometer_service", sensor) + monkeypatch.setattr(server, "PHONE_ORIENTATION_CALIBRATION_PATH", path) + + response = server.app.test_client().post( + "/api/calibration/iwr6843/orientation", + json=_payload(), + ) + + assert response.status_code == 409 + assert "enclosure sensor" in response.get_json()["error"] + assert runtime.calibration.tilt_rad == original_tilt + assert not path.exists() + + +def test_saved_phone_tilt_is_used_on_restart_unless_cli_overrides(monkeypatch, tmp_path): + path = tmp_path / "orientation.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "configured_iwr_tilt_deg": 11.75, + "measurement": _payload(tilt_deg=12.25), + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(server, "PHONE_ORIENTATION_CALIBRATION_PATH", path) + + assert server._resolve_iwr_mount_tilt(10.4, explicit_tilt_deg=None) == ( + pytest.approx(11.75), + "ios_companion", + ) + assert server._resolve_iwr_mount_tilt(10.4, explicit_tilt_deg=8.0) == ( + pytest.approx(8.0), + "command_line", + ) diff --git a/tests/test_phone_transport_server.py b/tests/test_phone_transport_server.py new file mode 100644 index 000000000..63cda1483 --- /dev/null +++ b/tests/test_phone_transport_server.py @@ -0,0 +1,104 @@ +"""Server-level behaviour of the phone transports (Socket.IO, SSE and BLE).""" + +import logging +import sys + +import pytest + +from openflight import server as server_module +from openflight.ble import BleShotPublisher +from openflight.launch_monitor import ClubType + + +class _ClubPublisher: + def __init__(self): + self.clubs = [] + + def publish_club(self, club): + self.clubs.append(club) + return True + + +@pytest.fixture +def no_monitor_transports(monkeypatch): + """No launch monitor, with every club transport captured.""" + stream = _ClubPublisher() + ble = _ClubPublisher() + emitted = [] + monkeypatch.setattr(server_module, "monitor", None) + monkeypatch.setattr(server_module, "shot_stream", stream) + monkeypatch.setattr(server_module, "ble_publisher", ble) + monkeypatch.setattr(server_module, "active_club", ClubType.DRIVER) + monkeypatch.setattr( + server_module.socketio, + "emit", + lambda event, data, **_kwargs: emitted.append((event, data)), + ) + return stream, ble, emitted + + +def test_socket_set_club_without_monitor_still_broadcasts(no_monitor_transports): + """Before the monitor exists, a club change is recorded and broadcast, as before.""" + stream, ble, emitted = no_monitor_transports + + server_module.handle_set_club({"club": "7-iron"}) + + assert server_module.active_club is ClubType.IRON_7 + assert emitted == [("club_changed", {"club": "7-iron"})] + assert stream.clubs == ["7-iron"] + assert ble.clubs == ["7-iron"] + + +def test_wifi_club_post_without_monitor_applies_and_broadcasts(no_monitor_transports): + stream, ble, emitted = no_monitor_transports + + response = server_module.app.test_client().post("/api/club", json={"club": "pw"}) + + assert response.status_code == 200 + assert response.get_json() == {"status": "applied", "club": "pw"} + assert server_module.active_club is ClubType.PW + assert emitted == [("club_changed", {"club": "pw"})] + assert stream.clubs == ["pw"] + assert ble.clubs == ["pw"] + + +@pytest.mark.parametrize("payload", [{"club": "putter"}, {"club": "unknown"}, {}, None, "pw"]) +def test_wifi_club_post_rejects_invalid_selection(no_monitor_transports, payload): + stream, ble, emitted = no_monitor_transports + + response = server_module.app.test_client().post("/api/club", json=payload) + + assert response.status_code == 400 + assert "error" in response.get_json() + assert server_module.active_club is ClubType.DRIVER + assert emitted == [] + assert stream.clubs == [] + assert ble.clubs == [] + + +@pytest.mark.parametrize("payload", [{"club": "putter"}, {"club": "unknown"}, None]) +def test_socket_set_club_ignores_invalid_selection(no_monitor_transports, payload): + stream, ble, emitted = no_monitor_transports + + server_module.handle_set_club(payload) + + assert server_module.active_club is ClubType.DRIVER + assert emitted == [] + assert stream.clubs == [] + assert ble.clubs == [] + + +def test_ble_without_bless_logs_unavailable_and_continues(monkeypatch, caplog): + """`--ble` on macOS or without the `ble` extra must not take the server down.""" + # A None entry makes `from bless import ...` raise ImportError. + monkeypatch.setitem(sys.modules, "bless", None) + publisher = BleShotPublisher() + + with caplog.at_level(logging.WARNING): + publisher.start() + publisher._thread.join(timeout=2) # pylint: disable=protected-access + + assert not publisher._thread.is_alive() # pylint: disable=protected-access + assert "Bluetooth unavailable" in caplog.text + assert publisher.publish_club("driver") is True + publisher.stop() diff --git a/tests/test_server.py b/tests/test_server.py index 4772ae051..fad8f4de3 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -29,6 +29,7 @@ swing_speed_to_dict, swing_speed_to_shot_dict, ) +from openflight.shot_stream import HEARTBEAT_FRAME, SSE_MIMETYPE, ShotStreamBroker from openflight.swing_speed import SwingSpeedEvent @@ -513,6 +514,11 @@ def test_init_iwr6843_has_no_host_freeze_delay(self, monkeypatch, tmp_path): """Production capture must always request the firmware-frozen boundary ring immediately.""" captured = {} calibration = Calibration.identity() + monkeypatch.setattr( + server_module, + "PHONE_ORIENTATION_CALIBRATION_PATH", + tmp_path / "missing-phone-orientation.json", + ) class FakeCaptureMonitor: def __init__(self, **kwargs): @@ -561,6 +567,11 @@ def test_init_iwr6843_wires_horizontal_calibration_into_runtime(self, monkeypatc club path relative to boresight instead of the target line. """ calibration = Calibration.identity() + monkeypatch.setattr( + server_module, + "PHONE_ORIENTATION_CALIBRATION_PATH", + tmp_path / "missing-phone-orientation.json", + ) class FakeCaptureMonitor: def __init__(self, **kwargs): @@ -1236,6 +1247,64 @@ def test_display_route_falls_back_when_dist_missing(self, monkeypatch, tmp_path) assert b'
' in response.data +class TestShotStreamRoute: + """Tests for the Server-Sent Events shot endpoint used by the iOS app.""" + + @staticmethod + def _shot_data(ball_speed=151.4): + return { + "timestamp": "2026-07-29T19:42:10", + "club": "driver", + "ball_speed_mph": ball_speed, + "club_speed_mph": None, + "smash_factor": None, + "estimated_carry_yards": 264, + "launch_angle_vertical": None, + "launch_angle_horizontal": None, + "spin_rpm": None, + "club_path_deg": None, + "spin_axis_deg": None, + } + + def test_stream_replays_latest_shot_as_sse(self, monkeypatch): + """A phone that connects mid-session gets the last shot immediately.""" + broker = ShotStreamBroker(heartbeat_interval_s=0.01) + broker.publish(self._shot_data()) + monkeypatch.setattr(server_module, "shot_stream", broker) + + response = server_module.app.test_client().get("/api/shots/stream") + try: + assert response.status_code == 200 + assert response.mimetype == SSE_MIMETYPE + assert response.headers["Cache-Control"] == "no-cache" + + frames = response.response + assert next(frames).decode("utf-8") == HEARTBEAT_FRAME + + frame = next(frames).decode("utf-8") + assert frame.startswith("event: shot\ndata: ") + event = json.loads(frame.split("data: ", 1)[1]) + assert event["schema_version"] == 1 + assert event["ball_speed_mph"] == 151.4 + finally: + response.close() + + assert broker.subscriber_count == 0 + + def test_stream_refuses_clients_beyond_the_limit(self, monkeypatch): + """The threaded dev server must not be swamped by stream connections.""" + broker = ShotStreamBroker(heartbeat_interval_s=0.01, max_subscribers=1) + monkeypatch.setattr(server_module, "shot_stream", broker) + client = server_module.app.test_client() + + first = client.get("/api/shots/stream") + try: + second = client.get("/api/shots/stream") + assert second.status_code == 503 + finally: + first.close() + + class TestShotToDict: """Tests for shot_to_dict conversion.""" @@ -4038,6 +4107,98 @@ def test_mock_shot_missing_angles_gets_fallback_values(self, monkeypatch): assert shot.launch_angle_vertical == pytest.approx(20.5) assert shot.launch_angle_horizontal == pytest.approx(0.0) + def test_ble_publish_survives_websocket_failure(self, monkeypatch): + """A broken web client must not suppress the independent BLE transport.""" + published = [] + publisher = SimpleNamespace(publish=lambda payload: published.append(payload)) + + monkeypatch.setattr(server_module, "monitor", None) + monkeypatch.setattr(server_module, "sim_connectors", []) + monkeypatch.setattr(server_module, "ble_publisher", publisher) + monkeypatch.setattr(server_module, "debug_mode", False) + monkeypatch.setattr(server_module, "get_session_logger", lambda: None) + monkeypatch.setattr( + server_module.socketio, + "emit", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("socket closed")), + ) + + shot = Shot( + ball_speed_mph=100.0, + timestamp=datetime.now(), + club=ClubType.IRON_7, + mode="mock", + ) + + on_shot_detected(shot) + _wait_for_shot_finalization_idle() + + assert len(published) == 1 + assert published[0]["ball_speed_mph"] == 100.0 + + def test_shot_stream_publish_survives_websocket_failure(self, monkeypatch): + """A broken web client must not suppress the independent Wi-Fi transport.""" + broker = ShotStreamBroker() + subscriber = broker.subscribe() + + monkeypatch.setattr(server_module, "monitor", None) + monkeypatch.setattr(server_module, "sim_connectors", []) + monkeypatch.setattr(server_module, "ble_publisher", None) + monkeypatch.setattr(server_module, "shot_stream", broker) + monkeypatch.setattr(server_module, "debug_mode", False) + monkeypatch.setattr(server_module, "get_session_logger", lambda: None) + monkeypatch.setattr( + server_module.socketio, + "emit", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("socket closed")), + ) + + on_shot_detected( + Shot( + ball_speed_mph=100.0, + timestamp=datetime.now(), + club=ClubType.IRON_7, + mode="mock", + ) + ) + _wait_for_shot_finalization_idle() + + streamed = json.loads(subscriber.get_nowait()) + assert streamed["schema_version"] == 1 + assert streamed["ball_speed_mph"] == 100.0 + + def test_shot_stream_failure_does_not_break_shot_processing(self, monkeypatch): + """A raising broker must not escape into the shot pipeline.""" + forwarded = [] + + class BrokenBroker: + def publish(self, _shot_data): + raise RuntimeError("stream is wedged") + + monkeypatch.setattr(server_module, "monitor", None) + monkeypatch.setattr(server_module, "ble_publisher", None) + monkeypatch.setattr(server_module, "shot_stream", BrokenBroker()) + monkeypatch.setattr(server_module, "debug_mode", False) + monkeypatch.setattr(server_module, "get_session_logger", lambda: None) + monkeypatch.setattr(server_module.socketio, "emit", lambda *_a, **_k: None) + monkeypatch.setattr( + server_module, + "_forward_shot_to_simulators", + lambda shot: forwarded.append(shot), + ) + + on_shot_detected( + Shot( + ball_speed_mph=100.0, + timestamp=datetime.now(), + club=ClubType.IRON_7, + mode="mock", + ) + ) + _wait_for_shot_finalization_idle() + + assert len(forwarded) == 1 + def test_implausible_club_aoa_is_rejected(self, monkeypatch): """A +31° club AoA is physically impossible and should be discarded.""" From a5f62074bc3daa19c4f2ed14718bfdf657bf510b Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Fri, 25 Sep 2026 10:04:13 -0400 Subject: [PATCH 04/19] docs(changelog): note phone transports ported from jake-fishtech Co-Authored-By: Claude Opus 5.5 --- docs/changelog.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index b29cdd7fe..b46a165b5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -59,6 +59,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 when Node is older than 22.12 or `npm install` fails and `ui/dist` already exists). Installing Electron needs **Node.js 22.12 or newer**. See [Electron Kiosk Shell](electron-kiosk-shell.md). +- **Phone transports: Bluetooth LE, a Wi-Fi shot stream and a club API.** + Ported from [jake-fishtech](https://github.com/jake-fishtech)'s `feat/iOS-ble` + branch. `--ble` (with the optional `ble` extra, `bless==0.3.0` on Linux) + advertises a GATT service that notifies each final shot and accepts versioned + phone commands (`set_club`, `get_club`, IWR6843 orientation). Without BlueZ or + the extra, `--ble` logs that Bluetooth is unavailable and the server carries + on. `GET /api/shots/stream` sends the same final shots and `club_changed` as + Server-Sent Events, replaying the latest shot on connect and sending a + keep-alive `: ping`. `GET`/`POST /api/club` reads or sets the Pi-owned club, + and every club change (kiosk, phone, simulator) is broadcast over Socket.IO, + SSE and BLE. `POST /api/calibration/iwr6843/orientation` applies a + gravity-referenced phone measurement as the IWR6843 mount tilt and persists it + to `~/.config/openflight/iwr6843_phone_orientation.json`; an explicit + `--iwr6843-tilt-deg` still wins. `start-kiosk.sh --ble` syncs the `ble` extra + and `setup.sh` installs it. Wire format: [iOS BLE](ios-ble.md). Behaviour + changes: Socket.IO `set_club` now ignores `unknown` and a missing club (it used + to fall back to driver), and a failed Socket.IO shot emit no longer stops BLE, + SSE and simulator delivery. - **PAR-TEE connector.** `"type": "partee"` in `config/sim.json` streams shots to the [PAR-TEE](https://playpartee.com) iPhone app over OpenConnect V1 on the phone's Wi-Fi address (port 921 by default). Same shared codec as GSPro and From e40437dd6b34de1812380caac1d2f8b5b38d0a44 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Fri, 25 Sep 2026 10:30:02 -0400 Subject: [PATCH 05/19] feat(ble): add schema v2 payloads, hello negotiation and size budget v2 shots add type, final, shot_number, profile, carry_range, spin_source, enrichment and a uuid5 event_id so provisional and final shots upsert. Adds v2 events and hello {client_schema_max}. v2 is UTF-8 so profile lists fit one BLE message; v1 is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/openflight/ble/__init__.py | 8 + src/openflight/ble/protocol.py | 225 ++++++++++++++++++++++++ tests/fixtures/shot_v2.json | 29 ++++ tests/test_ble_protocol_v2.py | 309 +++++++++++++++++++++++++++++++++ 4 files changed, 571 insertions(+) create mode 100644 tests/fixtures/shot_v2.json create mode 100644 tests/test_ble_protocol_v2.py diff --git a/src/openflight/ble/__init__.py b/src/openflight/ble/__init__.py index 474002f7b..c4f657b37 100644 --- a/src/openflight/ble/__init__.py +++ b/src/openflight/ble/__init__.py @@ -2,12 +2,16 @@ from .protocol import ( CONTROL_CHARACTERISTIC_UUID, + CONTROL_V2_CHARACTERISTIC_UUID, FRAME_SIZE, SERVICE_UUID, SHOT_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, FragmentReassembler, build_shot_event, + build_shot_event_v2, encode_shot_event, + encode_shot_event_v2, fragment_payload, ) from .publisher import BleShotPublisher @@ -15,11 +19,15 @@ __all__ = [ "BleShotPublisher", "CONTROL_CHARACTERISTIC_UUID", + "CONTROL_V2_CHARACTERISTIC_UUID", "FRAME_SIZE", "FragmentReassembler", "SERVICE_UUID", "SHOT_CHARACTERISTIC_UUID", + "SHOT_V2_CHARACTERISTIC_UUID", "build_shot_event", + "build_shot_event_v2", "encode_shot_event", + "encode_shot_event_v2", "fragment_payload", ] diff --git a/src/openflight/ble/protocol.py b/src/openflight/ble/protocol.py index ca2a5cc39..67728f32f 100644 --- a/src/openflight/ble/protocol.py +++ b/src/openflight/ble/protocol.py @@ -12,7 +12,16 @@ SHOT_CHARACTERISTIC_UUID = "2B28F67E-9011-41D2-98ED-562B47D7A5E4" CONTROL_CHARACTERISTIC_UUID = "7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11" +# Schema v2 lives on its own pair of characteristics in the same service. +# BlueZ notifies every subscribed central from one characteristic value, so a +# separate pair is the only way to keep version-one centrals on byte-identical +# version-one traffic (see "Schema v2 design decision" in docs/ios-ble.md). +SHOT_V2_CHARACTERISTIC_UUID = "ED365FE6-3ABF-4FC3-8E44-D9525A22DABD" +CONTROL_V2_CHARACTERISTIC_UUID = "7BA96E63-12C2-4CE0-BB84-3513C7FD1474" + SCHEMA_VERSION = 1 +SCHEMA_VERSION_V2 = 2 +MAX_SCHEMA_VERSION = SCHEMA_VERSION_V2 FRAME_VERSION = 1 FRAME_SIZE = 20 _HEADER = struct.Struct(">BHBB") @@ -31,6 +40,40 @@ "spin_axis_deg", ) +# Added by schema v2. Present on every v2 shot, ``null`` when unknown. +_V2_OPTIONAL_SHOT_FIELDS = ( + "shot_number", + "profile_id", + "profile_name", + "carry_range", + "spin_source", + "launch_angle_confidence", +) + +# Stable namespace for per-shot v2 event ids. Changing it changes every id. +_SHOT_EVENT_NAMESPACE = uuid.UUID("D49C99A9-A305-49CA-A8C2-7D30B7645988") + +V2_FEATURES = ( + "provisional_shots", + "shot_processing", + "profiles", + "power_status", + "session_clear", + "delete_shot", + "club", +) + +V2_EVENT_TYPES = ( + "shot", + "shot_processing", + "profiles", + "power_status", + "session_cleared", + "club_changed", +) + +ENRICHMENT_STATUSES = ("pending", "complete", "skipped") + def build_club_event(club: str) -> dict: """Build the V1 event broadcast whenever the authoritative club changes.""" @@ -80,6 +123,188 @@ def encode_shot_event(shot_data: Mapping, *, event_id: str | None = None) -> byt ).encode("utf-8") +def encode_message(message: Mapping) -> bytes: + """Encode a version-one message: compact, sorted, ASCII-only JSON.""" + return json.dumps( + message, + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def encode_message_v2(message: Mapping) -> bytes: + """Encode a schema v2 message: compact, sorted UTF-8 JSON. + + Unlike version one, non-ASCII text is sent as UTF-8 rather than ``\\uXXXX`` + escapes, which keeps twelve 40-character profile names inside one BLE + message. Clients must decode UTF-8 only after reassembling every fragment. + """ + return json.dumps( + message, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8", "replace") + + +def stable_shot_event_id(shot_data: Mapping) -> str: + """Derive one event id per shot, shared by its provisional and final v2 events. + + ``timestamp`` is fixed at detection and ``shot_number`` at callback time, + so both publications of the same shot hash to the same UUID while shots + from different logging sessions (whose numbers restart at one) do not. + """ + timestamp = shot_data["timestamp"] + if not isinstance(timestamp, str) or not timestamp: + raise ValueError("Shot timestamp must be a non-empty string") + shot_number = shot_data.get("shot_number") + return str(uuid.uuid5(_SHOT_EVENT_NAMESPACE, f"{timestamp}#{shot_number}")) + + +def _blank_to_none(value): + return None if value == "" else value + + +def build_enrichment(status: str, reason: str | None = None) -> dict: + """Build the v2 ``enrichment`` object describing optional-hardware progress.""" + if status not in ENRICHMENT_STATUSES: + raise ValueError(f"Unknown enrichment status: {status!r}") + enrichment = {"status": status} + if reason: + enrichment["reason"] = str(reason) + return enrichment + + +def build_shot_event_v2( + shot_data: Mapping, + *, + final: bool, + enrichment: Mapping | None = None, + event_id: str | None = None, +) -> dict: + """Build a v2 shot: the v1 fields plus identity, profile and state fields.""" + event = build_shot_event(shot_data, event_id=event_id or stable_shot_event_id(shot_data)) + event["schema_version"] = SCHEMA_VERSION_V2 + event["type"] = "shot" + event["final"] = bool(final) + for field in _V2_OPTIONAL_SHOT_FIELDS: + event[field] = _blank_to_none(shot_data.get(field)) + if event["carry_range"] is not None: + event["carry_range"] = list(event["carry_range"]) + event["enrichment"] = ( + build_enrichment(enrichment["status"], enrichment.get("reason")) + if enrichment is not None + else None + ) + return event + + +def encode_shot_event_v2( + shot_data: Mapping, + *, + final: bool, + enrichment: Mapping | None = None, + event_id: str | None = None, +) -> bytes: + """Encode a v2 shot event.""" + return encode_message_v2( + build_shot_event_v2(shot_data, final=final, enrichment=enrichment, event_id=event_id) + ) + + +def build_event_v2(event_type: str, fields: Mapping | None = None) -> dict: + """Build one v2 notify event: ``{"schema_version":2,"type":...,**fields}``.""" + if event_type not in V2_EVENT_TYPES: + raise ValueError(f"Unknown v2 event type: {event_type!r}") + fields = dict(fields or {}) + if "schema_version" in fields or "type" in fields: + raise ValueError("v2 event fields must not override schema_version or type") + return {"schema_version": SCHEMA_VERSION_V2, "type": event_type, **fields} + + +def build_club_event_v2(club: str) -> dict: + """The v2 counterpart of ``build_club_event``.""" + if not isinstance(club, str) or not club: + raise ValueError("Club must be a non-empty string") + return build_event_v2("club_changed", {"club": club}) + + +def build_shot_processing_event(state: str) -> dict: + """``shot_processing``: ``capturing``, ``calculating`` or ``failed``.""" + if not isinstance(state, str) or not state: + raise ValueError("Processing state must be a non-empty string") + return build_event_v2("shot_processing", {"state": state}) + + +def build_profiles_event(snapshot: Mapping) -> dict: + """``profiles`` for phones: ids and names only. + + ``created_at`` and the opaque ``settings`` dict stay on Socket.IO. Phones + only select profiles, and an unbounded ``settings`` dict could not be + guaranteed to fit in one BLE message. + """ + profiles = [ + {"id": str(profile["id"]), "name": str(profile["name"])} + for profile in snapshot.get("profiles") or [] + ] + return build_event_v2( + "profiles", + {"profiles": profiles, "active_profile_id": snapshot.get("active_profile_id")}, + ) + + +def build_power_status_event(status: Mapping) -> dict: + """``power_status``: the Socket.IO payload, flattened beside ``type``.""" + return build_event_v2("power_status", status) + + +def build_session_cleared_event(profile_id: str) -> dict: + """``session_cleared``: the profile whose shots were removed.""" + return build_event_v2("session_cleared", {"profile_id": profile_id}) + + +def build_control_response( + request_id: str, + *, + result: Mapping | None = None, + error: str | None = None, + schema_version: int = SCHEMA_VERSION, +) -> dict: + """Build the response notified for one control command.""" + response = { + "schema_version": schema_version, + "request_id": request_id, + "ok": error is None, + } + if error is None: + response["result"] = dict(result or {}) + else: + response["error"] = error + return response + + +def build_hello_result(client_schema_max) -> dict: + """Answer a ``hello`` command with the negotiated schema and features.""" + if isinstance(client_schema_max, bool) or not isinstance(client_schema_max, int): + raise ValueError("hello requires an integer client_schema_max") + if client_schema_max < SCHEMA_VERSION: + raise ValueError("client_schema_max must be at least 1") + negotiated = min(client_schema_max, MAX_SCHEMA_VERSION) + if negotiated < SCHEMA_VERSION_V2: + return {"schema_version": negotiated, "features": []} + return { + "schema_version": negotiated, + "features": list(V2_FEATURES), + "characteristics": { + "shot": SHOT_V2_CHARACTERISTIC_UUID, + "control": CONTROL_V2_CHARACTERISTIC_UUID, + }, + } + + def fragment_payload(payload: bytes, *, sequence: int) -> list[bytes]: """Split a message into conservative 20-byte BLE notification frames.""" if not payload: diff --git a/tests/fixtures/shot_v2.json b/tests/fixtures/shot_v2.json new file mode 100644 index 000000000..1e1c6da59 --- /dev/null +++ b/tests/fixtures/shot_v2.json @@ -0,0 +1,29 @@ +{ + "ball_speed_mph": 106.1, + "carry_range": [ + 144, + 160 + ], + "club": "7-iron", + "club_path_deg": 2.5, + "club_speed_mph": 83.5, + "enrichment": { + "status": "complete" + }, + "estimated_carry_yards": 152, + "event_id": "05dd37ec-49ed-596b-b1a4-953d54e4f239", + "final": true, + "launch_angle_confidence": 0.6, + "launch_angle_horizontal": -0.7, + "launch_angle_vertical": 21.2, + "profile_id": "0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b", + "profile_name": "Zoë", + "schema_version": 2, + "shot_number": 7, + "smash_factor": 1.27, + "spin_axis_deg": -1.6, + "spin_rpm": 6482, + "spin_source": null, + "timestamp": "2026-09-25T14:03:07.412345", + "type": "shot" +} diff --git a/tests/test_ble_protocol_v2.py b/tests/test_ble_protocol_v2.py new file mode 100644 index 000000000..655dc9509 --- /dev/null +++ b/tests/test_ble_protocol_v2.py @@ -0,0 +1,309 @@ +"""Tests for the BLE/SSE schema v2 payloads, negotiation and size budget.""" + +import json +from pathlib import Path + +import pytest + +from openflight.ble.protocol import ( + CONTROL_CHARACTERISTIC_UUID, + CONTROL_V2_CHARACTERISTIC_UUID, + MAX_MESSAGE_SIZE, + SERVICE_UUID, + SHOT_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, + V2_FEATURES, + build_event_v2, + build_hello_result, + build_power_status_event, + build_profiles_event, + build_shot_event, + build_shot_event_v2, + encode_message_v2, + encode_shot_event, + encode_shot_event_v2, + fragment_payload, + stable_shot_event_id, +) +from openflight.profiles import MAX_NAME_LENGTH, MAX_PROFILES + +FIXTURES = Path(__file__).parent / "fixtures" +SHOT_V2_FIXTURE = FIXTURES / "shot_v2.json" + +V1_KEYS = { + "schema_version", + "event_id", + "timestamp", + "club", + "ball_speed_mph", + "estimated_carry_yards", + "club_speed_mph", + "smash_factor", + "launch_angle_vertical", + "launch_angle_horizontal", + "spin_rpm", + "club_path_deg", + "spin_axis_deg", +} +V2_EXTRA_KEYS = { + "type", + "final", + "shot_number", + "profile_id", + "profile_name", + "carry_range", + "spin_source", + "launch_angle_confidence", + "enrichment", +} + + +def _shot_data(**overrides): + data = { + "timestamp": "2026-09-25T14:03:07.412345", + "club": "7-iron", + "ball_speed_mph": 106.1, + "club_speed_mph": 83.5, + "smash_factor": 1.27, + "estimated_carry_yards": 152, + "launch_angle_vertical": 21.2, + "launch_angle_horizontal": -0.7, + "spin_rpm": 6482, + "club_path_deg": 2.5, + "spin_axis_deg": -1.6, + "shot_number": 7, + "profile_id": "0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b", + "profile_name": "Zoë", + "carry_range": [144, 160], + "spin_source": "measured", + "launch_angle_confidence": 0.6, + "readings": [1, 2, 3], + } + data.update(overrides) + return data + + +def test_v2_characteristics_are_new_and_distinct(): + uuids = { + SERVICE_UUID, + SHOT_CHARACTERISTIC_UUID, + CONTROL_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, + CONTROL_V2_CHARACTERISTIC_UUID, + } + assert len(uuids) == 5 + + +def test_v2_shot_is_v1_fields_plus_v2_fields(): + event = build_shot_event_v2(_shot_data(), final=True) + + assert set(event) == V1_KEYS | V2_EXTRA_KEYS + assert event["schema_version"] == 2 + assert event["type"] == "shot" + assert event["final"] is True + assert event["enrichment"] is None + assert event["shot_number"] == 7 + assert event["carry_range"] == [144, 160] + assert "readings" not in event + + +def test_v1_shot_encoding_is_unchanged_by_v2(): + """The version-one builder must not grow v2 fields.""" + event = build_shot_event(_shot_data(), event_id="B0D91F0A-7950-4D7E-9DD5-AF9777C190E1") + + assert set(event) == V1_KEYS + assert event["schema_version"] == 1 + assert encode_shot_event(_shot_data(), event_id="x").isascii() + + +def test_missing_v2_fields_are_explicit_nulls_and_blanks_become_null(): + data = _shot_data(profile_id="", profile_name="", spin_source="") + for field in ("shot_number", "carry_range", "launch_angle_confidence"): + data.pop(field) + + event = build_shot_event_v2(data, final=False, enrichment={"status": "pending"}) + + for field in ( + "shot_number", + "profile_id", + "profile_name", + "carry_range", + "spin_source", + "launch_angle_confidence", + ): + assert event[field] is None + assert event["enrichment"] == {"status": "pending"} + + +def test_provisional_and_final_share_a_stable_event_id(): + provisional = build_shot_event_v2(_shot_data(), final=False, enrichment={"status": "pending"}) + final = build_shot_event_v2( + _shot_data(ball_speed_mph=107.0), final=True, enrichment={"status": "complete"} + ) + other_shot = build_shot_event_v2(_shot_data(shot_number=8), final=True) + other_session = build_shot_event_v2( + _shot_data(timestamp="2026-09-26T09:00:00.000001"), final=True + ) + + assert provisional["event_id"] == final["event_id"] == stable_shot_event_id(_shot_data()) + assert other_shot["event_id"] != final["event_id"] + assert other_session["event_id"] != final["event_id"] + + +def test_enrichment_status_is_validated(): + with pytest.raises(ValueError): + build_shot_event_v2(_shot_data(), final=True, enrichment={"status": "done"}) + + skipped = build_shot_event_v2( + _shot_data(), final=True, enrichment={"status": "skipped", "reason": "deadline"} + ) + assert skipped["enrichment"] == {"status": "skipped", "reason": "deadline"} + + +def test_v2_encoding_is_compact_sorted_utf8(): + payload = encode_shot_event_v2(_shot_data(), final=True) + + assert b" " not in payload.replace(b"Zo\xc3\xab", b"") + assert "Zoë".encode() in payload + decoded = json.loads(payload.decode("utf-8")) + assert list(decoded) == sorted(decoded) + + +def test_v2_events_cannot_override_envelope_fields(): + with pytest.raises(ValueError): + build_event_v2("profiles", {"type": "shot"}) + with pytest.raises(ValueError): + build_event_v2("not_an_event", {}) + + +def test_power_status_event_flattens_the_socketio_payload(): + status = {"available": False, "provider": "geekworm", "state": "unavailable"} + + assert build_power_status_event(status) == { + "schema_version": 2, + "type": "power_status", + **status, + } + + +def test_profiles_event_keeps_ids_and_names_only(): + event = build_profiles_event( + { + "profiles": [ + {"id": "a", "name": "A", "created_at": "2026", "settings": {"x": 1}}, + ], + "active_profile_id": "a", + } + ) + + assert event == { + "schema_version": 2, + "type": "profiles", + "profiles": [{"id": "a", "name": "A"}], + "active_profile_id": "a", + } + + +@pytest.mark.parametrize( + ("client_max", "expected_schema"), + [(1, 1), (2, 2), (9, 2)], +) +def test_hello_negotiates_the_highest_common_schema(client_max, expected_schema): + result = build_hello_result(client_max) + + assert result["schema_version"] == expected_schema + if expected_schema == 2: + assert result["features"] == list(V2_FEATURES) + assert result["characteristics"] == { + "shot": SHOT_V2_CHARACTERISTIC_UUID, + "control": CONTROL_V2_CHARACTERISTIC_UUID, + } + else: + assert result == {"schema_version": 1, "features": []} + + +@pytest.mark.parametrize("bad", [None, "2", 2.0, True, 0, -1]) +def test_hello_rejects_invalid_client_schema(bad): + with pytest.raises(ValueError): + build_hello_result(bad) + + +# -- size budget: every v2 message must fit in 255 fragments of 15 bytes ---------- + + +def _worst_float(): + # The longest repr a finite double produces. + return -1.2345678901234567e-300 + + +def test_worst_case_v2_shot_fits_in_one_ble_message(): + worst_name = "\x01" * MAX_NAME_LENGTH # six bytes per character once escaped + data = { + "timestamp": "2026-09-25T14:03:07.412345", + "club": "3-hybrid", + "ball_speed_mph": _worst_float(), + "club_speed_mph": _worst_float(), + "smash_factor": _worst_float(), + "estimated_carry_yards": _worst_float(), + "launch_angle_vertical": _worst_float(), + "launch_angle_horizontal": _worst_float(), + "spin_rpm": _worst_float(), + "club_path_deg": _worst_float(), + "spin_axis_deg": _worst_float(), + "shot_number": 2**63, + "profile_id": "f" * 64, + "profile_name": worst_name, + "carry_range": [_worst_float(), _worst_float()], + "spin_source": "calculated_from_launch_angle", + "launch_angle_confidence": _worst_float(), + } + payload = encode_shot_event_v2( + data, + final=False, + enrichment={"status": "skipped", "reason": "worker_unavailable"}, + ) + + assert len(payload) <= MAX_MESSAGE_SIZE + assert len(fragment_payload(payload, sequence=0)) <= 255 + + +@pytest.mark.parametrize("worst_char", ["\x01", "\U0001f3cc", '"', "\\"]) +def test_twelve_worst_case_profiles_fit_in_one_ble_message(worst_char): + snapshot = { + "profiles": [ + { + "id": f"{index:032x}", # ProfileStore ids are uuid4().hex + "name": worst_char * MAX_NAME_LENGTH, + "created_at": "2026-09-25T12:00:00Z", + "settings": {"not_sent": "x" * 10_000}, + } + for index in range(MAX_PROFILES) + ], + "active_profile_id": f"{0:032x}", + } + + payload = encode_message_v2(build_profiles_event(snapshot)) + + assert len(payload) <= MAX_MESSAGE_SIZE + fragment_payload(payload, sequence=0) + + +# -- contract fixture --------------------------------------------------------------- + + +def test_shot_v2_fixture_is_a_final_v2_shot(): + fixture = json.loads(SHOT_V2_FIXTURE.read_text(encoding="utf-8")) + + assert set(fixture) == V1_KEYS | V2_EXTRA_KEYS + assert fixture["schema_version"] == 2 + assert fixture["type"] == "shot" + assert fixture["final"] is True + assert fixture["event_id"] == stable_shot_event_id(fixture) + + +def test_shot_v2_fixture_round_trips_through_the_encoder(): + fixture = json.loads(SHOT_V2_FIXTURE.read_text(encoding="utf-8")) + + rebuilt = build_shot_event_v2(fixture, final=fixture["final"], enrichment=fixture["enrichment"]) + + assert rebuilt == fixture From 35f6fe68d8c2129faf6bdc8e2cd10150c2bb3400 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Fri, 25 Sep 2026 10:30:02 -0400 Subject: [PATCH 06/19] feat(ble): serve schema v2 on a second characteristic pair BlueZ notifies every subscriber and Bless 0.3.0 hides the writer, so v2 cannot be per-central. The publisher adds a v2 shot/control pair beside the unchanged v1 pair and gates delivery on per-characteristic subscriptions. Co-Authored-By: Claude Opus 5.5 --- src/openflight/ble/publisher.py | 455 +++++++++++++++++++++++++------- 1 file changed, 354 insertions(+), 101 deletions(-) diff --git a/src/openflight/ble/publisher.py b/src/openflight/ble/publisher.py index 7b7a9a121..53d0fa016 100644 --- a/src/openflight/ble/publisher.py +++ b/src/openflight/ble/publisher.py @@ -6,25 +6,76 @@ import json import logging import threading +import uuid from collections.abc import Callable from typing import Any, Mapping from .protocol import ( CONTROL_CHARACTERISTIC_UUID, + CONTROL_V2_CHARACTERISTIC_UUID, SCHEMA_VERSION, + SCHEMA_VERSION_V2, SERVICE_UUID, SHOT_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, FragmentReassembler, + build_club_event_v2, + build_control_response, + build_hello_result, encode_club_event, + encode_message, + encode_message_v2, encode_shot_event, + encode_shot_event_v2, fragment_payload, ) logger = logging.getLogger(__name__) +CommandHandler = Callable[[str, Mapping], tuple[dict, int]] + +_V1_CHARACTERISTICS = frozenset( + {SHOT_CHARACTERISTIC_UUID.lower(), CONTROL_CHARACTERISTIC_UUID.lower()} +) +_V2_CHARACTERISTICS = frozenset( + {SHOT_V2_CHARACTERISTIC_UUID.lower(), CONTROL_V2_CHARACTERISTIC_UUID.lower()} +) +_ALL_CHARACTERISTICS = _V1_CHARACTERISTICS | _V2_CHARACTERISTICS + +# (response schema, accepted request envelope schemas) per control +# characteristic. The version-one characteristic is unchanged; the v2 one also +# accepts version-one envelopes so a client can reuse one encoder for ``hello``. +_CONTROL_SCHEMAS = { + CONTROL_CHARACTERISTIC_UUID.lower(): (SCHEMA_VERSION, (SCHEMA_VERSION,)), + CONTROL_V2_CHARACTERISTIC_UUID.lower(): ( + SCHEMA_VERSION_V2, + (SCHEMA_VERSION, SCHEMA_VERSION_V2), + ), +} + + +def _normalize_uuid(value) -> str | None: + try: + return str(uuid.UUID(str(value))).lower() + except (TypeError, ValueError): + return None + + +def _characteristic_uuid(characteristic) -> str | None: + """The normalized UUID of a Bless characteristic object, when it has one.""" + if characteristic is None: + return None + return _normalize_uuid(getattr(characteristic, "uuid", None)) + class BleShotPublisher: - """Publish completed shots without coupling the radar thread to Bluetooth.""" + """Publish completed shots without coupling the radar thread to Bluetooth. + + Version-one traffic uses the original shot and control characteristics and + is byte-for-byte what jake-fishtech's iOS app expects. Schema v2 traffic + (provisional and final shots, profile, power and processing events) uses a + second shot/control pair, so a v1 central never receives a v2 message. + """ def __init__( self, @@ -32,7 +83,8 @@ def __init__( name: str = "OpenFlight", queue_size: int = 8, fragment_interval_s: float = 0.01, - command_handler: Callable[[str, Mapping], tuple[dict, int]] | None = None, + command_handler: CommandHandler | None = None, + command_handler_v2: CommandHandler | None = None, ): if queue_size < 1: raise ValueError("BLE queue size must be at least one") @@ -40,27 +92,42 @@ def __init__( self.queue_size = queue_size self.fragment_interval_s = fragment_interval_s self.command_handler = command_handler + self.command_handler_v2 = command_handler_v2 self._state_lock = threading.Lock() self._thread: threading.Thread | None = None self._loop: asyncio.AbstractEventLoop | None = None self._queue: asyncio.Queue[bytes] | None = None + self._v2_queue: asyncio.Queue[bytes] | None = None self._stop_event: asyncio.Event | None = None self._stop_requested = threading.Event() self._server = None self._latest_payload: bytes | None = None + self._latest_v2_payload: bytes | None = None + # ``_subscribed`` gates version-one traffic and ``_v2_subscribed`` gates + # schema v2 traffic. Both follow BlueZ's per-characteristic + # subscriptions when those are visible. self._subscribed = False + self._v2_subscribed = False + self._subscriptions: set[str] = set() self._sequence = 0 - self._control_sequence = 0 - self._control_reassembler = FragmentReassembler() - self._control_send_lock: asyncio.Lock | None = None + self._v2_sequence = 0 + self._control_sequences = {key: 0 for key in _CONTROL_SCHEMAS} + self._control_reassemblers = {key: FragmentReassembler() for key in _CONTROL_SCHEMAS} + self._control_send_locks: dict[str, asyncio.Lock] = {} @property def subscribed(self) -> bool: - """Whether at least one central is subscribed to shot notifications.""" + """Whether a central is subscribed to version-one notifications.""" with self._state_lock: return self._subscribed + @property + def v2_subscribed(self) -> bool: + """Whether a central is subscribed to schema v2 notifications.""" + with self._state_lock: + return self._v2_subscribed + def start(self) -> None: """Start advertising in a daemon thread; startup failures remain isolated.""" with self._state_lock: @@ -89,7 +156,7 @@ def stop(self) -> None: logger.warning("[BLE] Publisher thread did not stop within 5 seconds") def publish(self, shot_data: Mapping) -> bool: - """Store the latest shot and enqueue it when a central is subscribed.""" + """Store the latest v1 shot and enqueue it when a central is subscribed.""" try: payload = encode_shot_event(shot_data) except (KeyError, TypeError, ValueError): @@ -104,10 +171,34 @@ def publish(self, shot_data: Mapping) -> bool: loop.call_soon_threadsafe(self._enqueue_payload, payload) return True + def publish_v2_shot( + self, + shot_data: Mapping, + *, + final: bool, + enrichment: Mapping | None = None, + ) -> bool: + """Store the latest v2 shot (provisional or final) and notify v2 centrals.""" + try: + payload = encode_shot_event_v2(shot_data, final=final, enrichment=enrichment) + fragment_payload(payload, sequence=0) + except (KeyError, TypeError, ValueError): + logger.warning("[BLE] Failed to encode v2 shot payload", exc_info=True) + return False + + with self._state_lock: + self._latest_v2_payload = payload + loop = self._loop + subscribed = self._v2_subscribed + if loop and subscribed: + loop.call_soon_threadsafe(self._enqueue_v2_payload, payload) + return True + def publish_club(self, club: str) -> bool: """Notify connected centrals that the authoritative club changed.""" try: payload = encode_club_event(club) + v2_payload = encode_message_v2(build_club_event_v2(club)) except (TypeError, ValueError): logger.warning("[BLE] Failed to encode club payload", exc_info=True) return False @@ -115,8 +206,33 @@ def publish_club(self, club: str) -> bool: with self._state_lock: loop = self._loop subscribed = self._subscribed + v2_subscribed = self._v2_subscribed if loop and subscribed: asyncio.run_coroutine_threadsafe(self._send_control_response(payload), loop) + if loop and v2_subscribed: + asyncio.run_coroutine_threadsafe( + self._send_control_response(v2_payload, CONTROL_V2_CHARACTERISTIC_UUID), + loop, + ) + return True + + def publish_event_v2(self, event: Mapping) -> bool: + """Notify v2 centrals of one schema v2 event on the v2 control characteristic.""" + try: + payload = encode_message_v2(event) + fragment_payload(payload, sequence=0) + except (TypeError, ValueError): + logger.warning("[BLE] Failed to encode v2 event", exc_info=True) + return False + + with self._state_lock: + loop = self._loop + subscribed = self._v2_subscribed + if loop and subscribed: + asyncio.run_coroutine_threadsafe( + self._send_control_response(payload, CONTROL_V2_CHARACTERISTIC_UUID), + loop, + ) return True def _run_thread(self) -> None: @@ -131,10 +247,13 @@ def _run_thread(self) -> None: with self._state_lock: self._loop = None self._queue = None + self._v2_queue = None self._stop_event = None self._server = None self._subscribed = False - self._control_send_lock = None + self._v2_subscribed = False + self._subscriptions = set() + self._control_send_locks = {} async def _run(self) -> None: # Bless is an optional dependency and must not affect non-BLE installs. @@ -146,6 +265,7 @@ async def _run(self) -> None: loop = asyncio.get_running_loop() queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=self.queue_size) + v2_queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=self.queue_size) stop_event = asyncio.Event() server = BlessServer( name=self.name, @@ -154,26 +274,29 @@ async def _run(self) -> None: on_unsubscribe=self._on_unsubscribe, ) await server.add_new_service(SERVICE_UUID) - await server.add_new_characteristic( - SERVICE_UUID, - SHOT_CHARACTERISTIC_UUID, - GATTCharacteristicProperties.notify, - bytearray(), - GATTAttributePermissions.readable, - ) - await server.add_new_characteristic( - SERVICE_UUID, - CONTROL_CHARACTERISTIC_UUID, - GATTCharacteristicProperties.write | GATTCharacteristicProperties.notify, - bytearray(), - GATTAttributePermissions.readable | GATTAttributePermissions.writeable, - ) + for shot_uuid in (SHOT_CHARACTERISTIC_UUID, SHOT_V2_CHARACTERISTIC_UUID): + await server.add_new_characteristic( + SERVICE_UUID, + shot_uuid, + GATTCharacteristicProperties.notify, + bytearray(), + GATTAttributePermissions.readable, + ) + for control_uuid in (CONTROL_CHARACTERISTIC_UUID, CONTROL_V2_CHARACTERISTIC_UUID): + await server.add_new_characteristic( + SERVICE_UUID, + control_uuid, + GATTCharacteristicProperties.write | GATTCharacteristicProperties.notify, + bytearray(), + GATTAttributePermissions.readable | GATTAttributePermissions.writeable, + ) server.write_request_func = self._on_write_request self._install_bluez_subscription_hooks(server) with self._state_lock: self._loop = loop self._queue = queue + self._v2_queue = v2_queue self._stop_event = stop_event self._server = server @@ -182,12 +305,16 @@ async def _run(self) -> None: if self._stop_requested.is_set(): stop_event.set() - worker = asyncio.create_task(self._delivery_worker()) + workers = [ + asyncio.create_task(self._delivery_worker()), + asyncio.create_task(self._v2_delivery_worker()), + ] try: await stop_event.wait() finally: - worker.cancel() - await asyncio.gather(worker, return_exceptions=True) + for worker in workers: + worker.cancel() + await asyncio.gather(*workers, return_exceptions=True) await server.stop() logger.info("[BLE] Advertising stopped") @@ -199,32 +326,106 @@ def _install_bluez_subscription_hooks(self, server) -> None: and ``StopNotify`` handlers with no-ops. Hook the application object after asynchronous server setup so delivery state follows the iOS notification subscription. + + Bless calls the hook *before* it records the characteristic in + ``app.subscribed_characteristics``, so when that list exists the + per-characteristic state is re-read on the next loop iteration. """ app = getattr(server, "app", None) if app is None: return - app.StartNotify = lambda session: self._on_subscribe(None, session) - app.StopNotify = lambda session: self._on_unsubscribe(None, session) + app.StartNotify = lambda session: self._on_bluez_notify_change(app, session, True) + app.StopNotify = lambda session: self._on_bluez_notify_change(app, session, False) - def _on_subscribe(self, _characteristic, _session) -> None: + def _on_bluez_notify_change(self, app, session, started: bool) -> None: + tracked = getattr(app, "subscribed_characteristics", None) with self._state_lock: - self._subscribed = True - latest_payload = self._latest_payload - logger.info("[BLE] iOS client subscribed") - if latest_payload is not None: - self._enqueue_payload(latest_payload) + loop = self._loop + if isinstance(tracked, list) and loop is not None: + loop.call_soon(self._sync_bluez_subscriptions, app) + return + if started: + self._on_subscribe(None, session) + else: + self._on_unsubscribe(None, session) - def _on_unsubscribe(self, _characteristic, _session) -> None: + def _sync_bluez_subscriptions(self, app) -> None: + tracked = getattr(app, "subscribed_characteristics", None) or [] + subscriptions = {_normalize_uuid(item) for item in tracked} & _ALL_CHARACTERISTICS + self._set_subscriptions(subscriptions) + + def _on_subscribe(self, characteristic, _session) -> None: + characteristic_uuid = _characteristic_uuid(characteristic) + with self._state_lock: + subscriptions = set(self._subscriptions) + if characteristic_uuid is None: + # The backend did not say which characteristic; assume all of them, + # as the version-one publisher did. + subscriptions |= _ALL_CHARACTERISTICS + else: + subscriptions.add(characteristic_uuid) + self._set_subscriptions(subscriptions) + + def _on_unsubscribe(self, characteristic, _session) -> None: + characteristic_uuid = _characteristic_uuid(characteristic) + with self._state_lock: + subscriptions = set(self._subscriptions) + if characteristic_uuid is None: + subscriptions.clear() + else: + subscriptions.discard(characteristic_uuid) + self._set_subscriptions(subscriptions) + + def _set_subscriptions(self, subscriptions: set[str]) -> None: + v1 = bool(subscriptions & _V1_CHARACTERISTICS) + v2 = bool(subscriptions & _V2_CHARACTERISTICS) with self._state_lock: - self._subscribed = False - self._clear_queue() - logger.info("[BLE] iOS client unsubscribed") + previous = self._subscriptions + was_v1 = self._subscribed + was_v2 = self._v2_subscribed + self._subscriptions = set(subscriptions) + self._subscribed = v1 + self._v2_subscribed = v2 + latest_payload = self._latest_payload + latest_v2_payload = self._latest_v2_payload + v1_queue = self._queue + v2_queue = self._v2_queue + + if v1 != was_v1: + logger.info("[BLE] Client %s (v1)", "subscribed" if v1 else "unsubscribed") + if v2 != was_v2: + logger.info("[BLE] Client %s (schema v2)", "subscribed" if v2 else "unsubscribed") + if was_v1 and not v1: + self._clear_queue(v1_queue) + if was_v2 and not v2: + self._clear_queue(v2_queue) + + # Replay the latest shot when its shot characteristic gains a + # subscriber, so a phone that subscribes to control first still gets it. + shot_v1 = SHOT_CHARACTERISTIC_UUID.lower() + shot_v2 = SHOT_V2_CHARACTERISTIC_UUID.lower() + if shot_v1 in subscriptions and shot_v1 not in previous and latest_payload is not None: + self._enqueue_payload(latest_payload) + if shot_v2 in subscriptions and shot_v2 not in previous and latest_v2_payload is not None: + self._enqueue_v2_payload(latest_v2_payload) def _enqueue_payload(self, payload: bytes) -> None: with self._state_lock: queue = self._queue subscribed = self._subscribed - if queue is None or not subscribed: + if subscribed: + self._offer(queue, payload) + + def _enqueue_v2_payload(self, payload: bytes) -> None: + with self._state_lock: + queue = self._v2_queue + subscribed = self._v2_subscribed + if subscribed: + self._offer(queue, payload) + + @staticmethod + def _offer(queue: asyncio.Queue[bytes] | None, payload: bytes) -> None: + if queue is None: return if queue.full(): try: @@ -235,9 +436,8 @@ def _enqueue_payload(self, payload: bytes) -> None: pass queue.put_nowait(payload) - def _clear_queue(self) -> None: - with self._state_lock: - queue = self._queue + @staticmethod + def _clear_queue(queue: asyncio.Queue[bytes] | None) -> None: if queue is None: return while True: @@ -250,12 +450,21 @@ def _clear_queue(self) -> None: async def _delivery_worker(self) -> None: with self._state_lock: queue = self._queue + await self._drain(queue, self._send_payload) + + async def _v2_delivery_worker(self) -> None: + with self._state_lock: + queue = self._v2_queue + await self._drain(queue, self._send_v2_payload) + + @staticmethod + async def _drain(queue: asyncio.Queue[bytes] | None, send) -> None: if queue is None: return while True: payload = await queue.get() try: - await self._send_payload(payload) + await send(payload) except Exception: # pylint: disable=broad-exception-caught logger.warning("[BLE] Failed to notify shot payload", exc_info=True) finally: @@ -263,36 +472,57 @@ async def _delivery_worker(self) -> None: async def _send_payload(self, payload: bytes) -> None: with self._state_lock: - server = self._server - subscribed = self._subscribed sequence = self._sequence self._sequence = (self._sequence + 1) & 0xFFFF - if server is None or not subscribed: + await self._notify_frames(SHOT_CHARACTERISTIC_UUID, payload, sequence, v2=False) + + async def _send_v2_payload(self, payload: bytes) -> None: + with self._state_lock: + sequence = self._v2_sequence + self._v2_sequence = (self._v2_sequence + 1) & 0xFFFF + await self._notify_frames(SHOT_V2_CHARACTERISTIC_UUID, payload, sequence, v2=True) + + def _gate_open(self, v2: bool) -> bool: + with self._state_lock: + return self._v2_subscribed if v2 else self._subscribed + + async def _notify_frames( + self, + characteristic_uuid: str, + payload: bytes, + sequence: int, + *, + v2: bool, + ) -> None: + with self._state_lock: + server = self._server + if server is None or not self._gate_open(v2): return - characteristic = server.get_characteristic(SHOT_CHARACTERISTIC_UUID) + characteristic = server.get_characteristic(characteristic_uuid) if characteristic is None: - raise RuntimeError("BLE shot characteristic is unavailable") + raise RuntimeError(f"BLE characteristic {characteristic_uuid} is unavailable") for frame in fragment_payload(payload, sequence=sequence): - with self._state_lock: - if not self._subscribed: - return + if not self._gate_open(v2): + return characteristic.value = bytearray(frame) - if not server.update_value(SERVICE_UUID, SHOT_CHARACTERISTIC_UUID): + if not server.update_value(SERVICE_UUID, characteristic_uuid): raise RuntimeError("BLE notification update failed") await asyncio.sleep(self.fragment_interval_s) def _on_write_request(self, characteristic, value, **_kwargs) -> None: - """Receive one framed phone control command from the writable GATT value.""" - if str(getattr(characteristic, "uuid", "")).lower() != CONTROL_CHARACTERISTIC_UUID.lower(): + """Receive one framed phone control command from a writable GATT value.""" + characteristic_uuid = _characteristic_uuid(characteristic) + reassembler = self._control_reassemblers.get(characteristic_uuid) + if reassembler is None: return characteristic.value = bytearray(value) try: - payload = self._control_reassembler.append(bytes(value)) + payload = reassembler.append(bytes(value)) except ValueError: logger.warning("[BLE] Rejected malformed control frame", exc_info=True) - self._control_reassembler.reset() + reassembler.reset() return if payload is None: return @@ -301,9 +531,20 @@ def _on_write_request(self, characteristic, value, **_kwargs) -> None: loop = self._loop if loop is None: return - asyncio.run_coroutine_threadsafe(self._process_control_payload(payload), loop) + asyncio.run_coroutine_threadsafe( + self._process_control_payload(payload, characteristic_uuid), + loop, + ) - async def _process_control_payload(self, payload: bytes) -> None: + async def _process_control_payload( + self, + payload: bytes, + characteristic_uuid: str = CONTROL_CHARACTERISTIC_UUID, + ) -> None: + characteristic_uuid = characteristic_uuid.lower() + response_schema, accepted_schemas = _CONTROL_SCHEMAS[characteristic_uuid] + v2 = response_schema == SCHEMA_VERSION_V2 + handler = self.command_handler_v2 if v2 else self.command_handler request_id = "unknown" try: command = json.loads(payload) @@ -312,7 +553,7 @@ async def _process_control_payload(self, payload: bytes) -> None: request_id = command.get("request_id") command_type = command.get("type") command_payload = command.get("payload") - if command.get("schema_version") != SCHEMA_VERSION: + if command.get("schema_version") not in accepted_schemas: raise ValueError("Unsupported control schema version") if not isinstance(request_id, str) or not request_id: raise ValueError("Control command requires a request_id") @@ -320,36 +561,41 @@ async def _process_control_payload(self, payload: bytes) -> None: raise ValueError("Control command requires a type") if not isinstance(command_payload, dict): raise ValueError("Control command payload must be an object") - if self.command_handler is None: - raise ValueError("Phone controls are not configured on this OpenFlight server") - result, status = await asyncio.to_thread( - self.command_handler, - command_type, - command_payload, - ) - if status < 200 or status >= 300: - error = result.get("error", f"Control command failed with status {status}") - response = self._control_response(request_id, error=str(error)) + if command_type == "hello": + # Transport negotiation is answered by the publisher itself: it + # is about which characteristics exist, not server state. + result = build_hello_result(command_payload.get("client_schema_max")) + response = self._control_response( + request_id, result=result, schema_version=response_schema + ) else: - response = self._control_response(request_id, result=result) + if handler is None: + raise ValueError("Phone controls are not configured on this OpenFlight server") + result, status = await asyncio.to_thread(handler, command_type, command_payload) + if status < 200 or status >= 300: + error = result.get("error", f"Control command failed with status {status}") + response = self._control_response( + request_id, error=str(error), schema_version=response_schema + ) + else: + response = self._control_response( + request_id, result=result, schema_version=response_schema + ) except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as error: - response = self._control_response(str(request_id or "unknown"), error=str(error)) + response = self._control_response( + str(request_id or "unknown"), error=str(error), schema_version=response_schema + ) except Exception: # pylint: disable=broad-exception-caught logger.exception("[BLE] Phone control command failed") response = self._control_response( str(request_id or "unknown"), error="OpenFlight could not apply the phone command", + schema_version=response_schema, ) - encoded = json.dumps( - response, - allow_nan=False, - ensure_ascii=True, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") - await self._send_control_response(encoded) + encoded = encode_message_v2(response) if v2 else encode_message(response) + await self._send_control_response(encoded, characteristic_uuid) @staticmethod def _control_response( @@ -357,39 +603,46 @@ def _control_response( *, result: Mapping[str, Any] | None = None, error: str | None = None, + schema_version: int = SCHEMA_VERSION, ) -> dict: - response = { - "schema_version": SCHEMA_VERSION, - "request_id": request_id, - "ok": error is None, - } - if error is None: - response["result"] = dict(result or {}) - else: - response["error"] = error - return response - - async def _send_control_response(self, payload: bytes) -> None: - if self._control_send_lock is None: - self._control_send_lock = asyncio.Lock() - async with self._control_send_lock: - await self._send_control_payload(payload) + return build_control_response( + request_id, result=result, error=error, schema_version=schema_version + ) - async def _send_control_payload(self, payload: bytes) -> None: + async def _send_control_response( + self, + payload: bytes, + characteristic_uuid: str = CONTROL_CHARACTERISTIC_UUID, + ) -> None: + key = characteristic_uuid.lower() + lock = self._control_send_locks.get(key) + if lock is None: + lock = self._control_send_locks[key] = asyncio.Lock() + async with lock: + await self._send_control_payload(payload, key) + + async def _send_control_payload( + self, + payload: bytes, + characteristic_uuid: str = CONTROL_CHARACTERISTIC_UUID, + ) -> None: """Send one complete control message without interleaving fragments.""" + key = characteristic_uuid.lower() + v2 = key in _V2_CHARACTERISTICS + # Notify with the spelling the characteristic was registered with. + canonical = CONTROL_V2_CHARACTERISTIC_UUID if v2 else CONTROL_CHARACTERISTIC_UUID with self._state_lock: server = self._server - subscribed = self._subscribed - sequence = self._control_sequence - self._control_sequence = (self._control_sequence + 1) & 0xFFFF - if server is None or not subscribed: + sequence = self._control_sequences[key] + self._control_sequences[key] = (sequence + 1) & 0xFFFF + if server is None or not self._gate_open(v2): return - characteristic = server.get_characteristic(CONTROL_CHARACTERISTIC_UUID) + characteristic = server.get_characteristic(canonical) if characteristic is None: raise RuntimeError("BLE control characteristic is unavailable") for frame in fragment_payload(payload, sequence=sequence): characteristic.value = bytearray(frame) - if not server.update_value(SERVICE_UUID, CONTROL_CHARACTERISTIC_UUID): + if not server.update_value(SERVICE_UUID, canonical): raise RuntimeError("BLE control notification update failed") await asyncio.sleep(self.fragment_interval_s) From fdf178f0c78ca7c708396b377bfc40d88ff511b7 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Fri, 25 Sep 2026 10:30:02 -0400 Subject: [PATCH 07/19] feat(server): publish v2 shots, events and commands to phones v2 phones get a provisional and a final shot with one event_id and an enrichment status; v1 phones are unchanged. Processing, power, profile and session events fan out to BLE and SSE v2. BLE v2 commands reuse the Socket.IO handlers. SSE opts in with ?schema=2. Co-Authored-By: Claude Opus 5.5 --- src/openflight/server.py | 210 +++++++++++++++++-- src/openflight/shot_stream.py | 111 ++++++++-- tests/test_phone_transport_v2.py | 336 +++++++++++++++++++++++++++++++ 3 files changed, 625 insertions(+), 32 deletions(-) create mode 100644 tests/test_phone_transport_v2.py diff --git a/src/openflight/server.py b/src/openflight/server.py index f580b1233..4c1e2f64e 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -25,6 +25,13 @@ from flask_socketio import SocketIO from .ballistics import resolve_launch, simulate +from .ble.protocol import ( + build_club_event_v2, + build_power_status_event, + build_profiles_event, + build_session_cleared_event, + build_shot_processing_event, +) from .clubs import ClubType from .clubs.physics import ( SHOT_SIMULATION_DEFAULTS, @@ -216,6 +223,9 @@ class _ShotEnrichmentResult: iwr6843_ms: float | None = None kld7_ms: float | None = None camera_capture_ms: float | None = None + # Why optional hardware was skipped for this shot (``deadline``, + # ``capacity``, ``queue_full``, ``worker_unavailable``); None when it ran. + skipped_reason: str | None = None @dataclass(frozen=True) @@ -358,7 +368,9 @@ def _shot_finalization_worker_loop() -> None: shot=registered.shot, emit_event=registered.emit_event, initial_ui_ms=registered.initial_ui_ms, - enrichment=_ShotEnrichmentResult(), + enrichment=_ShotEnrichmentResult( + skipped_reason="deadline" if deadline_expired else "capacity" + ), ) elif pending.shot is not registered.shot: for shot_field in fields(Shot): @@ -1145,7 +1157,7 @@ def _broadcast_club_selection(club: ClubType) -> None: def dispatch_phone_control_command(command_type, payload): - """Route a versioned BLE phone command to the shared server operation.""" + """Route a version-one BLE phone command to the shared server operation.""" handlers = { "iwr6843_orientation_calibration": apply_iwr6843_orientation_calibration, "set_club": apply_club_selection, @@ -1157,6 +1169,43 @@ def dispatch_phone_control_command(command_type, payload): return handler(payload) +def dispatch_phone_control_command_v2(command_type, payload): + """Route a schema v2 BLE phone command through the Socket.IO operations. + + Each command calls the same function its Socket.IO counterpart does, so the + kiosk and every other client see identical broadcasts. Profile add, rename + and remove deliberately stay on Socket.IO. + """ + handlers = { + "iwr6843_orientation_calibration": apply_iwr6843_orientation_calibration, + "set_club": apply_club_selection, + "get_club": current_club_selection, + "get_profiles": request_profiles, + "set_active_profile": apply_active_profile, + "get_power_status": current_power_status, + "clear_session": apply_clear_session, + "delete_shot": apply_delete_shot, + } + handler = handlers.get(command_type) + if handler is None: + return {"error": f"Unsupported phone command: {command_type}"}, 400 + return handler(payload) + + +def _phone_state_events_v2() -> list[dict]: + """Current club, profiles and power, as seeded to a new schema v2 SSE client.""" + with club_selection_lock: + club_value = active_club.value + events = [build_club_event_v2(club_value)] + try: + events.append(build_profiles_event(get_profile_store().snapshot())) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Could not read profiles for the shot stream", exc_info=True) + if power_monitor is not None and power_monitor.status is not None: + events.append(build_power_status_event(power_monitor.status.to_dict())) + return events + + @app.route("/api/club", methods=["GET", "POST"]) def api_club_selection(): """Read or set the active club over Wi-Fi.""" @@ -1703,9 +1752,18 @@ def handle_get_camera_capture_settings(): @app.route("/api/shots/stream") def shots_stream(): - """Stream completed shots to the iOS app as Server-Sent Events.""" + """Stream completed shots to phones as Server-Sent Events. + + ``?schema=2`` opts into schema v2 events; the default stays version one. + """ + schema_arg = request.args.get("schema", "1") + if schema_arg not in ("1", "2"): + return {"error": "Unsupported schema; use 1 or 2"}, 400 try: - subscriber = shot_stream.subscribe() + if schema_arg == "2": + subscriber = shot_stream.subscribe(schema=2, initial_events=_phone_state_events_v2()) + else: + subscriber = shot_stream.subscribe() except ShotStreamFull as exc: logger.warning("[SERVER] Refused shot stream client: %s", exc) return str(exc), 503 @@ -1999,8 +2057,20 @@ def _emit_sim_snapshot() -> None: def _on_power_status(status: PowerStatus) -> None: - """Publish one battery reading to connected UI clients.""" - socketio.emit("power_status", status.to_dict()) + """Publish one battery reading to connected UI clients and v2 phones.""" + payload = status.to_dict() + socketio.emit("power_status", payload) + _publish_phone_event(build_power_status_event(payload)) + + +def current_power_status(_payload=None): + """Return the latest battery reading, as ``power_status`` carries it.""" + if power_monitor is None: + return {"error": "Battery monitoring is not enabled"}, 409 + status = power_monitor.status + if status is None: + return {"error": "No battery reading yet"}, 409 + return status.to_dict(), 200 def _log_power_status(status: PowerStatus) -> None: @@ -2065,20 +2135,41 @@ def _emit_profiles() -> None: Sent after every mutation, including rejected ones, so a stale client self-heals on the next round trip instead of needing an error event. """ - socketio.emit("profiles", get_profile_store().snapshot()) + snapshot = get_profile_store().snapshot() + socketio.emit("profiles", snapshot) + _publish_phone_event(build_profiles_event(snapshot)) + + +def request_profiles(_payload=None): + """Broadcast the roster, as Socket.IO ``get_profiles`` does.""" + _emit_profiles() + return {"status": "sent"}, 200 + + +def apply_active_profile(payload=None): + """Change which profile shots are attributed to, then broadcast the roster. + + The roster goes out even when the id is unknown, so every client converges + on the unchanged selection. + """ + store = get_profile_store() + applied = store.set_active(_payload_dict(payload).get("profile_id")) + _emit_profiles() + if not applied: + return {"error": "Unknown profile"}, 404 + return {"status": "applied", "active_profile_id": store.get_active().id}, 200 @socketio.on("get_profiles") def handle_get_profiles(): """Send the roster to a client that asked for it.""" - _emit_profiles() + request_profiles() @socketio.on("set_active_profile") def handle_set_active_profile(data=None): """Change which profile shots are attributed to.""" - get_profile_store().set_active(_payload_dict(data).get("profile_id")) - _emit_profiles() + apply_active_profile(data) @socketio.on("add_profile") @@ -2170,16 +2261,23 @@ def _clear_profile_rows(profile_id: str) -> None: monitor.clear_session() -@socketio.on("clear_session") -def handle_clear_session(data=None): - """Clear recorded rows for one profile only.""" - raw_id = _payload_dict(data).get("profile_id") +def apply_clear_session(payload=None): + """Clear recorded rows for one profile (default: the active one).""" + raw_id = _payload_dict(payload).get("profile_id") profile_id = str(raw_id).strip() if raw_id else get_profile_store().get_active().id _clear_profile_rows(profile_id) socketio.emit( "session_cleared", {"profile_id": profile_id, "shots": _session_shots()}, ) + _publish_phone_event(build_session_cleared_event(profile_id)) + return {"status": "cleared", "profile_id": profile_id}, 200 + + +@socketio.on("clear_session") +def handle_clear_session(data=None): + """Clear recorded rows for one profile only.""" + apply_clear_session(data) @socketio.on("upload_cloud") @@ -2195,17 +2293,23 @@ def handle_get_session(): socketio.emit("session_state", _session_state_payload()) -@socketio.on("delete_shot") -def handle_delete_shot(data): - """Delete one recorded shot or swing-speed rep from the current session.""" - timestamp = data.get("timestamp") if isinstance(data, dict) else None +def apply_delete_shot(payload): + """Delete one recorded shot or swing-speed rep, keyed by its timestamp.""" + timestamp = payload.get("timestamp") if isinstance(payload, dict) else None deleted = _delete_session_row(timestamp) if not deleted: socketio.emit("delete_shot_error", {"error": "Shot not found"}) - return + return {"error": "Shot not found"}, 404 socketio.emit("session_state", _session_state_payload()) + return {"status": "deleted", "timestamp": timestamp}, 200 + + +@socketio.on("delete_shot") +def handle_delete_shot(data): + """Delete one recorded shot or swing-speed rep from the current session.""" + apply_delete_shot(data) @socketio.on("simulate_shot") @@ -2353,6 +2457,10 @@ def handle_shutdown(): def on_shot_processing(state: str) -> None: """Forward the rolling-buffer processing lifecycle to the UI.""" socketio.emit("shot_processing", {"state": state}) + try: + _publish_phone_event(build_shot_processing_event(state)) + except ValueError: + logger.warning("[SERVER] Ignoring invalid shot processing state %r", state) def _forward_shot_to_simulators(shot: Shot) -> None: @@ -3542,6 +3650,15 @@ def _finalize_shot_detected( except Exception as e: # pylint: disable=broad-exception-caught logger.warning("[SERVER] Failed to queue streamed shot: %s", e, exc_info=True) + # Schema v2 phones get the final shot too, marked final and carrying the + # event_id of any provisional shot published for it. + if shot_data is not None: + _publish_phone_shot_v2( + shot_data, + final=True, + enrichment=_final_phone_enrichment(emit_event, enrichment), + ) + # Forward to simulator connectors (optional) _forward_shot_to_simulators(shot) @@ -3648,6 +3765,51 @@ def _queue_ordered_shot_finalization( _shot_finalization_condition.notify_all() +def _final_phone_enrichment( + emit_event: str, + enrichment: _ShotEnrichmentResult, +) -> dict | None: + """Describe optional-hardware progress on a final v2 shot. + + Only shots that were published provisionally (``emit_event`` is + ``shot_update``) carry an ``enrichment`` object; the rest never waited. + """ + if emit_event != "shot_update": + return None + if enrichment.skipped_reason: + return {"status": "skipped", "reason": enrichment.skipped_reason} + return {"status": "complete"} + + +def _publish_phone_shot_v2( + shot_data: dict, + *, + final: bool, + enrichment: dict | None, +) -> None: + """Hand one v2 shot to the BLE and SSE phone transports; never raises.""" + transports = [("Wi-Fi stream", shot_stream)] + if ble_publisher is not None: + transports.append(("BLE", ble_publisher)) + for name, transport in transports: + try: + transport.publish_v2_shot(shot_data, final=final, enrichment=enrichment) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to queue v2 shot over %s", name, exc_info=True) + + +def _publish_phone_event(event: dict) -> None: + """Hand one schema v2 event to the BLE and SSE phone transports; never raises.""" + transports = [("Wi-Fi stream", shot_stream)] + if ble_publisher is not None: + transports.append(("BLE", ble_publisher)) + for name, transport in transports: + try: + transport.publish_event_v2(event) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to queue v2 event over %s", name, exc_info=True) + + def _emit_initial_ops_shot(shot: Shot) -> bool: """Publish immediately available OPS metrics before slow enrichments.""" try: @@ -3666,6 +3828,9 @@ def _emit_initial_ops_shot(shot: Shot) -> bool: "pending": pending, }, ) + # Schema v2 phones get the same provisional shot. The final one follows + # from _finalize_shot_detected with the same event_id. + _publish_phone_shot_v2(shot_data, final=False, enrichment={"status": "pending"}) return True except Exception as error: # pylint: disable=broad-exception-caught logger.error("[SERVER] Failed to emit initial OPS shot: %s", error, exc_info=True) @@ -3832,6 +3997,7 @@ def _handle_shot_detected(shot: Shot) -> None: shot, emit_event=final_event, initial_ui_ms=initial_ui_ms, + enrichment=_ShotEnrichmentResult(skipped_reason="queue_full"), ) except Exception as error: # pylint: disable=broad-exception-caught logger.warning( @@ -3845,6 +4011,7 @@ def _handle_shot_detected(shot: Shot) -> None: shot, emit_event=final_event, initial_ui_ms=initial_ui_ms, + enrichment=_ShotEnrichmentResult(skipped_reason="worker_unavailable"), ) @@ -5248,7 +5415,10 @@ def main(): if args.ble: from .ble import BleShotPublisher # pylint: disable=import-outside-toplevel - ble_publisher = BleShotPublisher(command_handler=dispatch_phone_control_command) + ble_publisher = BleShotPublisher( + command_handler=dispatch_phone_control_command, + command_handler_v2=dispatch_phone_control_command_v2, + ) ble_publisher.start() print("Bluetooth LE enabled (advertising as OpenFlight)") diff --git a/src/openflight/shot_stream.py b/src/openflight/shot_stream.py index 7cd2185c4..9426484b9 100644 --- a/src/openflight/shot_stream.py +++ b/src/openflight/shot_stream.py @@ -1,7 +1,9 @@ """Fan out completed shots to HTTP clients as Server-Sent Events. -This is the Wi-Fi sibling of the BLE publisher: same versioned payload, same -bounded-queue delivery policy, same isolation from shot recording. It exists so +This is the Wi-Fi sibling of the BLE publisher: same versioned payloads, same +bounded-queue delivery policy, same isolation from shot recording. Clients get +version one by default and schema v2 (provisional and final shots plus +profile, power, processing and club events) with ``?schema=2``. It exists so a phone can receive shots over the network on hardware where BLE advertising is unavailable, and so the payload contract can be exercised with nothing but ``curl``. @@ -12,11 +14,19 @@ import logging import queue import threading -from typing import Iterator, Mapping +from typing import Iterable, Iterator, Mapping # The wire payload is the same versioned V1 shot event the BLE transport sends, # so both transports are validated against one contract and one test fixture. -from .ble.protocol import encode_club_event, encode_shot_event +from .ble.protocol import ( + SCHEMA_VERSION, + SCHEMA_VERSION_V2, + build_club_event_v2, + encode_club_event, + encode_message_v2, + encode_shot_event, + encode_shot_event_v2, +) logger = logging.getLogger(__name__) @@ -77,7 +87,11 @@ def __init__( self._lock = threading.Lock() self._subscribers: list[queue.Queue[StreamEvent]] = [] + # Subscribers that opted into schema v2 with ``?schema=2``. Everyone + # else keeps receiving the unchanged version-one stream. + self._v2_subscribers: set[int] = set() self._latest_payload: bytes | None = None + self._latest_v2_payload: bytes | None = None @property def subscriber_count(self) -> int: @@ -95,45 +109,118 @@ def publish(self, shot_data: Mapping) -> bool: with self._lock: self._latest_payload = payload - subscribers = list(self._subscribers) + subscribers = self._subscribers_for(SCHEMA_VERSION) event = StreamEvent("shot", payload) for subscriber in subscribers: self._offer(subscriber, event) return True + def publish_v2_shot( + self, + shot_data: Mapping, + *, + final: bool, + enrichment: Mapping | None = None, + ) -> bool: + """Send a provisional or final v2 shot to schema v2 subscribers.""" + try: + payload = encode_shot_event_v2(shot_data, final=final, enrichment=enrichment) + except (KeyError, TypeError, ValueError): + logger.warning("[STREAM] Failed to encode v2 shot payload", exc_info=True) + return False + + with self._lock: + self._latest_v2_payload = payload + subscribers = self._subscribers_for(SCHEMA_VERSION_V2) + event = StreamEvent("shot", payload) + for subscriber in subscribers: + self._offer(subscriber, event) + return True + + def publish_event_v2(self, event: Mapping) -> bool: + """Send one schema v2 event, named by its ``type``, to v2 subscribers.""" + try: + stream_event = StreamEvent(str(event["type"]), encode_message_v2(event)) + except (KeyError, TypeError, ValueError): + logger.warning("[STREAM] Failed to encode v2 event", exc_info=True) + return False + + with self._lock: + subscribers = self._subscribers_for(SCHEMA_VERSION_V2) + for subscriber in subscribers: + self._offer(subscriber, stream_event) + return True + def publish_club(self, club: str) -> bool: """Broadcast an authoritative club change to every Wi-Fi subscriber.""" try: event = StreamEvent("club_changed", encode_club_event(club)) + v2_event = StreamEvent("club_changed", encode_message_v2(build_club_event_v2(club))) except (TypeError, ValueError): logger.warning("[STREAM] Failed to encode club payload", exc_info=True) return False with self._lock: - subscribers = list(self._subscribers) + subscribers = self._subscribers_for(SCHEMA_VERSION) + v2_subscribers = self._subscribers_for(SCHEMA_VERSION_V2) for subscriber in subscribers: self._offer(subscriber, event) + for subscriber in v2_subscribers: + self._offer(subscriber, v2_event) return True - def subscribe(self) -> queue.Queue[StreamEvent]: - """Register a subscriber, seeded with the latest shot for replay.""" + def subscribe( + self, + *, + schema: int = SCHEMA_VERSION, + initial_events: Iterable[Mapping] = (), + ) -> queue.Queue[StreamEvent]: + """Register a subscriber, seeded with the latest shot for replay. + + A schema v2 subscriber is first seeded with ``initial_events`` (current + state such as club and profiles), then the latest v2 shot. + """ + if schema not in (SCHEMA_VERSION, SCHEMA_VERSION_V2): + raise ValueError(f"Unsupported shot stream schema: {schema}") + seed = [] + if schema == SCHEMA_VERSION_V2: + for event in initial_events: + try: + seed.append(StreamEvent(str(event["type"]), encode_message_v2(event))) + except (KeyError, TypeError, ValueError): + logger.warning("[STREAM] Failed to encode initial v2 event", exc_info=True) with self._lock: if len(self._subscribers) >= self.max_subscribers: raise ShotStreamFull(f"Shot stream already has {self.max_subscribers} clients") - subscriber: queue.Queue[StreamEvent] = queue.Queue(maxsize=self.queue_size) - if self._latest_payload is not None: - subscriber.put_nowait(StreamEvent("shot", self._latest_payload)) + latest = ( + self._latest_v2_payload if schema == SCHEMA_VERSION_V2 else self._latest_payload + ) + if latest is not None: + seed.append(StreamEvent("shot", latest)) + subscriber: queue.Queue[StreamEvent] = queue.Queue( + maxsize=max(self.queue_size, len(seed)) + ) + for event in seed: + subscriber.put_nowait(event) self._subscribers.append(subscriber) + if schema == SCHEMA_VERSION_V2: + self._v2_subscribers.add(id(subscriber)) count = len(self._subscribers) - logger.info("[STREAM] Client subscribed (%d streaming)", count) + logger.info("[STREAM] Client subscribed (%d streaming, schema %d)", count, schema) return subscriber + def _subscribers_for(self, schema: int) -> list[queue.Queue[StreamEvent]]: + """Subscribers of one schema; the caller holds ``_lock``.""" + v2 = schema == SCHEMA_VERSION_V2 + return [item for item in self._subscribers if (id(item) in self._v2_subscribers) == v2] + def unsubscribe(self, subscriber: queue.Queue[StreamEvent]) -> None: """Drop a subscriber. Unsubscribing twice is not an error.""" with self._lock: if subscriber not in self._subscribers: return self._subscribers.remove(subscriber) + self._v2_subscribers.discard(id(subscriber)) count = len(self._subscribers) logger.info("[STREAM] Client unsubscribed (%d streaming)", count) diff --git a/tests/test_phone_transport_v2.py b/tests/test_phone_transport_v2.py new file mode 100644 index 000000000..ce6c96487 --- /dev/null +++ b/tests/test_phone_transport_v2.py @@ -0,0 +1,336 @@ +"""Server-level schema v2 behaviour: SSE opt-in, event fan-out and command routing.""" + +import json +from datetime import datetime + +import pytest + +from openflight import server as server_module +from openflight.launch_monitor import ClubType, Shot +from openflight.power import PowerStatus +from openflight.power.models import PowerState +from openflight.profiles import ProfileStore +from openflight.shot_stream import HEARTBEAT_FRAME, ShotStreamBroker + + +def _shot_data(ball_speed=150.0, **extra): + data = { + "timestamp": "2026-09-25T12:00:00.000001", + "club": "driver", + "ball_speed_mph": ball_speed, + "estimated_carry_yards": 250, + "shot_number": 1, + } + data.update(extra) + return data + + +def _decode(event): + return event.name, json.loads(bytes(event).decode("utf-8")) + + +def _drain(subscriber): + events = [] + while not subscriber.empty(): + events.append(_decode(subscriber.get_nowait())) + return events + + +class _PhoneTransport: + """Records what the server hands to one phone transport.""" + + def __init__(self): + self.v1_shots = [] + self.v2_shots = [] + self.events = [] + self.clubs = [] + + def publish(self, shot_data): + self.v1_shots.append(shot_data) + return True + + def publish_v2_shot(self, shot_data, *, final, enrichment=None): + self.v2_shots.append((shot_data, final, enrichment)) + return True + + def publish_event_v2(self, event): + self.events.append(event) + return True + + def publish_club(self, club): + self.clubs.append(club) + return True + + +@pytest.fixture +def phones(monkeypatch, tmp_path): + """Capture Socket.IO, SSE and BLE output of a monitor-less server.""" + stream = _PhoneTransport() + ble = _PhoneTransport() + emitted = [] + monkeypatch.setattr(server_module, "shot_stream", stream) + monkeypatch.setattr(server_module, "ble_publisher", ble) + monkeypatch.setattr(server_module, "monitor", None) + monkeypatch.setattr(server_module, "power_monitor", None) + monkeypatch.setattr(server_module, "active_club", ClubType.DRIVER) + monkeypatch.setattr(server_module, "profile_store", ProfileStore(tmp_path / "profiles.json")) + monkeypatch.setattr( + server_module.socketio, + "emit", + lambda event, data=None, **_kwargs: emitted.append((event, data)), + ) + return stream, ble, emitted + + +# -- SSE broker ---------------------------------------------------------------------- + + +def test_default_subscriber_stays_on_version_one(): + broker = ShotStreamBroker() + v1 = broker.subscribe() + v2 = broker.subscribe(schema=2) + + broker.publish(_shot_data()) + broker.publish_v2_shot(_shot_data(), final=False, enrichment={"status": "pending"}) + broker.publish_event_v2({"schema_version": 2, "type": "shot_processing", "state": "failed"}) + broker.publish_club("pw") + + v1_events = _drain(v1) + assert [name for name, _ in v1_events] == ["shot", "club_changed"] + assert v1_events[0][1]["schema_version"] == 1 + assert "final" not in v1_events[0][1] + assert v1_events[1][1] == {"schema_version": 1, "type": "club_changed", "club": "pw"} + v2_events = _drain(v2) + assert [name for name, _ in v2_events] == ["shot", "shot_processing", "club_changed"] + assert v2_events[0][1]["final"] is False + assert v2_events[2][1] == {"schema_version": 2, "type": "club_changed", "club": "pw"} + + +def test_v2_subscriber_is_seeded_with_state_then_latest_v2_shot(): + broker = ShotStreamBroker() + broker.publish(_shot_data(140.0)) + broker.publish_v2_shot(_shot_data(151.0), final=True) + seed = [{"schema_version": 2, "type": "club_changed", "club": "7-iron"}] + + v2 = broker.subscribe(schema=2, initial_events=seed) + v1 = broker.subscribe() + + v2_events = _drain(v2) + assert [name for name, _ in v2_events] == ["club_changed", "shot"] + assert v2_events[1][1]["ball_speed_mph"] == 151.0 + assert _drain(v1)[0][1]["ball_speed_mph"] == 140.0 + + +def test_unsupported_stream_schema_is_rejected(): + with pytest.raises(ValueError): + ShotStreamBroker().subscribe(schema=3) + + +def test_v2_stream_route_opts_in_and_seeds_current_state(monkeypatch, tmp_path): + broker = ShotStreamBroker(heartbeat_interval_s=0.01) + broker.publish_v2_shot(_shot_data(), final=True) + monkeypatch.setattr(server_module, "shot_stream", broker) + monkeypatch.setattr(server_module, "active_club", ClubType.IRON_7) + monkeypatch.setattr(server_module, "power_monitor", None) + monkeypatch.setattr(server_module, "profile_store", ProfileStore(tmp_path / "profiles.json")) + + response = server_module.app.test_client().get("/api/shots/stream?schema=2") + try: + assert response.status_code == 200 + frames = response.response + assert next(frames).decode("utf-8") == HEARTBEAT_FRAME + events = [] + for _ in range(3): + frame = next(frames).decode("utf-8") + name = frame.split("\n", 1)[0].removeprefix("event: ") + events.append((name, json.loads(frame.split("data: ", 1)[1]))) + finally: + response.close() + + assert [name for name, _ in events] == ["club_changed", "profiles", "shot"] + assert events[0][1] == {"schema_version": 2, "type": "club_changed", "club": "7-iron"} + assert events[1][1]["profiles"][0]["name"] == "Profile 1" + assert events[2][1]["schema_version"] == 2 and events[2][1]["final"] is True + assert broker.subscriber_count == 0 + + +def test_stream_route_rejects_unknown_schema(monkeypatch): + monkeypatch.setattr(server_module, "shot_stream", ShotStreamBroker()) + + response = server_module.app.test_client().get("/api/shots/stream?schema=3") + + assert response.status_code == 400 + + +# -- event fan-out --------------------------------------------------------------------- + + +def test_shot_processing_power_and_profiles_reach_v2_phones(phones): + stream, ble, emitted = phones + status = PowerStatus( + available=True, + provider="geekworm", + state=PowerState.ON_BATTERY, + battery_percent=80.0, + battery_voltage_v=4.0, + external_power=False, + updated_at="2026-09-25T12:00:00+00:00", + ) + + server_module.on_shot_processing("calculating") + server_module._on_power_status(status) + server_module.handle_get_profiles() + + for transport in (stream, ble): + types = [event["type"] for event in transport.events] + assert types == ["shot_processing", "power_status", "profiles"] + assert transport.events[0]["state"] == "calculating" + assert transport.events[1] == { + "schema_version": 2, + "type": "power_status", + **status.to_dict(), + } + assert [event for event, _ in emitted] == ["shot_processing", "power_status", "profiles"] + + +# -- v2 command routing ------------------------------------------------------------------ + + +def test_v2_dispatch_uses_the_socketio_operations(phones): + stream, _ble, emitted = phones + store = server_module.get_profile_store() + first = store.get_active() + second = store.add("Sam") + + response, status = server_module.dispatch_phone_control_command_v2( + "set_active_profile", {"profile_id": first.id} + ) + via_ble = emitted[-1] + server_module.handle_set_active_profile({"profile_id": first.id}) + via_socket = emitted[-1] + + assert (status, response) == (200, {"status": "applied", "active_profile_id": first.id}) + assert via_ble == via_socket + assert via_ble[0] == "profiles" + assert stream.events[-1]["active_profile_id"] == first.id + assert second.id in {item["id"] for item in stream.events[-1]["profiles"]} + + +def test_v2_clear_session_broadcasts_like_socketio(phones): + stream, ble, emitted = phones + active = server_module.get_profile_store().get_active().id + + response, status = server_module.dispatch_phone_control_command_v2("clear_session", {}) + + assert (status, response) == (200, {"status": "cleared", "profile_id": active}) + assert emitted == [("session_cleared", {"profile_id": active, "shots": []})] + for transport in (stream, ble): + assert transport.events == [ + {"schema_version": 2, "type": "session_cleared", "profile_id": active} + ] + + +def test_v2_delete_shot_reports_missing_and_deleted_rows(phones, monkeypatch): + _stream, _ble, emitted = phones + shot = Shot( + ball_speed_mph=150.0, + timestamp=datetime(2026, 9, 25, 12, 0, 0, 1), + club=ClubType.DRIVER, + ) + monitor = server_module.MockLaunchMonitor() + monitor._shots.append(shot) + monkeypatch.setattr(server_module, "monitor", monitor) + + missing = server_module.dispatch_phone_control_command_v2( + "delete_shot", {"timestamp": "2026-01-01T00:00:00"} + ) + deleted = server_module.dispatch_phone_control_command_v2( + "delete_shot", {"timestamp": shot.timestamp.isoformat()} + ) + + assert missing == ({"error": "Shot not found"}, 404) + assert deleted == ({"status": "deleted", "timestamp": shot.timestamp.isoformat()}, 200) + assert [event for event, _ in emitted] == ["delete_shot_error", "session_state"] + assert monitor.get_shots() == [] + + +def test_v2_power_status_command_returns_the_socketio_payload(phones, monkeypatch): + status = PowerStatus( + available=True, + provider="geekworm", + state=PowerState.PLUGGED_IN, + battery_percent=100.0, + battery_voltage_v=4.2, + external_power=True, + updated_at="2026-09-25T12:00:00+00:00", + ) + + class _Monitor: + pass + + power = _Monitor() + power.status = status + monkeypatch.setattr(server_module, "power_monitor", power) + + assert server_module.dispatch_phone_control_command_v2("get_power_status", {}) == ( + status.to_dict(), + 200, + ) + + +def test_v1_dispatch_does_not_grow_v2_commands(phones): + for command in ("get_profiles", "set_active_profile", "clear_session", "delete_shot"): + response, status = server_module.dispatch_phone_control_command(command, {}) + assert status == 400 + assert response["error"] == f"Unsupported phone command: {command}" + + +# -- shot publication ---------------------------------------------------------------------- + + +def test_fast_path_publishes_one_final_shot_without_enrichment(phones, monkeypatch): + stream, ble, _emitted = phones + for name in ("kld7_vertical", "kld7_horizontal", "camera_capture_runtime", "iwr6843_runtime"): + monkeypatch.setattr(server_module, name, None) + for name in ("ball_speed_correction_enabled", "calculated_spin_enabled", "ballistics_enabled"): + monkeypatch.setattr(server_module, name, False) + monkeypatch.setattr(server_module, "sim_connectors", []) + monkeypatch.setattr(server_module, "get_session_logger", lambda: None) + + server_module.on_shot_detected( + Shot( + ball_speed_mph=150.0, + timestamp=datetime(2026, 9, 25, 12, 0, 1), + club=ClubType.DRIVER, + ) + ) + with server_module._shot_finalization_condition: + assert server_module._shot_finalization_condition.wait_for( + lambda: ( + not server_module._shot_finalization_order + and not server_module._shot_finalization_running + ), + timeout=5, + ) + + for transport in (stream, ble): + assert len(transport.v1_shots) == 1 + [(shot_data, final, enrichment)] = transport.v2_shots + assert final is True + assert enrichment is None + assert shot_data is transport.v1_shots[0] + + +@pytest.mark.parametrize( + ("emit_event", "skipped_reason", "expected"), + [ + ("shot", None, None), + ("shot_update", None, {"status": "complete"}), + ("shot_update", "deadline", {"status": "skipped", "reason": "deadline"}), + ("shot_update", "queue_full", {"status": "skipped", "reason": "queue_full"}), + ], +) +def test_final_enrichment_describes_what_happened(emit_event, skipped_reason, expected): + enrichment = server_module._ShotEnrichmentResult(skipped_reason=skipped_reason) + + assert server_module._final_phone_enrichment(emit_event, enrichment) == expected From cd6eeb41ae0b02f789bc7e68e4bf948d9c187d84 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Fri, 25 Sep 2026 10:30:02 -0400 Subject: [PATCH 08/19] test(ble): add loopback harness and cross-language BLE goldens tests/ble_harness.py runs the real publisher against a fake Bless server with virtual centrals, so BLE is tested end to end without a Pi. scripts/ble/generate_goldens.py writes framed hex goldens that client suites share; a test fails when they drift. Co-Authored-By: Claude Opus 5.5 --- scripts/ble/generate_goldens.py | 318 ++++++++++++++ tests/ble_harness.py | 392 ++++++++++++++++++ .../fixtures/ble_goldens/client_v1_hello.json | 35 ++ .../ble_goldens/client_v2_get_profiles.json | 32 ++ .../ble_goldens/client_v2_set_club.json | 36 ++ .../fixtures/ble_goldens/v1_club_changed.json | 20 + .../ble_goldens/v1_response_get_club.json | 29 ++ .../ble_goldens/v1_response_hello.json | 55 +++ .../v1_response_unknown_command.json | 26 ++ tests/fixtures/ble_goldens/v1_shot.json | 49 +++ .../ble_goldens/v2_event_club_changed.json | 20 + .../ble_goldens/v2_event_power_status.json | 39 ++ .../ble_goldens/v2_event_profiles.json | 41 ++ .../v2_event_profiles_worst_case.json | 307 ++++++++++++++ .../ble_goldens/v2_event_session_cleared.json | 23 + .../ble_goldens/v2_event_shot_processing.json | 21 + .../ble_goldens/v2_response_error.json | 25 ++ .../ble_goldens/v2_response_hello.json | 55 +++ .../v2_response_set_active_profile.json | 32 ++ tests/fixtures/ble_goldens/v2_shot_final.json | 78 ++++ .../ble_goldens/v2_shot_provisional.json | 78 ++++ tests/test_ble_goldens.py | 112 +++++ tests/test_ble_loopback.py | 370 +++++++++++++++++ 23 files changed, 2193 insertions(+) create mode 100644 scripts/ble/generate_goldens.py create mode 100644 tests/ble_harness.py create mode 100644 tests/fixtures/ble_goldens/client_v1_hello.json create mode 100644 tests/fixtures/ble_goldens/client_v2_get_profiles.json create mode 100644 tests/fixtures/ble_goldens/client_v2_set_club.json create mode 100644 tests/fixtures/ble_goldens/v1_club_changed.json create mode 100644 tests/fixtures/ble_goldens/v1_response_get_club.json create mode 100644 tests/fixtures/ble_goldens/v1_response_hello.json create mode 100644 tests/fixtures/ble_goldens/v1_response_unknown_command.json create mode 100644 tests/fixtures/ble_goldens/v1_shot.json create mode 100644 tests/fixtures/ble_goldens/v2_event_club_changed.json create mode 100644 tests/fixtures/ble_goldens/v2_event_power_status.json create mode 100644 tests/fixtures/ble_goldens/v2_event_profiles.json create mode 100644 tests/fixtures/ble_goldens/v2_event_profiles_worst_case.json create mode 100644 tests/fixtures/ble_goldens/v2_event_session_cleared.json create mode 100644 tests/fixtures/ble_goldens/v2_event_shot_processing.json create mode 100644 tests/fixtures/ble_goldens/v2_response_error.json create mode 100644 tests/fixtures/ble_goldens/v2_response_hello.json create mode 100644 tests/fixtures/ble_goldens/v2_response_set_active_profile.json create mode 100644 tests/fixtures/ble_goldens/v2_shot_final.json create mode 100644 tests/fixtures/ble_goldens/v2_shot_provisional.json create mode 100644 tests/test_ble_goldens.py create mode 100644 tests/test_ble_loopback.py diff --git a/scripts/ble/generate_goldens.py b/scripts/ble/generate_goldens.py new file mode 100644 index 000000000..c8b573e99 --- /dev/null +++ b/scripts/ble/generate_goldens.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Write cross-language BLE golden files to ``tests/fixtures/ble_goldens``. + +Each server-to-client golden holds one message exactly as OpenFlight notifies +it: the decoded JSON, the UTF-8 payload and every 20-byte frame, all as hex. +Client implementations (the Swift app, the Kotlin Multiplatform app) decode +these frames in their own tests, and ``tests/test_ble_goldens.py`` fails when +the committed files drift from what the encoder produces now. + +Files whose ``direction`` is ``client_to_server`` are inputs, not outputs: +clients commit the frames their encoders produce, and the Python tests +reassemble and dispatch them. This script never rewrites them. + +Usage:: + + uv run python scripts/ble/generate_goldens.py # rewrite goldens + uv run python scripts/ble/generate_goldens.py --check # exit 1 on drift + uv run python scripts/ble/generate_goldens.py --refresh-shot-fixture +""" + +from __future__ import annotations + +import argparse +import json +import random +import sys +from datetime import datetime +from pathlib import Path + +from openflight.ble.protocol import ( + CONTROL_CHARACTERISTIC_UUID, + CONTROL_V2_CHARACTERISTIC_UUID, + SHOT_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, + build_club_event_v2, + build_control_response, + build_hello_result, + build_power_status_event, + build_profiles_event, + build_session_cleared_event, + build_shot_event_v2, + build_shot_processing_event, + encode_club_event, + encode_message, + encode_message_v2, + encode_shot_event, + fragment_payload, +) + +ROOT = Path(__file__).resolve().parents[2] +FIXTURES = ROOT / "tests" / "fixtures" +GOLDENS_DIR = FIXTURES / "ble_goldens" +SHOT_V1_FIXTURE = FIXTURES / "shot_v1.json" +SHOT_V2_FIXTURE = FIXTURES / "shot_v2.json" + +REQUEST_ID = "5E0F2C4A-8B1D-4C3E-9F6A-7D2B1C0E9A84" +PROFILE_A = "0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b" +PROFILE_B = "7c1d9e3f5a2b4c6d8e0f1a3b5c7d9e1f" + + +def build_shot_v2_fixture() -> dict: + """Build the v2 contract fixture from a real (seeded) mock shot.""" + from openflight import server # pylint: disable=import-outside-toplevel + from openflight.launch_monitor import ClubType # pylint: disable=import-outside-toplevel + + random.seed(2026) + monitor = server.MockLaunchMonitor() + monitor.set_club(ClubType.IRON_7) + shot = monitor.simulate_shot() + shot.timestamp = datetime(2026, 9, 25, 14, 3, 7, 412345) + shot.shot_number = 7 + shot.profile_id = PROFILE_A + shot.profile_name = "Zoë" + return build_shot_event_v2( + server.shot_to_dict(shot), + final=True, + enrichment={"status": "complete"}, + ) + + +def _golden( + name: str, + description: str, + characteristic: str, + message: dict, + payload: bytes, + sequence: int, +) -> dict: + return { + "name": name, + "description": description, + "direction": "server_to_client", + "characteristic": characteristic, + "schema_version": message["schema_version"], + "sequence": sequence, + "message": message, + "payload_hex": payload.hex(), + "frames_hex": [frame.hex() for frame in fragment_payload(payload, sequence=sequence)], + } + + +def _v1(name, description, characteristic, message, sequence): + return _golden(name, description, characteristic, message, encode_message(message), sequence) + + +def _v2(name, description, characteristic, message, sequence): + return _golden(name, description, characteristic, message, encode_message_v2(message), sequence) + + +def worst_case_profiles_snapshot() -> dict: + """Twelve profiles with the longest-escaping 40-character names allowed.""" + return { + "profiles": [ + { + "id": f"{index:032x}", + "name": "\x01" * 40, + "created_at": "2026-09-25T12:00:00Z", + "settings": {"ignored": "x" * 500}, + } + for index in range(12) + ], + "active_profile_id": f"{0:032x}", + } + + +def build_goldens() -> dict[str, dict]: + """Every server-to-client golden, keyed by file stem.""" + shot_v1 = json.loads(SHOT_V1_FIXTURE.read_text(encoding="utf-8")) + shot_v2 = json.loads(SHOT_V2_FIXTURE.read_text(encoding="utf-8")) + v1_shot_payload = encode_shot_event(shot_v1, event_id=shot_v1["event_id"]) + provisional = build_shot_event_v2( + shot_v2, + final=False, + enrichment={"status": "pending"}, + event_id=shot_v2["event_id"], + ) + profiles = { + "profiles": [ + {"id": PROFILE_A, "name": "Zoë ⛳", "created_at": "2026-09-01T08:00:00Z"}, + {"id": PROFILE_B, "name": "Sam", "created_at": "2026-09-02T08:00:00Z"}, + ], + "active_profile_id": PROFILE_A, + } + power = { + "available": True, + "provider": "geekworm", + "state": "on_battery", + "battery_percent": 76.5, + "battery_voltage_v": 3.98, + "external_power": False, + "updated_at": "2026-09-25T14:03:05.000000+00:00", + "error": None, + } + + goldens = [ + _golden( + "v1_shot", + "Version-one final shot on the v1 shot characteristic (tests/fixtures/shot_v1.json).", + SHOT_CHARACTERISTIC_UUID, + json.loads(v1_shot_payload), + v1_shot_payload, + 0, + ), + _golden( + "v1_club_changed", + "Version-one club_changed notify on the v1 control characteristic.", + CONTROL_CHARACTERISTIC_UUID, + json.loads(encode_club_event("7-iron")), + encode_club_event("7-iron"), + 1, + ), + _v1( + "v1_response_get_club", + "get_club answered on the v1 control characteristic.", + CONTROL_CHARACTERISTIC_UUID, + build_control_response(REQUEST_ID, result={"status": "current", "club": "7-iron"}), + 2, + ), + _v1( + "v1_response_hello", + "hello {client_schema_max:2} answered on the v1 control characteristic: " + "v1 envelope, v2 result.", + CONTROL_CHARACTERISTIC_UUID, + build_control_response(REQUEST_ID, result=build_hello_result(2)), + 3, + ), + _v1( + "v1_response_unknown_command", + "What a server without schema v2 (or any server, for an unknown type) answers.", + CONTROL_CHARACTERISTIC_UUID, + build_control_response(REQUEST_ID, error="Unsupported phone command: hello"), + 4, + ), + _v2( + "v2_shot_provisional", + "Provisional OPS-only v2 shot (final:false); same event_id as v2_shot_final.", + SHOT_V2_CHARACTERISTIC_UUID, + provisional, + 0, + ), + _v2( + "v2_shot_final", + "Final v2 shot (tests/fixtures/shot_v2.json).", + SHOT_V2_CHARACTERISTIC_UUID, + shot_v2, + 1, + ), + _v2( + "v2_response_hello", + "hello answered on the v2 control characteristic.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_control_response(REQUEST_ID, result=build_hello_result(2), schema_version=2), + 0, + ), + _v2( + "v2_response_set_active_profile", + "set_active_profile accepted.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_control_response( + REQUEST_ID, + result={"status": "applied", "active_profile_id": PROFILE_B}, + schema_version=2, + ), + 1, + ), + _v2( + "v2_response_error", + "A failed v2 command.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_control_response(REQUEST_ID, error="Unknown profile", schema_version=2), + 2, + ), + _v2( + "v2_event_shot_processing", + "shot_processing notify (capturing | calculating | failed).", + CONTROL_V2_CHARACTERISTIC_UUID, + build_shot_processing_event("calculating"), + 3, + ), + _v2( + "v2_event_profiles", + "profiles notify: ids and names only, UTF-8 (not \\u-escaped) names.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_profiles_event(profiles), + 4, + ), + _v2( + "v2_event_profiles_worst_case", + "Twelve 40-character names that each escape to six bytes per character: " + "the largest profiles event the server can send.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_profiles_event(worst_case_profiles_snapshot()), + 5, + ), + _v2( + "v2_event_power_status", + "power_status notify: the Socket.IO payload beside type.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_power_status_event(power), + 6, + ), + _v2( + "v2_event_session_cleared", + "session_cleared notify.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_session_cleared_event(PROFILE_A), + 7, + ), + _v2( + "v2_event_club_changed", + "club_changed notify on the v2 control characteristic.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_club_event_v2("7-iron"), + 8, + ), + ] + return {golden["name"]: golden for golden in goldens} + + +def render(golden: dict) -> str: + return json.dumps(golden, ensure_ascii=False, indent=2) + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + parser.add_argument("--check", action="store_true", help="exit 1 if files differ") + parser.add_argument( + "--refresh-shot-fixture", + action="store_true", + help="rebuild tests/fixtures/shot_v2.json from a seeded mock shot first", + ) + args = parser.parse_args(argv) + + if args.refresh_shot_fixture: + SHOT_V2_FIXTURE.write_text( + json.dumps(build_shot_v2_fixture(), ensure_ascii=False, indent=2, sort_keys=True) + + "\n", + encoding="utf-8", + ) + + GOLDENS_DIR.mkdir(parents=True, exist_ok=True) + drift = [] + for name, golden in build_goldens().items(): + path = GOLDENS_DIR / f"{name}.json" + text = render(golden) + if args.check: + if not path.exists() or path.read_text(encoding="utf-8") != text: + drift.append(path.name) + else: + path.write_text(text, encoding="utf-8") + if drift: + print("BLE goldens out of date: " + ", ".join(drift), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/ble_harness.py b/tests/ble_harness.py new file mode 100644 index 000000000..8277bbf21 --- /dev/null +++ b/tests/ble_harness.py @@ -0,0 +1,392 @@ +"""Loopback BLE harness: a fake Bless/BlueZ server plus virtual centrals. + +``BleLoopback`` runs the real ``BleShotPublisher`` on its own thread and event +loop, exactly as ``--ble`` does, but with a fake ``bless`` module. The fake +server behaves like Bless 0.3.0 on BlueZ where it matters to the publisher: + +* ``app.StartNotify``/``app.StopNotify`` are called *before* the + characteristic is added to (or removed from) ``app.subscribed_characteristics``. +* ``update_value`` notifies the characteristic's current value, and BlueZ + delivers it to every central subscribed to that characteristic (and only + those), which ``VirtualCentral`` models. +* Writes arrive through ``write_request_func(characteristic, value)``. + +A ``VirtualCentral`` subscribes, writes framed commands and reassembles the +frames it is notified with using the real protocol reassembler. No Bluetooth +stack, radio or ``bless`` install is needed. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import sys +import threading +import time +import types +import uuid +from enum import IntFlag + +from openflight.ble.protocol import ( + CONTROL_CHARACTERISTIC_UUID, + CONTROL_V2_CHARACTERISTIC_UUID, + SHOT_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, + FragmentReassembler, + fragment_payload, +) +from openflight.ble.publisher import BleShotPublisher + + +def normalize(value: str) -> str: + return str(uuid.UUID(str(value))).lower() + + +class _Properties(IntFlag): + notify = 1 + write = 2 + + +class _Permissions(IntFlag): + readable = 1 + writeable = 2 + + +class FakeCharacteristic: + def __init__(self, char_uuid: str, properties, permissions): + # Bless normalizes UUIDs to lowercase strings. + self.uuid = normalize(char_uuid) + self.properties = properties + self.permissions = permissions + self.value = bytearray() + + +class FakeBlueZApplication: + """The slice of Bless's ``BlueZGattApplication`` the publisher touches.""" + + def __init__(self): + self.subscribed_characteristics: list[str] = [] + # Bless 0.3.0 installs no-ops here; the publisher replaces them. + self.StartNotify = lambda _session: None # pylint: disable=invalid-name + self.StopNotify = lambda _session: None # pylint: disable=invalid-name + + +class FakeBlessServer: + """Fake ``bless.BlessServer`` that records notifications per characteristic.""" + + instances: list["FakeBlessServer"] = [] + + def __init__(self, *, name, loop, on_subscribe=None, on_unsubscribe=None, **_kwargs): + self.name = name + self.loop = loop + self.on_subscribe = on_subscribe + self.on_unsubscribe = on_unsubscribe + self.app = FakeBlueZApplication() + self.characteristics: dict[str, FakeCharacteristic] = {} + self.write_request_func = None + self.started = threading.Event() + self.stopped = threading.Event() + self.listeners: list = [] + FakeBlessServer.instances.append(self) + + async def add_new_service(self, _service_uuid): + return None + + async def add_new_characteristic( + self, _service_uuid, char_uuid, properties, _value, permissions + ): + characteristic = FakeCharacteristic(char_uuid, properties, permissions) + self.characteristics[characteristic.uuid] = characteristic + + async def start(self): + self.started.set() + + async def stop(self): + self.stopped.set() + + def get_characteristic(self, char_uuid): + return self.characteristics.get(normalize(char_uuid)) + + def update_value(self, _service_uuid, char_uuid) -> bool: + key = normalize(char_uuid) + frame = bytes(self.characteristics[key].value) + for listener in list(self.listeners): + listener(key, frame) + return True + + # -- driven from the central side, always on the publisher's loop ------ + + def start_notify(self, char_uuid: str) -> None: + """What BlueZ does when the first central enables a characteristic's CCCD.""" + self.app.StartNotify(None) + self.app.subscribed_characteristics.append(normalize(char_uuid)) + + def stop_notify(self, char_uuid: str) -> None: + """What BlueZ does when the last central disables a characteristic's CCCD.""" + self.app.StopNotify(None) + self.app.subscribed_characteristics.remove(normalize(char_uuid)) + + def write(self, char_uuid: str, value: bytes) -> None: + self.write_request_func(self.characteristics[normalize(char_uuid)], bytearray(value)) + + +def fake_bless_module() -> types.ModuleType: + module = types.ModuleType("bless") + module.BlessServer = FakeBlessServer + module.GATTCharacteristicProperties = _Properties + module.GATTAttributePermissions = _Permissions + return module + + +class BleLoopback: + """Run a real ``BleShotPublisher`` against ``FakeBlessServer`` in-process.""" + + def __init__(self, monkeypatch, **publisher_kwargs): + monkeypatch.setitem(sys.modules, "bless", fake_bless_module()) + FakeBlessServer.instances.clear() + publisher_kwargs.setdefault("fragment_interval_s", 0) + self.publisher = BleShotPublisher(**publisher_kwargs) + self.server: FakeBlessServer | None = None + self.centrals: list[VirtualCentral] = [] + self._subscribers: dict[str, int] = {} + self._lock = threading.Lock() + + def __enter__(self) -> "BleLoopback": + self.publisher.start() + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if FakeBlessServer.instances and FakeBlessServer.instances[0].started.is_set(): + break + time.sleep(0.005) + else: + raise AssertionError("fake BLE server never started") + self.server = FakeBlessServer.instances[0] + self.server.listeners.append(self._deliver) + # Let the publisher finish its own post-start bookkeeping. + self.run_on_loop(lambda: None) + return self + + def __exit__(self, *_exc): + self.publisher.stop() + + @property + def loop(self) -> asyncio.AbstractEventLoop: + return self.server.loop + + def run_on_loop(self, callback) -> None: + """Run ``callback`` on the publisher's loop and wait for queued follow-ups.""" + done = threading.Event() + + def call(): + callback() + # One more turn so ``call_soon`` work scheduled by the callback runs. + self.loop.call_soon(done.set) + + self.loop.call_soon_threadsafe(call) + assert done.wait(5), "publisher loop did not run the callback" + + def central(self, name: str = "central") -> "VirtualCentral": + central = VirtualCentral(self, name) + self.centrals.append(central) + return central + + def _deliver(self, char_uuid: str, frame: bytes) -> None: + for central in list(self.centrals): + central.receive(char_uuid, frame) + + def _subscribe(self, char_uuid: str) -> None: + key = normalize(char_uuid) + with self._lock: + count = self._subscribers.get(key, 0) + self._subscribers[key] = count + 1 + if count == 0: + self.run_on_loop(lambda: self.server.start_notify(key)) + + def _unsubscribe(self, char_uuid: str) -> None: + key = normalize(char_uuid) + with self._lock: + count = self._subscribers.get(key, 0) - 1 + self._subscribers[key] = max(count, 0) + if count == 0: + self.run_on_loop(lambda: self.server.stop_notify(key)) + + def write(self, char_uuid: str, frame: bytes) -> None: + self.run_on_loop(lambda: self.server.write(char_uuid, frame)) + + +class VirtualCentral: + """A phone: subscribes, writes framed commands, reassembles notifications.""" + + def __init__(self, loopback: BleLoopback, name: str): + self.loopback = loopback + self.name = name + self.subscriptions: set[str] = set() + self._reassemblers: dict[str, FragmentReassembler] = {} + self.frames: dict[str, list[bytes]] = {} + self.messages: dict[str, list[bytes]] = {} + self._condition = threading.Condition() + self._write_sequence = 0 + + # -- GATT operations ---------------------------------------------------- + + def subscribe(self, *char_uuids: str) -> None: + for char_uuid in char_uuids: + key = normalize(char_uuid) + if key not in self.subscriptions: + self.subscriptions.add(key) + self.loopback._subscribe(key) # pylint: disable=protected-access + + def unsubscribe(self, *char_uuids: str) -> None: + for char_uuid in char_uuids: + key = normalize(char_uuid) + if key in self.subscriptions: + self.subscriptions.discard(key) + self.loopback._unsubscribe(key) # pylint: disable=protected-access + + def disconnect(self) -> None: + self.unsubscribe(*list(self.subscriptions)) + + def write_payload(self, char_uuid: str, payload: bytes) -> None: + for frame in fragment_payload(payload, sequence=self._write_sequence): + self.loopback.write(char_uuid, frame) + self._write_sequence = (self._write_sequence + 1) & 0xFFFF + + def command( + self, + command_type: str, + payload: dict | None = None, + *, + control: str = CONTROL_CHARACTERISTIC_UUID, + schema_version: int = 1, + request_id: str | None = None, + ) -> str: + request_id = request_id or str(uuid.uuid4()) + message = { + "schema_version": schema_version, + "type": command_type, + "request_id": request_id, + "payload": payload if payload is not None else {}, + } + self.write_payload(control, json.dumps(message).encode("utf-8")) + return request_id + + def request(self, command_type: str, payload: dict | None = None, **kwargs) -> dict: + """Send a command and wait for the response carrying its request id.""" + control = kwargs.get("control", CONTROL_CHARACTERISTIC_UUID) + request_id = self.command(command_type, payload, **kwargs) + return self.wait_for( + control, + lambda message: message.get("request_id") == request_id, + ) + + # -- notifications -------------------------------------------------------- + + def receive(self, char_uuid: str, frame: bytes) -> None: + if char_uuid not in self.subscriptions: + return # BlueZ only notifies centrals that enabled this CCCD. + with self._condition: + self.frames.setdefault(char_uuid, []).append(frame) + reassembler = self._reassemblers.setdefault(char_uuid, FragmentReassembler()) + message = reassembler.append(frame) + if message is not None: + self.messages.setdefault(char_uuid, []).append(message) + self._condition.notify_all() + + def decoded(self, char_uuid: str) -> list[dict]: + with self._condition: + raw = list(self.messages.get(normalize(char_uuid), [])) + return [json.loads(item.decode("utf-8")) for item in raw] + + def raw(self, char_uuid: str) -> list[bytes]: + with self._condition: + return list(self.messages.get(normalize(char_uuid), [])) + + def wait_for(self, char_uuid: str, predicate=lambda _message: True, timeout=5.0) -> dict: + key = normalize(char_uuid) + deadline = time.monotonic() + timeout + with self._condition: + while True: + for raw in self.messages.get(key, []): + message = json.loads(raw.decode("utf-8")) + if predicate(message): + return message + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AssertionError( + f"{self.name} got no matching message on {key}; " + f"saw {self.messages.get(key, [])!r}" + ) + self._condition.wait(remaining) + + def wait_for_count(self, char_uuid: str, count: int, timeout=5.0) -> list[dict]: + key = normalize(char_uuid) + deadline = time.monotonic() + timeout + with self._condition: + while len(self.messages.get(key, [])) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AssertionError( + f"{self.name} expected {count} messages on {key}; " + f"saw {self.messages.get(key, [])!r}" + ) + self._condition.wait(remaining) + return self.decoded(key) + + +def settle(loopback: BleLoopback, rounds: int = 3) -> None: + """Let queued notifications drain before asserting on their absence.""" + for _ in range(rounds): + loopback.run_on_loop(lambda: None) + time.sleep(0.02) + + +@contextlib.contextmanager +def server_loopback(monkeypatch, tmp_path): + """A monitor-less OpenFlight server state with BLE on the loopback harness. + + The real server command dispatch answers commands; Socket.IO emits are + recorded on ``loopback.emitted`` instead of sent. + """ + from openflight import server as server_module # pylint: disable=import-outside-toplevel + from openflight.launch_monitor import ClubType # pylint: disable=import-outside-toplevel + from openflight.profiles import ProfileStore # pylint: disable=import-outside-toplevel + from openflight.shot_stream import ShotStreamBroker # pylint: disable=import-outside-toplevel + + emitted = [] + lock = threading.Lock() + + def emit(event, payload=None, **_kwargs): + with lock: + emitted.append((event, payload)) + + monkeypatch.setattr(server_module.socketio, "emit", emit) + monkeypatch.setattr(server_module, "monitor", None) + monkeypatch.setattr(server_module, "profile_store", ProfileStore(tmp_path / "profiles.json")) + monkeypatch.setattr(server_module, "shot_stream", ShotStreamBroker()) + monkeypatch.setattr(server_module, "active_club", ClubType.DRIVER) + monkeypatch.setattr(server_module, "iwr6843_runtime", None) + monkeypatch.setattr(server_module, "power_monitor", None) + monkeypatch.setattr(server_module, "kld7_vertical", None) + monkeypatch.setattr(server_module, "kld7_horizontal", None) + monkeypatch.setattr(server_module, "camera_capture_runtime", None) + monkeypatch.setattr(server_module, "ball_speed_correction_enabled", False) + monkeypatch.setattr(server_module, "calculated_spin_enabled", False) + monkeypatch.setattr(server_module, "ballistics_enabled", False) + monkeypatch.setattr(server_module, "debug_mode", False) + monkeypatch.setattr(server_module, "sim_connectors", []) + monkeypatch.setattr(server_module, "get_session_logger", lambda: None) + + loopback = BleLoopback( + monkeypatch, + command_handler=server_module.dispatch_phone_control_command, + command_handler_v2=server_module.dispatch_phone_control_command_v2, + ) + with loopback: + monkeypatch.setattr(server_module, "ble_publisher", loopback.publisher) + loopback.emitted = emitted + yield loopback + + +V1_UUIDS = (SHOT_CHARACTERISTIC_UUID, CONTROL_CHARACTERISTIC_UUID) +V2_UUIDS = (SHOT_V2_CHARACTERISTIC_UUID, CONTROL_V2_CHARACTERISTIC_UUID) diff --git a/tests/fixtures/ble_goldens/client_v1_hello.json b/tests/fixtures/ble_goldens/client_v1_hello.json new file mode 100644 index 000000000..668b8a9bc --- /dev/null +++ b/tests/fixtures/ble_goldens/client_v1_hello.json @@ -0,0 +1,35 @@ +{ + "name": "client_v1_hello", + "description": "Hand-built: a v2-capable client probing with hello on the v1 control characteristic (v1 envelope).", + "direction": "client_to_server", + "characteristic": "7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11", + "schema_version": 1, + "sequence": 41, + "message": { + "schema_version": 1, + "type": "hello", + "request_id": "0b3c6f9e-2d41-4a8b-9c7e-5f1a2b3c4d5e", + "payload": { + "client_schema_max": 2 + } + }, + "payload_hex": "7b22736368656d615f76657273696f6e223a312c2274797065223a2268656c6c6f222c22726571756573745f6964223a2230623363366639652d326434312d346138622d396337652d356631613262336334643565222c227061796c6f6164223a7b22636c69656e745f736368656d615f6d6178223a327d7d", + "frames_hex": [ + "01002900097b22736368656d615f76657273696f", + "01002901096e223a312c2274797065223a226865", + "01002902096c6c6f222c22726571756573745f69", + "010029030964223a2230623363366639652d3264", + "010029040934312d346138622d396337652d3566", + "010029050931613262336334643565222c227061", + "0100290609796c6f6164223a7b22636c69656e74", + "01002907095f736368656d615f6d6178223a327d", + "01002908097d" + ], + "expect": { + "ok": true, + "schema_version": 1, + "result": { + "schema_version": 2 + } + } +} diff --git a/tests/fixtures/ble_goldens/client_v2_get_profiles.json b/tests/fixtures/ble_goldens/client_v2_get_profiles.json new file mode 100644 index 000000000..d1dfe0b24 --- /dev/null +++ b/tests/fixtures/ble_goldens/client_v2_get_profiles.json @@ -0,0 +1,32 @@ +{ + "name": "client_v2_get_profiles", + "description": "Hand-built: get_profiles on the v2 control characteristic; the roster follows as a profiles event.", + "direction": "client_to_server", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 43, + "message": { + "payload": {}, + "request_id": "c0ffee00-1234-4abc-8def-0123456789ab", + "schema_version": 2, + "type": "get_profiles" + }, + "payload_hex": "7b227061796c6f6164223a7b7d2c22726571756573745f6964223a2263306666656530302d313233342d346162632d386465662d303132333435363738396162222c22736368656d615f76657273696f6e223a322c2274797065223a226765745f70726f66696c6573227d", + "frames_hex": [ + "01002b00087b227061796c6f6164223a7b7d2c22", + "01002b0108726571756573745f6964223a226330", + "01002b02086666656530302d313233342d346162", + "01002b0308632d386465662d3031323334353637", + "01002b040838396162222c22736368656d615f76", + "01002b0508657273696f6e223a322c2274797065", + "01002b0608223a226765745f70726f66696c6573", + "01002b0708227d" + ], + "expect": { + "ok": true, + "schema_version": 2, + "result": { + "status": "sent" + } + } +} diff --git a/tests/fixtures/ble_goldens/client_v2_set_club.json b/tests/fixtures/ble_goldens/client_v2_set_club.json new file mode 100644 index 000000000..2b8975dd3 --- /dev/null +++ b/tests/fixtures/ble_goldens/client_v2_set_club.json @@ -0,0 +1,36 @@ +{ + "name": "client_v2_set_club", + "description": "Hand-built: set_club on the v2 control characteristic, with unsorted keys and spaces as a non-Python encoder may send.", + "direction": "client_to_server", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 42, + "message": { + "type": "set_club", + "payload": { + "club": "7-iron" + }, + "request_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", + "schema_version": 2 + }, + "payload_hex": "7b2274797065223a20227365745f636c7562222c20227061796c6f6164223a207b22636c7562223a2022372d69726f6e227d2c2022726571756573745f6964223a202239613862376336642d356534662d346133622d386332642d316530663961386237633664222c2022736368656d615f76657273696f6e223a20327d", + "frames_hex": [ + "01002a00097b2274797065223a20227365745f63", + "01002a01096c7562222c20227061796c6f616422", + "01002a02093a207b22636c7562223a2022372d69", + "01002a0309726f6e227d2c202272657175657374", + "01002a04095f6964223a20223961386237633664", + "01002a05092d356534662d346133622d38633264", + "01002a06092d316530663961386237633664222c", + "01002a07092022736368656d615f76657273696f", + "01002a08096e223a20327d" + ], + "expect": { + "ok": true, + "schema_version": 2, + "result": { + "status": "applied", + "club": "7-iron" + } + } +} diff --git a/tests/fixtures/ble_goldens/v1_club_changed.json b/tests/fixtures/ble_goldens/v1_club_changed.json new file mode 100644 index 000000000..cd38a567c --- /dev/null +++ b/tests/fixtures/ble_goldens/v1_club_changed.json @@ -0,0 +1,20 @@ +{ + "name": "v1_club_changed", + "description": "Version-one club_changed notify on the v1 control characteristic.", + "direction": "server_to_client", + "characteristic": "7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11", + "schema_version": 1, + "sequence": 1, + "message": { + "club": "7-iron", + "schema_version": 1, + "type": "club_changed" + }, + "payload_hex": "7b22636c7562223a22372d69726f6e222c22736368656d615f76657273696f6e223a312c2274797065223a22636c75625f6368616e676564227d", + "frames_hex": [ + "01000100047b22636c7562223a22372d69726f6e", + "0100010104222c22736368656d615f7665727369", + "01000102046f6e223a312c2274797065223a2263", + "01000103046c75625f6368616e676564227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v1_response_get_club.json b/tests/fixtures/ble_goldens/v1_response_get_club.json new file mode 100644 index 000000000..6e98ddaf8 --- /dev/null +++ b/tests/fixtures/ble_goldens/v1_response_get_club.json @@ -0,0 +1,29 @@ +{ + "name": "v1_response_get_club", + "description": "get_club answered on the v1 control characteristic.", + "direction": "server_to_client", + "characteristic": "7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11", + "schema_version": 1, + "sequence": 2, + "message": { + "schema_version": 1, + "request_id": "5E0F2C4A-8B1D-4C3E-9F6A-7D2B1C0E9A84", + "ok": true, + "result": { + "status": "current", + "club": "7-iron" + } + }, + "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636c7562223a22372d69726f6e222c22737461747573223a2263757272656e74227d2c22736368656d615f76657273696f6e223a317d", + "frames_hex": [ + "01000200097b226f6b223a747275652c22726571", + "0100020109756573745f6964223a223545304632", + "01000202094334412d384231442d344333452d39", + "01000203094636412d3744324231433045394138", + "010002040934222c22726573756c74223a7b2263", + "01000205096c7562223a22372d69726f6e222c22", + "0100020609737461747573223a2263757272656e", + "010002070974227d2c22736368656d615f766572", + "010002080973696f6e223a317d" + ] +} diff --git a/tests/fixtures/ble_goldens/v1_response_hello.json b/tests/fixtures/ble_goldens/v1_response_hello.json new file mode 100644 index 000000000..42449fe8f --- /dev/null +++ b/tests/fixtures/ble_goldens/v1_response_hello.json @@ -0,0 +1,55 @@ +{ + "name": "v1_response_hello", + "description": "hello {client_schema_max:2} answered on the v1 control characteristic: v1 envelope, v2 result.", + "direction": "server_to_client", + "characteristic": "7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11", + "schema_version": 1, + "sequence": 3, + "message": { + "schema_version": 1, + "request_id": "5E0F2C4A-8B1D-4C3E-9F6A-7D2B1C0E9A84", + "ok": true, + "result": { + "schema_version": 2, + "features": [ + "provisional_shots", + "shot_processing", + "profiles", + "power_status", + "session_clear", + "delete_shot", + "club" + ], + "characteristics": { + "shot": "ED365FE6-3ABF-4FC3-8E44-D9525A22DABD", + "control": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474" + } + } + }, + "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636861726163746572697374696373223a7b22636f6e74726f6c223a2237424139364536332d313243322d344345302d424238342d333531334337464431343734222c2273686f74223a2245443336354645362d334142462d344643332d384534342d443935323541323244414244227d2c226665617475726573223a5b2270726f766973696f6e616c5f73686f7473222c2273686f745f70726f63657373696e67222c2270726f66696c6573222c22706f7765725f737461747573222c2273657373696f6e5f636c656172222c2264656c6574655f73686f74222c22636c7562225d2c22736368656d615f76657273696f6e223a327d2c22736368656d615f76657273696f6e223a317d", + "frames_hex": [ + "01000300177b226f6b223a747275652c22726571", + "0100030117756573745f6964223a223545304632", + "01000302174334412d384231442d344333452d39", + "01000303174636412d3744324231433045394138", + "010003041734222c22726573756c74223a7b2263", + "0100030517686172616374657269737469637322", + "01000306173a7b22636f6e74726f6c223a223742", + "01000307174139364536332d313243322d344345", + "0100030817302d424238342d3335313343374644", + "010003091731343734222c2273686f74223a2245", + "0100030a17443336354645362d334142462d3446", + "0100030b1743332d384534342d44393532354132", + "0100030c173244414244227d2c22666561747572", + "0100030d176573223a5b2270726f766973696f6e", + "0100030e17616c5f73686f7473222c2273686f74", + "0100030f175f70726f63657373696e67222c2270", + "0100031017726f66696c6573222c22706f776572", + "01000311175f737461747573222c227365737369", + "01000312176f6e5f636c656172222c2264656c65", + "010003131774655f73686f74222c22636c756222", + "01000314175d2c22736368656d615f7665727369", + "01000315176f6e223a327d2c22736368656d615f", + "010003161776657273696f6e223a317d" + ] +} diff --git a/tests/fixtures/ble_goldens/v1_response_unknown_command.json b/tests/fixtures/ble_goldens/v1_response_unknown_command.json new file mode 100644 index 000000000..0138cbb5c --- /dev/null +++ b/tests/fixtures/ble_goldens/v1_response_unknown_command.json @@ -0,0 +1,26 @@ +{ + "name": "v1_response_unknown_command", + "description": "What a server without schema v2 (or any server, for an unknown type) answers.", + "direction": "server_to_client", + "characteristic": "7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11", + "schema_version": 1, + "sequence": 4, + "message": { + "schema_version": 1, + "request_id": "5E0F2C4A-8B1D-4C3E-9F6A-7D2B1C0E9A84", + "ok": false, + "error": "Unsupported phone command: hello" + }, + "payload_hex": "7b226572726f72223a22556e737570706f727465642070686f6e6520636f6d6d616e643a2068656c6c6f222c226f6b223a66616c73652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22736368656d615f76657273696f6e223a317d", + "frames_hex": [ + "01000400097b226572726f72223a22556e737570", + "0100040109706f727465642070686f6e6520636f", + "01000402096d6d616e643a2068656c6c6f222c22", + "01000403096f6b223a66616c73652c2272657175", + "01000404096573745f6964223a22354530463243", + "010004050934412d384231442d344333452d3946", + "010004060936412d374432423143304539413834", + "0100040709222c22736368656d615f7665727369", + "01000408096f6e223a317d" + ] +} diff --git a/tests/fixtures/ble_goldens/v1_shot.json b/tests/fixtures/ble_goldens/v1_shot.json new file mode 100644 index 000000000..9cd599a9b --- /dev/null +++ b/tests/fixtures/ble_goldens/v1_shot.json @@ -0,0 +1,49 @@ +{ + "name": "v1_shot", + "description": "Version-one final shot on the v1 shot characteristic (tests/fixtures/shot_v1.json).", + "direction": "server_to_client", + "characteristic": "2B28F67E-9011-41D2-98ED-562B47D7A5E4", + "schema_version": 1, + "sequence": 0, + "message": { + "ball_speed_mph": 151.4, + "club": "driver", + "club_path_deg": 2.1, + "club_speed_mph": 103.2, + "estimated_carry_yards": 264, + "event_id": "B0D91F0A-7950-4D7E-9DD5-AF9777C190E1", + "launch_angle_horizontal": -1.3, + "launch_angle_vertical": 12.6, + "schema_version": 1, + "smash_factor": 1.47, + "spin_axis_deg": -3.4, + "spin_rpm": 2380, + "timestamp": "2026-07-29T19:42:10.123456" + }, + "payload_hex": "7b2262616c6c5f73706565645f6d7068223a3135312e342c22636c7562223a22647269766572222c22636c75625f706174685f646567223a322e312c22636c75625f73706565645f6d7068223a3130332e322c22657374696d617465645f63617272795f7961726473223a3236342c226576656e745f6964223a2242304439314630412d373935302d344437452d394444352d414639373737433139304531222c226c61756e63685f616e676c655f686f72697a6f6e74616c223a2d312e332c226c61756e63685f616e676c655f766572746963616c223a31322e362c22736368656d615f76657273696f6e223a312c22736d6173685f666163746f72223a312e34372c227370696e5f617869735f646567223a2d332e342c227370696e5f72706d223a323338302c2274696d657374616d70223a22323032362d30372d32395431393a34323a31302e313233343536227d", + "frames_hex": [ + "01000000177b2262616c6c5f73706565645f6d70", + "010000011768223a3135312e342c22636c756222", + "01000002173a22647269766572222c22636c7562", + "01000003175f706174685f646567223a322e312c", + "010000041722636c75625f73706565645f6d7068", + "0100000517223a3130332e322c22657374696d61", + "01000006177465645f63617272795f7961726473", + "0100000717223a3236342c226576656e745f6964", + "0100000817223a2242304439314630412d373935", + "0100000917302d344437452d394444352d414639", + "0100000a17373737433139304531222c226c6175", + "0100000b176e63685f616e676c655f686f72697a", + "0100000c176f6e74616c223a2d312e332c226c61", + "0100000d17756e63685f616e676c655f76657274", + "0100000e176963616c223a31322e362c22736368", + "0100000f17656d615f76657273696f6e223a312c", + "010000101722736d6173685f666163746f72223a", + "0100001117312e34372c227370696e5f61786973", + "01000012175f646567223a2d332e342c22737069", + "01000013176e5f72706d223a323338302c227469", + "01000014176d657374616d70223a22323032362d", + "010000151730372d32395431393a34323a31302e", + "0100001617313233343536227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_event_club_changed.json b/tests/fixtures/ble_goldens/v2_event_club_changed.json new file mode 100644 index 000000000..b7a7fa7e3 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_event_club_changed.json @@ -0,0 +1,20 @@ +{ + "name": "v2_event_club_changed", + "description": "club_changed notify on the v2 control characteristic.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 8, + "message": { + "schema_version": 2, + "type": "club_changed", + "club": "7-iron" + }, + "payload_hex": "7b22636c7562223a22372d69726f6e222c22736368656d615f76657273696f6e223a322c2274797065223a22636c75625f6368616e676564227d", + "frames_hex": [ + "01000800047b22636c7562223a22372d69726f6e", + "0100080104222c22736368656d615f7665727369", + "01000802046f6e223a322c2274797065223a2263", + "01000803046c75625f6368616e676564227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_event_power_status.json b/tests/fixtures/ble_goldens/v2_event_power_status.json new file mode 100644 index 000000000..ad759b572 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_event_power_status.json @@ -0,0 +1,39 @@ +{ + "name": "v2_event_power_status", + "description": "power_status notify: the Socket.IO payload beside type.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 6, + "message": { + "schema_version": 2, + "type": "power_status", + "available": true, + "provider": "geekworm", + "state": "on_battery", + "battery_percent": 76.5, + "battery_voltage_v": 3.98, + "external_power": false, + "updated_at": "2026-09-25T14:03:05.000000+00:00", + "error": null + }, + "payload_hex": "7b22617661696c61626c65223a747275652c22626174746572795f70657263656e74223a37362e352c22626174746572795f766f6c746167655f76223a332e39382c226572726f72223a6e756c6c2c2265787465726e616c5f706f776572223a66616c73652c2270726f7669646572223a226765656b776f726d222c22736368656d615f76657273696f6e223a322c227374617465223a226f6e5f62617474657279222c2274797065223a22706f7765725f737461747573222c22757064617465645f6174223a22323032362d30392d32355431343a30333a30352e3030303030302b30303a3030227d", + "frames_hex": [ + "01000600107b22617661696c61626c65223a7472", + "010006011075652c22626174746572795f706572", + "010006021063656e74223a37362e352c22626174", + "0100060310746572795f766f6c746167655f7622", + "01000604103a332e39382c226572726f72223a6e", + "0100060510756c6c2c2265787465726e616c5f70", + "01000606106f776572223a66616c73652c227072", + "01000607106f7669646572223a226765656b776f", + "0100060810726d222c22736368656d615f766572", + "010006091073696f6e223a322c22737461746522", + "0100060a103a226f6e5f62617474657279222c22", + "0100060b1074797065223a22706f7765725f7374", + "0100060c1061747573222c22757064617465645f", + "0100060d106174223a22323032362d30392d3235", + "0100060e105431343a30333a30352e3030303030", + "0100060f10302b30303a3030227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_event_profiles.json b/tests/fixtures/ble_goldens/v2_event_profiles.json new file mode 100644 index 000000000..b851f3fe8 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_event_profiles.json @@ -0,0 +1,41 @@ +{ + "name": "v2_event_profiles", + "description": "profiles notify: ids and names only, UTF-8 (not \\u-escaped) names.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 4, + "message": { + "schema_version": 2, + "type": "profiles", + "profiles": [ + { + "id": "0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b", + "name": "Zoë ⛳" + }, + { + "id": "7c1d9e3f5a2b4c6d8e0f1a3b5c7d9e1f", + "name": "Sam" + } + ], + "active_profile_id": "0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b" + }, + "payload_hex": "7b226163746976655f70726f66696c655f6964223a223066386534623261396337643465316638613662336335643765396631613262222c2270726f66696c6573223a5b7b226964223a223066386534623261396337643465316638613662336335643765396631613262222c226e616d65223a225a6fc3ab20e29bb3227d2c7b226964223a223763316439653366356132623463366438653066316133623563376439653166222c226e616d65223a2253616d227d5d2c22736368656d615f76657273696f6e223a322c2274797065223a2270726f66696c6573227d", + "frames_hex": [ + "010004000f7b226163746976655f70726f66696c", + "010004010f655f6964223a223066386534623261", + "010004020f396337643465316638613662336335", + "010004030f643765396631613262222c2270726f", + "010004040f66696c6573223a5b7b226964223a22", + "010004050f306638653462326139633764346531", + "010004060f663861366233633564376539663161", + "010004070f3262222c226e616d65223a225a6fc3", + "010004080fab20e29bb3227d2c7b226964223a22", + "010004090f376331643965336635613262346336", + "0100040a0f643865306631613362356337643965", + "0100040b0f3166222c226e616d65223a2253616d", + "0100040c0f227d5d2c22736368656d615f766572", + "0100040d0f73696f6e223a322c2274797065223a", + "0100040e0f2270726f66696c6573227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_event_profiles_worst_case.json b/tests/fixtures/ble_goldens/v2_event_profiles_worst_case.json new file mode 100644 index 000000000..b3626a8b4 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_event_profiles_worst_case.json @@ -0,0 +1,307 @@ +{ + "name": "v2_event_profiles_worst_case", + "description": "Twelve 40-character names that each escape to six bytes per character: the largest profiles event the server can send.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 5, + "message": { + "schema_version": 2, + "type": "profiles", + "profiles": [ + { + "id": "00000000000000000000000000000000", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000001", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000002", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000003", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000004", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000005", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000006", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000007", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000008", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000009", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "0000000000000000000000000000000a", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "0000000000000000000000000000000b", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + } + ], + "active_profile_id": "00000000000000000000000000000000" + }, + "payload_hex": "7b226163746976655f70726f66696c655f6964223a223030303030303030303030303030303030303030303030303030303030303030222c2270726f66696c6573223a5b7b226964223a223030303030303030303030303030303030303030303030303030303030303030222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303031222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303032222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303033222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303034222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303035222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303036222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303037222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303038222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303039222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303061222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303062222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d5d2c22736368656d615f76657273696f6e223a322c2274797065223a2270726f66696c6573227d", + "frames_hex": [ + "01000500f17b226163746976655f70726f66696c", + "01000501f1655f6964223a223030303030303030", + "01000502f1303030303030303030303030303030", + "01000503f1303030303030303030222c2270726f", + "01000504f166696c6573223a5b7b226964223a22", + "01000505f1303030303030303030303030303030", + "01000506f1303030303030303030303030303030", + "01000507f13030222c226e616d65223a225c7530", + "01000508f13030315c75303030315c7530303031", + "01000509f15c75303030315c75303030315c7530", + "0100050af13030315c75303030315c7530303031", + "0100050bf15c75303030315c75303030315c7530", + "0100050cf13030315c75303030315c7530303031", + "0100050df15c75303030315c75303030315c7530", + "0100050ef13030315c75303030315c7530303031", + "0100050ff15c75303030315c75303030315c7530", + "01000510f13030315c75303030315c7530303031", + "01000511f15c75303030315c75303030315c7530", + "01000512f13030315c75303030315c7530303031", + "01000513f15c75303030315c75303030315c7530", + "01000514f13030315c75303030315c7530303031", + "01000515f15c75303030315c75303030315c7530", + "01000516f13030315c75303030315c7530303031", + "01000517f15c75303030315c7530303031227d2c", + "01000518f17b226964223a223030303030303030", + "01000519f1303030303030303030303030303030", + "0100051af1303030303030303031222c226e616d", + "0100051bf165223a225c75303030315c75303030", + "0100051cf1315c75303030315c75303030315c75", + "0100051df1303030315c75303030315c75303030", + "0100051ef1315c75303030315c75303030315c75", + "0100051ff1303030315c75303030315c75303030", + "01000520f1315c75303030315c75303030315c75", + "01000521f1303030315c75303030315c75303030", + "01000522f1315c75303030315c75303030315c75", + "01000523f1303030315c75303030315c75303030", + "01000524f1315c75303030315c75303030315c75", + "01000525f1303030315c75303030315c75303030", + "01000526f1315c75303030315c75303030315c75", + "01000527f1303030315c75303030315c75303030", + "01000528f1315c75303030315c75303030315c75", + "01000529f1303030315c75303030315c75303030", + "0100052af1315c75303030315c75303030315c75", + "0100052bf130303031227d2c7b226964223a2230", + "0100052cf1303030303030303030303030303030", + "0100052df1303030303030303030303030303030", + "0100052ef132222c226e616d65223a225c753030", + "0100052ff130315c75303030315c75303030315c", + "01000530f175303030315c75303030315c753030", + "01000531f130315c75303030315c75303030315c", + "01000532f175303030315c75303030315c753030", + "01000533f130315c75303030315c75303030315c", + "01000534f175303030315c75303030315c753030", + "01000535f130315c75303030315c75303030315c", + "01000536f175303030315c75303030315c753030", + "01000537f130315c75303030315c75303030315c", + "01000538f175303030315c75303030315c753030", + "01000539f130315c75303030315c75303030315c", + "0100053af175303030315c75303030315c753030", + "0100053bf130315c75303030315c75303030315c", + "0100053cf175303030315c75303030315c753030", + "0100053df130315c75303030315c75303030315c", + "0100053ef175303030315c7530303031227d2c7b", + "0100053ff1226964223a22303030303030303030", + "01000540f1303030303030303030303030303030", + "01000541f13030303030303033222c226e616d65", + "01000542f1223a225c75303030315c7530303031", + "01000543f15c75303030315c75303030315c7530", + "01000544f13030315c75303030315c7530303031", + "01000545f15c75303030315c75303030315c7530", + "01000546f13030315c75303030315c7530303031", + "01000547f15c75303030315c75303030315c7530", + "01000548f13030315c75303030315c7530303031", + "01000549f15c75303030315c75303030315c7530", + "0100054af13030315c75303030315c7530303031", + "0100054bf15c75303030315c75303030315c7530", + "0100054cf13030315c75303030315c7530303031", + "0100054df15c75303030315c75303030315c7530", + "0100054ef13030315c75303030315c7530303031", + "0100054ff15c75303030315c75303030315c7530", + "01000550f13030315c75303030315c7530303031", + "01000551f15c75303030315c75303030315c7530", + "01000552f1303031227d2c7b226964223a223030", + "01000553f1303030303030303030303030303030", + "01000554f1303030303030303030303030303034", + "01000555f1222c226e616d65223a225c75303030", + "01000556f1315c75303030315c75303030315c75", + "01000557f1303030315c75303030315c75303030", + "01000558f1315c75303030315c75303030315c75", + "01000559f1303030315c75303030315c75303030", + "0100055af1315c75303030315c75303030315c75", + "0100055bf1303030315c75303030315c75303030", + "0100055cf1315c75303030315c75303030315c75", + "0100055df1303030315c75303030315c75303030", + "0100055ef1315c75303030315c75303030315c75", + "0100055ff1303030315c75303030315c75303030", + "01000560f1315c75303030315c75303030315c75", + "01000561f1303030315c75303030315c75303030", + "01000562f1315c75303030315c75303030315c75", + "01000563f1303030315c75303030315c75303030", + "01000564f1315c75303030315c75303030315c75", + "01000565f1303030315c7530303031227d2c7b22", + "01000566f16964223a2230303030303030303030", + "01000567f1303030303030303030303030303030", + "01000568f130303030303035222c226e616d6522", + "01000569f13a225c75303030315c75303030315c", + "0100056af175303030315c75303030315c753030", + "0100056bf130315c75303030315c75303030315c", + "0100056cf175303030315c75303030315c753030", + "0100056df130315c75303030315c75303030315c", + "0100056ef175303030315c75303030315c753030", + "0100056ff130315c75303030315c75303030315c", + "01000570f175303030315c75303030315c753030", + "01000571f130315c75303030315c75303030315c", + "01000572f175303030315c75303030315c753030", + "01000573f130315c75303030315c75303030315c", + "01000574f175303030315c75303030315c753030", + "01000575f130315c75303030315c75303030315c", + "01000576f175303030315c75303030315c753030", + "01000577f130315c75303030315c75303030315c", + "01000578f175303030315c75303030315c753030", + "01000579f13031227d2c7b226964223a22303030", + "0100057af1303030303030303030303030303030", + "0100057bf1303030303030303030303030303622", + "0100057cf12c226e616d65223a225c7530303031", + "0100057df15c75303030315c75303030315c7530", + "0100057ef13030315c75303030315c7530303031", + "0100057ff15c75303030315c75303030315c7530", + "01000580f13030315c75303030315c7530303031", + "01000581f15c75303030315c75303030315c7530", + "01000582f13030315c75303030315c7530303031", + "01000583f15c75303030315c75303030315c7530", + "01000584f13030315c75303030315c7530303031", + "01000585f15c75303030315c75303030315c7530", + "01000586f13030315c75303030315c7530303031", + "01000587f15c75303030315c75303030315c7530", + "01000588f13030315c75303030315c7530303031", + "01000589f15c75303030315c75303030315c7530", + "0100058af13030315c75303030315c7530303031", + "0100058bf15c75303030315c75303030315c7530", + "0100058cf13030315c7530303031227d2c7b2269", + "0100058df164223a223030303030303030303030", + "0100058ef1303030303030303030303030303030", + "0100058ff1303030303037222c226e616d65223a", + "01000590f1225c75303030315c75303030315c75", + "01000591f1303030315c75303030315c75303030", + "01000592f1315c75303030315c75303030315c75", + "01000593f1303030315c75303030315c75303030", + "01000594f1315c75303030315c75303030315c75", + "01000595f1303030315c75303030315c75303030", + "01000596f1315c75303030315c75303030315c75", + "01000597f1303030315c75303030315c75303030", + "01000598f1315c75303030315c75303030315c75", + "01000599f1303030315c75303030315c75303030", + "0100059af1315c75303030315c75303030315c75", + "0100059bf1303030315c75303030315c75303030", + "0100059cf1315c75303030315c75303030315c75", + "0100059df1303030315c75303030315c75303030", + "0100059ef1315c75303030315c75303030315c75", + "0100059ff1303030315c75303030315c75303030", + "010005a0f131227d2c7b226964223a2230303030", + "010005a1f1303030303030303030303030303030", + "010005a2f130303030303030303030303038222c", + "010005a3f1226e616d65223a225c75303030315c", + "010005a4f175303030315c75303030315c753030", + "010005a5f130315c75303030315c75303030315c", + "010005a6f175303030315c75303030315c753030", + "010005a7f130315c75303030315c75303030315c", + "010005a8f175303030315c75303030315c753030", + "010005a9f130315c75303030315c75303030315c", + "010005aaf175303030315c75303030315c753030", + "010005abf130315c75303030315c75303030315c", + "010005acf175303030315c75303030315c753030", + "010005adf130315c75303030315c75303030315c", + "010005aef175303030315c75303030315c753030", + "010005aff130315c75303030315c75303030315c", + "010005b0f175303030315c75303030315c753030", + "010005b1f130315c75303030315c75303030315c", + "010005b2f175303030315c75303030315c753030", + "010005b3f130315c7530303031227d2c7b226964", + "010005b4f1223a22303030303030303030303030", + "010005b5f1303030303030303030303030303030", + "010005b6f13030303039222c226e616d65223a22", + "010005b7f15c75303030315c75303030315c7530", + "010005b8f13030315c75303030315c7530303031", + "010005b9f15c75303030315c75303030315c7530", + "010005baf13030315c75303030315c7530303031", + "010005bbf15c75303030315c75303030315c7530", + "010005bcf13030315c75303030315c7530303031", + "010005bdf15c75303030315c75303030315c7530", + "010005bef13030315c75303030315c7530303031", + "010005bff15c75303030315c75303030315c7530", + "010005c0f13030315c75303030315c7530303031", + "010005c1f15c75303030315c75303030315c7530", + "010005c2f13030315c75303030315c7530303031", + "010005c3f15c75303030315c75303030315c7530", + "010005c4f13030315c75303030315c7530303031", + "010005c5f15c75303030315c75303030315c7530", + "010005c6f13030315c75303030315c7530303031", + "010005c7f1227d2c7b226964223a223030303030", + "010005c8f1303030303030303030303030303030", + "010005c9f1303030303030303030303061222c22", + "010005caf16e616d65223a225c75303030315c75", + "010005cbf1303030315c75303030315c75303030", + "010005ccf1315c75303030315c75303030315c75", + "010005cdf1303030315c75303030315c75303030", + "010005cef1315c75303030315c75303030315c75", + "010005cff1303030315c75303030315c75303030", + "010005d0f1315c75303030315c75303030315c75", + "010005d1f1303030315c75303030315c75303030", + "010005d2f1315c75303030315c75303030315c75", + "010005d3f1303030315c75303030315c75303030", + "010005d4f1315c75303030315c75303030315c75", + "010005d5f1303030315c75303030315c75303030", + "010005d6f1315c75303030315c75303030315c75", + "010005d7f1303030315c75303030315c75303030", + "010005d8f1315c75303030315c75303030315c75", + "010005d9f1303030315c75303030315c75303030", + "010005daf1315c7530303031227d2c7b22696422", + "010005dbf13a2230303030303030303030303030", + "010005dcf1303030303030303030303030303030", + "010005ddf130303062222c226e616d65223a225c", + "010005def175303030315c75303030315c753030", + "010005dff130315c75303030315c75303030315c", + "010005e0f175303030315c75303030315c753030", + "010005e1f130315c75303030315c75303030315c", + "010005e2f175303030315c75303030315c753030", + "010005e3f130315c75303030315c75303030315c", + "010005e4f175303030315c75303030315c753030", + "010005e5f130315c75303030315c75303030315c", + "010005e6f175303030315c75303030315c753030", + "010005e7f130315c75303030315c75303030315c", + "010005e8f175303030315c75303030315c753030", + "010005e9f130315c75303030315c75303030315c", + "010005eaf175303030315c75303030315c753030", + "010005ebf130315c75303030315c75303030315c", + "010005ecf175303030315c75303030315c753030", + "010005edf130315c75303030315c753030303122", + "010005eef17d5d2c22736368656d615f76657273", + "010005eff1696f6e223a322c2274797065223a22", + "010005f0f170726f66696c6573227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_event_session_cleared.json b/tests/fixtures/ble_goldens/v2_event_session_cleared.json new file mode 100644 index 000000000..8d178ca52 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_event_session_cleared.json @@ -0,0 +1,23 @@ +{ + "name": "v2_event_session_cleared", + "description": "session_cleared notify.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 7, + "message": { + "schema_version": 2, + "type": "session_cleared", + "profile_id": "0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b" + }, + "payload_hex": "7b2270726f66696c655f6964223a223066386534623261396337643465316638613662336335643765396631613262222c22736368656d615f76657273696f6e223a322c2274797065223a2273657373696f6e5f636c6561726564227d", + "frames_hex": [ + "01000700077b2270726f66696c655f6964223a22", + "0100070107306638653462326139633764346531", + "0100070207663861366233633564376539663161", + "01000703073262222c22736368656d615f766572", + "010007040773696f6e223a322c2274797065223a", + "01000705072273657373696f6e5f636c65617265", + "010007060764227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_event_shot_processing.json b/tests/fixtures/ble_goldens/v2_event_shot_processing.json new file mode 100644 index 000000000..62989dfa8 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_event_shot_processing.json @@ -0,0 +1,21 @@ +{ + "name": "v2_event_shot_processing", + "description": "shot_processing notify (capturing | calculating | failed).", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 3, + "message": { + "schema_version": 2, + "type": "shot_processing", + "state": "calculating" + }, + "payload_hex": "7b22736368656d615f76657273696f6e223a322c227374617465223a2263616c63756c6174696e67222c2274797065223a2273686f745f70726f63657373696e67227d", + "frames_hex": [ + "01000300057b22736368656d615f76657273696f", + "01000301056e223a322c227374617465223a2263", + "0100030205616c63756c6174696e67222c227479", + "01000303057065223a2273686f745f70726f6365", + "01000304057373696e67227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_response_error.json b/tests/fixtures/ble_goldens/v2_response_error.json new file mode 100644 index 000000000..c8bf89af0 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_response_error.json @@ -0,0 +1,25 @@ +{ + "name": "v2_response_error", + "description": "A failed v2 command.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 2, + "message": { + "schema_version": 2, + "request_id": "5E0F2C4A-8B1D-4C3E-9F6A-7D2B1C0E9A84", + "ok": false, + "error": "Unknown profile" + }, + "payload_hex": "7b226572726f72223a22556e6b6e6f776e2070726f66696c65222c226f6b223a66616c73652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22736368656d615f76657273696f6e223a327d", + "frames_hex": [ + "01000200087b226572726f72223a22556e6b6e6f", + "0100020108776e2070726f66696c65222c226f6b", + "0100020208223a66616c73652c22726571756573", + "0100020308745f6964223a223545304632433441", + "01000204082d384231442d344333452d39463641", + "01000205082d374432423143304539413834222c", + "010002060822736368656d615f76657273696f6e", + "0100020708223a327d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_response_hello.json b/tests/fixtures/ble_goldens/v2_response_hello.json new file mode 100644 index 000000000..946b31308 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_response_hello.json @@ -0,0 +1,55 @@ +{ + "name": "v2_response_hello", + "description": "hello answered on the v2 control characteristic.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 0, + "message": { + "schema_version": 2, + "request_id": "5E0F2C4A-8B1D-4C3E-9F6A-7D2B1C0E9A84", + "ok": true, + "result": { + "schema_version": 2, + "features": [ + "provisional_shots", + "shot_processing", + "profiles", + "power_status", + "session_clear", + "delete_shot", + "club" + ], + "characteristics": { + "shot": "ED365FE6-3ABF-4FC3-8E44-D9525A22DABD", + "control": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474" + } + } + }, + "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636861726163746572697374696373223a7b22636f6e74726f6c223a2237424139364536332d313243322d344345302d424238342d333531334337464431343734222c2273686f74223a2245443336354645362d334142462d344643332d384534342d443935323541323244414244227d2c226665617475726573223a5b2270726f766973696f6e616c5f73686f7473222c2273686f745f70726f63657373696e67222c2270726f66696c6573222c22706f7765725f737461747573222c2273657373696f6e5f636c656172222c2264656c6574655f73686f74222c22636c7562225d2c22736368656d615f76657273696f6e223a327d2c22736368656d615f76657273696f6e223a327d", + "frames_hex": [ + "01000000177b226f6b223a747275652c22726571", + "0100000117756573745f6964223a223545304632", + "01000002174334412d384231442d344333452d39", + "01000003174636412d3744324231433045394138", + "010000041734222c22726573756c74223a7b2263", + "0100000517686172616374657269737469637322", + "01000006173a7b22636f6e74726f6c223a223742", + "01000007174139364536332d313243322d344345", + "0100000817302d424238342d3335313343374644", + "010000091731343734222c2273686f74223a2245", + "0100000a17443336354645362d334142462d3446", + "0100000b1743332d384534342d44393532354132", + "0100000c173244414244227d2c22666561747572", + "0100000d176573223a5b2270726f766973696f6e", + "0100000e17616c5f73686f7473222c2273686f74", + "0100000f175f70726f63657373696e67222c2270", + "0100001017726f66696c6573222c22706f776572", + "01000011175f737461747573222c227365737369", + "01000012176f6e5f636c656172222c2264656c65", + "010000131774655f73686f74222c22636c756222", + "01000014175d2c22736368656d615f7665727369", + "01000015176f6e223a327d2c22736368656d615f", + "010000161776657273696f6e223a327d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_response_set_active_profile.json b/tests/fixtures/ble_goldens/v2_response_set_active_profile.json new file mode 100644 index 000000000..c9b8e43ae --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_response_set_active_profile.json @@ -0,0 +1,32 @@ +{ + "name": "v2_response_set_active_profile", + "description": "set_active_profile accepted.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 1, + "message": { + "schema_version": 2, + "request_id": "5E0F2C4A-8B1D-4C3E-9F6A-7D2B1C0E9A84", + "ok": true, + "result": { + "status": "applied", + "active_profile_id": "7c1d9e3f5a2b4c6d8e0f1a3b5c7d9e1f" + } + }, + "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b226163746976655f70726f66696c655f6964223a223763316439653366356132623463366438653066316133623563376439653166222c22737461747573223a226170706c696564227d2c22736368656d615f76657273696f6e223a327d", + "frames_hex": [ + "010001000c7b226f6b223a747275652c22726571", + "010001010c756573745f6964223a223545304632", + "010001020c4334412d384231442d344333452d39", + "010001030c4636412d3744324231433045394138", + "010001040c34222c22726573756c74223a7b2261", + "010001050c63746976655f70726f66696c655f69", + "010001060c64223a223763316439653366356132", + "010001070c623463366438653066316133623563", + "010001080c376439653166222c22737461747573", + "010001090c223a226170706c696564227d2c2273", + "0100010a0c6368656d615f76657273696f6e223a", + "0100010b0c327d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_shot_final.json b/tests/fixtures/ble_goldens/v2_shot_final.json new file mode 100644 index 000000000..9864864c5 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_shot_final.json @@ -0,0 +1,78 @@ +{ + "name": "v2_shot_final", + "description": "Final v2 shot (tests/fixtures/shot_v2.json).", + "direction": "server_to_client", + "characteristic": "ED365FE6-3ABF-4FC3-8E44-D9525A22DABD", + "schema_version": 2, + "sequence": 1, + "message": { + "ball_speed_mph": 106.1, + "carry_range": [ + 144, + 160 + ], + "club": "7-iron", + "club_path_deg": 2.5, + "club_speed_mph": 83.5, + "enrichment": { + "status": "complete" + }, + "estimated_carry_yards": 152, + "event_id": "05dd37ec-49ed-596b-b1a4-953d54e4f239", + "final": true, + "launch_angle_confidence": 0.6, + "launch_angle_horizontal": -0.7, + "launch_angle_vertical": 21.2, + "profile_id": "0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b", + "profile_name": "Zoë", + "schema_version": 2, + "shot_number": 7, + "smash_factor": 1.27, + "spin_axis_deg": -1.6, + "spin_rpm": 6482, + "spin_source": null, + "timestamp": "2026-09-25T14:03:07.412345", + "type": "shot" + }, + "payload_hex": "7b2262616c6c5f73706565645f6d7068223a3130362e312c2263617272795f72616e6765223a5b3134342c3136305d2c22636c7562223a22372d69726f6e222c22636c75625f706174685f646567223a322e352c22636c75625f73706565645f6d7068223a38332e352c22656e726963686d656e74223a7b22737461747573223a22636f6d706c657465227d2c22657374696d617465645f63617272795f7961726473223a3135322c226576656e745f6964223a2230356464333765632d343965642d353936622d623161342d393533643534653466323339222c2266696e616c223a747275652c226c61756e63685f616e676c655f636f6e666964656e6365223a302e362c226c61756e63685f616e676c655f686f72697a6f6e74616c223a2d302e372c226c61756e63685f616e676c655f766572746963616c223a32312e322c2270726f66696c655f6964223a223066386534623261396337643465316638613662336335643765396631613262222c2270726f66696c655f6e616d65223a225a6fc3ab222c22736368656d615f76657273696f6e223a322c2273686f745f6e756d626572223a372c22736d6173685f666163746f72223a312e32372c227370696e5f617869735f646567223a2d312e362c227370696e5f72706d223a363438322c227370696e5f736f75726365223a6e756c6c2c2274696d657374616d70223a22323032362d30392d32355431343a30333a30372e343132333435222c2274797065223a2273686f74227d", + "frames_hex": [ + "01000100267b2262616c6c5f73706565645f6d70", + "010001012668223a3130362e312c226361727279", + "01000102265f72616e6765223a5b3134342c3136", + "0100010326305d2c22636c7562223a22372d6972", + "01000104266f6e222c22636c75625f706174685f", + "0100010526646567223a322e352c22636c75625f", + "010001062673706565645f6d7068223a38332e35", + "01000107262c22656e726963686d656e74223a7b", + "010001082622737461747573223a22636f6d706c", + "0100010926657465227d2c22657374696d617465", + "0100010a26645f63617272795f7961726473223a", + "0100010b263135322c226576656e745f6964223a", + "0100010c262230356464333765632d343965642d", + "0100010d26353936622d623161342d3935336435", + "0100010e2634653466323339222c2266696e616c", + "0100010f26223a747275652c226c61756e63685f", + "0100011026616e676c655f636f6e666964656e63", + "010001112665223a302e362c226c61756e63685f", + "0100011226616e676c655f686f72697a6f6e7461", + "01000113266c223a2d302e372c226c61756e6368", + "01000114265f616e676c655f766572746963616c", + "0100011526223a32312e322c2270726f66696c65", + "01000116265f6964223a22306638653462326139", + "0100011726633764346531663861366233633564", + "01000118263765396631613262222c2270726f66", + "0100011926696c655f6e616d65223a225a6fc3ab", + "0100011a26222c22736368656d615f7665727369", + "0100011b266f6e223a322c2273686f745f6e756d", + "0100011c26626572223a372c22736d6173685f66", + "0100011d266163746f72223a312e32372c227370", + "0100011e26696e5f617869735f646567223a2d31", + "0100011f262e362c227370696e5f72706d223a36", + "01000120263438322c227370696e5f736f757263", + "010001212665223a6e756c6c2c2274696d657374", + "0100012226616d70223a22323032362d30392d32", + "0100012326355431343a30333a30372e34313233", + "01000124263435222c2274797065223a2273686f", + "010001252674227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_shot_provisional.json b/tests/fixtures/ble_goldens/v2_shot_provisional.json new file mode 100644 index 000000000..b84eccebb --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_shot_provisional.json @@ -0,0 +1,78 @@ +{ + "name": "v2_shot_provisional", + "description": "Provisional OPS-only v2 shot (final:false); same event_id as v2_shot_final.", + "direction": "server_to_client", + "characteristic": "ED365FE6-3ABF-4FC3-8E44-D9525A22DABD", + "schema_version": 2, + "sequence": 0, + "message": { + "schema_version": 2, + "event_id": "05dd37ec-49ed-596b-b1a4-953d54e4f239", + "timestamp": "2026-09-25T14:03:07.412345", + "club": "7-iron", + "ball_speed_mph": 106.1, + "estimated_carry_yards": 152, + "club_speed_mph": 83.5, + "smash_factor": 1.27, + "launch_angle_vertical": 21.2, + "launch_angle_horizontal": -0.7, + "spin_rpm": 6482, + "club_path_deg": 2.5, + "spin_axis_deg": -1.6, + "type": "shot", + "final": false, + "shot_number": 7, + "profile_id": "0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b", + "profile_name": "Zoë", + "carry_range": [ + 144, + 160 + ], + "spin_source": null, + "launch_angle_confidence": 0.6, + "enrichment": { + "status": "pending" + } + }, + "payload_hex": "7b2262616c6c5f73706565645f6d7068223a3130362e312c2263617272795f72616e6765223a5b3134342c3136305d2c22636c7562223a22372d69726f6e222c22636c75625f706174685f646567223a322e352c22636c75625f73706565645f6d7068223a38332e352c22656e726963686d656e74223a7b22737461747573223a2270656e64696e67227d2c22657374696d617465645f63617272795f7961726473223a3135322c226576656e745f6964223a2230356464333765632d343965642d353936622d623161342d393533643534653466323339222c2266696e616c223a66616c73652c226c61756e63685f616e676c655f636f6e666964656e6365223a302e362c226c61756e63685f616e676c655f686f72697a6f6e74616c223a2d302e372c226c61756e63685f616e676c655f766572746963616c223a32312e322c2270726f66696c655f6964223a223066386534623261396337643465316638613662336335643765396631613262222c2270726f66696c655f6e616d65223a225a6fc3ab222c22736368656d615f76657273696f6e223a322c2273686f745f6e756d626572223a372c22736d6173685f666163746f72223a312e32372c227370696e5f617869735f646567223a2d312e362c227370696e5f72706d223a363438322c227370696e5f736f75726365223a6e756c6c2c2274696d657374616d70223a22323032362d30392d32355431343a30333a30372e343132333435222c2274797065223a2273686f74227d", + "frames_hex": [ + "01000000267b2262616c6c5f73706565645f6d70", + "010000012668223a3130362e312c226361727279", + "01000002265f72616e6765223a5b3134342c3136", + "0100000326305d2c22636c7562223a22372d6972", + "01000004266f6e222c22636c75625f706174685f", + "0100000526646567223a322e352c22636c75625f", + "010000062673706565645f6d7068223a38332e35", + "01000007262c22656e726963686d656e74223a7b", + "010000082622737461747573223a2270656e6469", + "01000009266e67227d2c22657374696d61746564", + "0100000a265f63617272795f7961726473223a31", + "0100000b2635322c226576656e745f6964223a22", + "0100000c2630356464333765632d343965642d35", + "0100000d263936622d623161342d393533643534", + "0100000e26653466323339222c2266696e616c22", + "0100000f263a66616c73652c226c61756e63685f", + "0100001026616e676c655f636f6e666964656e63", + "010000112665223a302e362c226c61756e63685f", + "0100001226616e676c655f686f72697a6f6e7461", + "01000013266c223a2d302e372c226c61756e6368", + "01000014265f616e676c655f766572746963616c", + "0100001526223a32312e322c2270726f66696c65", + "01000016265f6964223a22306638653462326139", + "0100001726633764346531663861366233633564", + "01000018263765396631613262222c2270726f66", + "0100001926696c655f6e616d65223a225a6fc3ab", + "0100001a26222c22736368656d615f7665727369", + "0100001b266f6e223a322c2273686f745f6e756d", + "0100001c26626572223a372c22736d6173685f66", + "0100001d266163746f72223a312e32372c227370", + "0100001e26696e5f617869735f646567223a2d31", + "0100001f262e362c227370696e5f72706d223a36", + "01000020263438322c227370696e5f736f757263", + "010000212665223a6e756c6c2c2274696d657374", + "0100002226616d70223a22323032362d30392d32", + "0100002326355431343a30333a30372e34313233", + "01000024263435222c2274797065223a2273686f", + "010000252674227d" + ] +} diff --git a/tests/test_ble_goldens.py b/tests/test_ble_goldens.py new file mode 100644 index 000000000..315555bdc --- /dev/null +++ b/tests/test_ble_goldens.py @@ -0,0 +1,112 @@ +"""Cross-language BLE goldens in tests/fixtures/ble_goldens. + +Server-to-client files must match what the encoder produces today (regenerate +with ``uv run python scripts/ble/generate_goldens.py``). Client-to-server files +are committed by client implementations; they must reassemble to their JSON +and be answered as their ``expect`` block says. +""" + +import importlib.util +import json +from pathlib import Path + +import pytest +from ble_harness import V1_UUIDS, V2_UUIDS, server_loopback + +from openflight.ble.protocol import ( + FRAME_SIZE, + MAX_MESSAGE_SIZE, + parse_fragment, + reassemble_fragments, +) + +ROOT = Path(__file__).resolve().parents[1] +GOLDENS_DIR = ROOT / "tests" / "fixtures" / "ble_goldens" + + +def _load_generator(): + path = ROOT / "scripts" / "ble" / "generate_goldens.py" + spec = importlib.util.spec_from_file_location("generate_ble_goldens", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +GENERATOR = _load_generator() + + +def _files(direction): + for path in sorted(GOLDENS_DIR.glob("*.json")): + golden = json.loads(path.read_text(encoding="utf-8")) + if golden["direction"] == direction: + yield pytest.param(golden, id=path.stem) + + +def test_committed_server_goldens_match_the_encoder(): + expected = GENERATOR.build_goldens() + committed = { + path.stem: path.read_text(encoding="utf-8") + for path in GOLDENS_DIR.glob("*.json") + if json.loads(path.read_text(encoding="utf-8"))["direction"] == "server_to_client" + } + + assert set(committed) == set(expected) + for name, golden in expected.items(): + assert committed[name] == GENERATOR.render(golden), ( + f"{name}.json is stale; run scripts/ble/generate_goldens.py" + ) + + +@pytest.mark.parametrize("golden", list(_files("server_to_client"))) +def test_server_golden_frames_decode_to_their_message(golden): + frames = [bytes.fromhex(item) for item in golden["frames_hex"]] + payload = bytes.fromhex(golden["payload_hex"]) + + assert all(len(frame) <= FRAME_SIZE for frame in frames) + assert {parse_fragment(frame)[0] for frame in frames} == {golden["sequence"]} + assert reassemble_fragments(reversed(frames)) == payload + assert len(payload) <= MAX_MESSAGE_SIZE + assert json.loads(payload.decode("utf-8")) == golden["message"] + assert golden["message"]["schema_version"] == golden["schema_version"] + + +def test_v1_goldens_are_ascii_and_v2_goldens_are_utf8(): + for golden in GENERATOR.build_goldens().values(): + payload = bytes.fromhex(golden["payload_hex"]) + if golden["schema_version"] == 1: + assert payload.isascii() + profiles = GENERATOR.build_goldens()["v2_event_profiles"] + assert "Zoë ⛳".encode() in bytes.fromhex(profiles["payload_hex"]) + + +def test_shot_goldens_share_one_event_id(): + goldens = GENERATOR.build_goldens() + provisional = goldens["v2_shot_provisional"]["message"] + final = goldens["v2_shot_final"]["message"] + + assert provisional["event_id"] == final["event_id"] + assert (provisional["final"], final["final"]) == (False, True) + + +@pytest.mark.parametrize("golden", list(_files("client_to_server"))) +def test_client_golden_reassembles_and_is_dispatched(golden, monkeypatch, tmp_path): + frames = [bytes.fromhex(item) for item in golden["frames_hex"]] + payload = reassemble_fragments(frames) + assert payload == bytes.fromhex(golden["payload_hex"]) + assert json.loads(payload) == golden["message"] + + with server_loopback(monkeypatch, tmp_path) as loopback: + phone = loopback.central("golden-client") + phone.subscribe(*V1_UUIDS, *V2_UUIDS) + for frame in frames: + loopback.write(golden["characteristic"], frame) + response = phone.wait_for( + golden["characteristic"], + lambda message: message.get("request_id") == golden["message"]["request_id"], + ) + + expect = golden["expect"] + assert response["ok"] is expect["ok"] + assert response["schema_version"] == expect["schema_version"] + for key, value in expect.get("result", {}).items(): + assert response["result"][key] == value diff --git a/tests/test_ble_loopback.py b/tests/test_ble_loopback.py new file mode 100644 index 000000000..84717b29c --- /dev/null +++ b/tests/test_ble_loopback.py @@ -0,0 +1,370 @@ +"""End-to-end BLE tests over the loopback harness: no Pi, radio or bless needed. + +Each test drives the real ``BleShotPublisher`` (own thread and event loop) and +the real server command dispatch, with virtual centrals standing in for +phones. A version-one central models jake-fishtech's iOS app and must only +ever see version-one bytes. +""" + +import json +import threading +from datetime import datetime + +import pytest +from ble_harness import V1_UUIDS, V2_UUIDS, server_loopback, settle + +from openflight import server as server_module +from openflight.ble.protocol import ( + CONTROL_CHARACTERISTIC_UUID, + CONTROL_V2_CHARACTERISTIC_UUID, + SHOT_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, + encode_club_event, +) +from openflight.launch_monitor import ClubType, Shot + + +@pytest.fixture +def pi(monkeypatch, tmp_path): + """A monitor-less OpenFlight server with BLE on the loopback harness.""" + with server_loopback(monkeypatch, tmp_path) as loopback: + yield loopback + + +def _slow_enrichment(monkeypatch): + """Take the provisional-then-final path that optional hardware triggers.""" + monkeypatch.setattr(server_module, "camera_capture_runtime", object()) + monkeypatch.setattr(server_module, "shot_enrichment_task", None) + monkeypatch.setattr( + server_module, + "shot_enrichment_queue", + server_module.queue.Queue(maxsize=server_module._SHOT_ENRICHMENT_QUEUE_CAPACITY), + ) + monkeypatch.setattr( + server_module, + "_enrich_shot_from_optional_hardware", + lambda _shot: server_module._ShotEnrichmentResult(camera_capture_ms=5.0), + ) + + def start_background_task(target, *args, **kwargs): + thread = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True) + thread.start() + return thread + + monkeypatch.setattr(server_module.socketio, "start_background_task", start_background_task) + + +def _hardware_shot(second=0): + return Shot( + ball_speed_mph=151.4, + club_speed_mph=103.2, + timestamp=datetime(2026, 9, 25, 12, 0, second, 123456), + impact_timestamp=100.0 + second, + club=ClubType.DRIVER, + ) + + +def _wait_idle(): + with server_module._shot_finalization_condition: + assert server_module._shot_finalization_condition.wait_for( + lambda: ( + not server_module._shot_finalization_order + and not server_module._shot_finalization_running + ), + timeout=5, + ) + + +def test_v1_central_replays_latest_shot_on_subscribe(pi): + pi.publisher.publish( + { + "timestamp": "2026-09-25T12:00:00", + "club": "driver", + "ball_speed_mph": 150.0, + "estimated_carry_yards": 250, + } + ) + phone = pi.central("v1-app") + phone.subscribe(*V1_UUIDS) + + shot = phone.wait_for(SHOT_CHARACTERISTIC_UUID) + assert shot["schema_version"] == 1 + assert shot["ball_speed_mph"] == 150.0 + assert phone.raw(SHOT_CHARACTERISTIC_UUID)[0] == pi.publisher._latest_payload + + +def test_hello_negotiates_on_both_control_characteristics(pi): + phone = pi.central("v2-app") + phone.subscribe(*V1_UUIDS, *V2_UUIDS) + + v1_answer = phone.request("hello", {"client_schema_max": 2}) + v2_answer = phone.request( + "hello", + {"client_schema_max": 2}, + control=CONTROL_V2_CHARACTERISTIC_UUID, + schema_version=2, + ) + + assert v1_answer["schema_version"] == 1 # v1 envelope on the v1 characteristic + assert v1_answer["ok"] is True + assert v1_answer["result"]["schema_version"] == 2 + assert v1_answer["result"]["characteristics"] == { + "shot": SHOT_V2_CHARACTERISTIC_UUID, + "control": CONTROL_V2_CHARACTERISTIC_UUID, + } + assert "profiles" in v1_answer["result"]["features"] + assert v2_answer["schema_version"] == 2 + assert v2_answer["result"] == v1_answer["result"] + + v1_client = phone.request("hello", {"client_schema_max": 1}) + assert v1_client["result"] == {"schema_version": 1, "features": []} + + invalid = phone.request("hello", {"client_schema_max": "two"}) + assert invalid["ok"] is False + assert "client_schema_max" in invalid["error"] + + +def test_provisional_and_final_shot_share_event_id_and_v1_sees_only_final(pi, monkeypatch): + _slow_enrichment(monkeypatch) + v1_phone = pi.central("v1-app") + v2_phone = pi.central("v2-app") + v1_phone.subscribe(*V1_UUIDS) + v2_phone.subscribe(*V2_UUIDS) + + server_module.on_shot_detected(_hardware_shot()) + _wait_idle() + + provisional, final = v2_phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 2) + assert provisional["final"] is False + assert provisional["enrichment"] == {"status": "pending"} + assert final["final"] is True + assert final["enrichment"] == {"status": "complete"} + assert provisional["event_id"] == final["event_id"] + assert final["schema_version"] == 2 and final["type"] == "shot" + assert final["shot_number"] == provisional["shot_number"] is not None + + v1_shot = v1_phone.wait_for(SHOT_CHARACTERISTIC_UUID) + settle(pi) + assert len(v1_phone.decoded(SHOT_CHARACTERISTIC_UUID)) == 1 + assert v1_shot["schema_version"] == 1 + assert "final" not in v1_shot and "type" not in v1_shot + assert v1_shot["event_id"] != final["event_id"] # v1 keeps its per-publish uuid4 + # The v1 central never subscribed to the v2 pair, so it saw nothing there. + assert v1_phone.decoded(SHOT_V2_CHARACTERISTIC_UUID) == [] + assert v1_phone.decoded(CONTROL_V2_CHARACTERISTIC_UUID) == [] + + +def test_club_commands_and_club_changed_reach_each_schema(pi): + v1_phone = pi.central("v1-app") + v2_phone = pi.central("v2-app") + v1_phone.subscribe(*V1_UUIDS) + v2_phone.subscribe(*V2_UUIDS) + + answer = v2_phone.request( + "set_club", + {"club": "7-iron"}, + control=CONTROL_V2_CHARACTERISTIC_UUID, + schema_version=2, + ) + assert answer == { + "schema_version": 2, + "request_id": answer["request_id"], + "ok": True, + "result": {"status": "applied", "club": "7-iron"}, + } + + v1_event = v1_phone.wait_for( + CONTROL_CHARACTERISTIC_UUID, lambda message: message.get("type") == "club_changed" + ) + v2_event = v2_phone.wait_for( + CONTROL_V2_CHARACTERISTIC_UUID, lambda message: message.get("type") == "club_changed" + ) + assert v1_event == {"schema_version": 1, "type": "club_changed", "club": "7-iron"} + assert v2_event == {"schema_version": 2, "type": "club_changed", "club": "7-iron"} + raw_v1_events = [ + raw for raw in v1_phone.raw(CONTROL_CHARACTERISTIC_UUID) if b"club_changed" in raw + ] + assert raw_v1_events == [encode_club_event("7-iron")] + assert ("club_changed", {"club": "7-iron"}) in pi.emitted + + current = v1_phone.request("get_club") + assert current["result"] == {"status": "current", "club": "7-iron"} + + +def test_profiles_commands_route_through_socketio_operations(pi): + store = server_module.get_profile_store() + second = store.add("Sam") + store.set_active(store.list()[0].id) + phone = pi.central("v2-app") + phone.subscribe(*V2_UUIDS) + + ack = phone.request("get_profiles", control=CONTROL_V2_CHARACTERISTIC_UUID, schema_version=2) + assert ack["result"] == {"status": "sent"} + profiles = phone.wait_for( + CONTROL_V2_CHARACTERISTIC_UUID, lambda message: message.get("type") == "profiles" + ) + assert [item["name"] for item in profiles["profiles"]] == ["Profile 1", "Sam"] + assert set(profiles["profiles"][0]) == {"id", "name"} + + answer = phone.request( + "set_active_profile", + {"profile_id": second.id}, + control=CONTROL_V2_CHARACTERISTIC_UUID, + schema_version=2, + ) + assert answer["result"] == {"status": "applied", "active_profile_id": second.id} + assert store.get_active().id == second.id + socket_profiles = [payload for event, payload in pi.emitted if event == "profiles"] + assert socket_profiles[-1]["active_profile_id"] == second.id + + rejected = phone.request( + "set_active_profile", + {"profile_id": "nope"}, + control=CONTROL_V2_CHARACTERISTIC_UUID, + schema_version=2, + ) + assert rejected["ok"] is False + assert rejected["error"] == "Unknown profile" + + +def test_calibration_without_iwr6843_returns_the_409_error(pi): + phone = pi.central("v1-app") + phone.subscribe(*V1_UUIDS) + + answer = phone.request("iwr6843_orientation_calibration", {"mount_tilt_deg": 12.0}) + + assert answer["ok"] is False + assert answer["error"] == "TI IWR6843 radar is not enabled" + + +def test_unknown_and_v2_only_commands_error_on_the_v1_characteristic(pi): + phone = pi.central("v1-app") + phone.subscribe(*V1_UUIDS, *V2_UUIDS) + + unknown = phone.request("launch_rocket") + v2_only_on_v1 = phone.request("get_profiles") + v2_envelope_on_v1 = phone.request("get_club", schema_version=2) + unknown_v2 = phone.request( + "launch_rocket", control=CONTROL_V2_CHARACTERISTIC_UUID, schema_version=2 + ) + + assert unknown == { + "schema_version": 1, + "request_id": unknown["request_id"], + "ok": False, + "error": "Unsupported phone command: launch_rocket", + } + assert v2_only_on_v1["error"] == "Unsupported phone command: get_profiles" + assert v2_envelope_on_v1["error"] == "Unsupported control schema version" + assert unknown_v2["schema_version"] == 2 + assert unknown_v2["error"] == "Unsupported phone command: launch_rocket" + + +def test_v2_session_and_power_commands(pi, monkeypatch): + class _Power: + status = None + + phone = pi.central("v2-app") + phone.subscribe(*V2_UUIDS) + + disabled = phone.request( + "get_power_status", control=CONTROL_V2_CHARACTERISTIC_UUID, schema_version=2 + ) + assert disabled["error"] == "Battery monitoring is not enabled" + monkeypatch.setattr(server_module, "power_monitor", _Power()) + waiting = phone.request( + "get_power_status", control=CONTROL_V2_CHARACTERISTIC_UUID, schema_version=2 + ) + assert waiting["error"] == "No battery reading yet" + + active_id = server_module.get_profile_store().get_active().id + cleared = phone.request( + "clear_session", {}, control=CONTROL_V2_CHARACTERISTIC_UUID, schema_version=2 + ) + assert cleared["result"] == {"status": "cleared", "profile_id": active_id} + event = phone.wait_for( + CONTROL_V2_CHARACTERISTIC_UUID, + lambda message: message.get("type") == "session_cleared", + ) + assert event == {"schema_version": 2, "type": "session_cleared", "profile_id": active_id} + + missing = phone.request( + "delete_shot", + {"timestamp": "2026-09-25T12:00:00"}, + control=CONTROL_V2_CHARACTERISTIC_UUID, + schema_version=2, + ) + assert missing["ok"] is False + assert missing["error"] == "Shot not found" + assert ("delete_shot_error", {"error": "Shot not found"}) in pi.emitted + + +def test_unsubscribing_one_pair_keeps_the_other_flowing(pi): + v1_phone = pi.central("v1-app") + v2_phone = pi.central("v2-app") + v1_phone.subscribe(*V1_UUIDS) + v2_phone.subscribe(*V2_UUIDS) + assert pi.publisher.subscribed and pi.publisher.v2_subscribed + + v2_phone.disconnect() + assert pi.publisher.subscribed + assert not pi.publisher.v2_subscribed + + server_module.apply_club_selection({"club": "pw"}) + event = v1_phone.wait_for( + CONTROL_CHARACTERISTIC_UUID, lambda message: message.get("type") == "club_changed" + ) + assert event["club"] == "pw" + + v1_phone.disconnect() + assert not pi.publisher.subscribed + + +def test_v2_central_gets_latest_v2_shot_replayed(pi): + pi.publisher.publish_v2_shot( + { + "timestamp": "2026-09-25T12:00:00", + "club": "driver", + "ball_speed_mph": 150.0, + "estimated_carry_yards": 250, + "shot_number": 3, + }, + final=True, + ) + phone = pi.central("v2-app") + # Control first, as the negotiation flow does, then the shot characteristic. + phone.subscribe(CONTROL_V2_CHARACTERISTIC_UUID) + settle(pi) + phone.subscribe(SHOT_V2_CHARACTERISTIC_UUID) + + shot = phone.wait_for(SHOT_V2_CHARACTERISTIC_UUID) + + assert shot["shot_number"] == 3 + assert shot["final"] is True + settle(pi) + assert len(phone.decoded(SHOT_V2_CHARACTERISTIC_UUID)) == 1 + + +def test_client_frames_for_two_commands_do_not_interleave_responses(pi): + phone = pi.central("v2-app") + phone.subscribe(*V2_UUIDS) + first = phone.command("get_club", control=CONTROL_V2_CHARACTERISTIC_UUID, schema_version=2) + second = phone.command( + "hello", + {"client_schema_max": 2}, + control=CONTROL_V2_CHARACTERISTIC_UUID, + schema_version=2, + ) + + answers = { + message["request_id"]: message + for message in ( + phone.wait_for(CONTROL_V2_CHARACTERISTIC_UUID, lambda m: m.get("request_id") == first), + phone.wait_for(CONTROL_V2_CHARACTERISTIC_UUID, lambda m: m.get("request_id") == second), + ) + } + assert answers[first]["result"]["club"] == "driver" + assert answers[second]["result"]["schema_version"] == 2 + # Every notification reassembled into valid JSON: no interleaved fragments. + for raw in phone.raw(CONTROL_V2_CHARACTERISTIC_UUID): + json.loads(raw) From 5ca3583fa781e9bbcee630d36b48cef455361409 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Fri, 25 Sep 2026 10:30:02 -0400 Subject: [PATCH 09/19] test(control): isolate the global club and broadcasts per test apply_club_selection writes the module-global active_club and fans out over Socket.IO, SSE and BLE; two tests leaked "pw" and "3-wood" into later tests. An autouse fixture now monkeypatches all four. Co-Authored-By: Claude Opus 5.5 --- tests/test_control_commands.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_control_commands.py b/tests/test_control_commands.py index 9adc42d04..a4cfc02e8 100644 --- a/tests/test_control_commands.py +++ b/tests/test_control_commands.py @@ -1,5 +1,7 @@ """Tests for transport-independent phone control commands.""" +import pytest + from openflight import server as server_module from openflight.launch_monitor import ClubType @@ -21,6 +23,19 @@ def publish_club(self, club): return True +@pytest.fixture(autouse=True) +def _isolate_club_state(monkeypatch): + """Keep club changes and their broadcasts from leaking into other tests. + + ``apply_club_selection`` writes the module-global ``active_club`` and fans + out over Socket.IO, the SSE broker and BLE, so every test gets its own. + """ + monkeypatch.setattr(server_module, "active_club", ClubType.DRIVER) + monkeypatch.setattr(server_module, "shot_stream", _ClubPublisher()) + monkeypatch.setattr(server_module, "ble_publisher", None) + monkeypatch.setattr(server_module.socketio, "emit", lambda *_args, **_kwargs: None) + + def test_apply_club_selection_updates_monitor_and_broadcasts(monkeypatch): monitor = _Monitor() stream = _ClubPublisher() From 0994667bb75a2e5a9535892907d0e3219de7d8ef Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Fri, 25 Sep 2026 10:30:02 -0400 Subject: [PATCH 10/19] docs(ble): document schema v2, get_club, hello and hardware-free testing Documents schema v2 (why a second characteristic pair), its payloads and commands, get_club and hello, the SSE opt-in and testing without hardware; removes ios/ paths and adds the page to the nav and changelog. Co-Authored-By: Claude Opus 5.5 --- docs/changelog.md | 19 +++ docs/ios-ble.md | 312 ++++++++++++++++++++++++++++++++++++++++++---- zensical.toml | 1 + 3 files changed, 311 insertions(+), 21 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index b46a165b5..49579334d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -77,6 +77,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 changes: Socket.IO `set_club` now ignores `unknown` and a missing club (it used to fall back to driver), and a failed Socket.IO shot emit no longer stops BLE, SSE and simulator delivery. +- **BLE and Wi-Fi schema v2 for phone apps.** Version-one traffic is unchanged + byte for byte, so jake-fishtech's iOS app keeps working. v2 lives on a second + shot/control characteristic pair in the same GATT service (BlueZ cannot notify + one central but not another on a shared characteristic; see the design note in + [phone app connection](ios-ble.md#schema-v2-design-decision)). A `hello` + command negotiates it on either control characteristic. v2 shots add + `shot_number`, profile, `carry_range`, `spin_source`, + `launch_angle_confidence`, `final` and `enrichment`, and hardware-enriched + shots now arrive twice, provisional then final, with one `event_id`. v2 phones + also get `shot_processing`, `profiles`, `power_status`, `session_cleared` and + `club_changed` events and can `get_profiles`, `set_active_profile`, + `get_power_status`, `clear_session` and `delete_shot` through the same server + functions Socket.IO uses. `GET /api/shots/stream?schema=2` opts SSE clients + into the same events. BLE delivery now follows per-characteristic + subscriptions, so the latest shot is replayed when a shot characteristic is + subscribed and one phone unsubscribing no longer pauses the others. Tests run + the real publisher against a loopback fake of Bless/BlueZ, and + `tests/fixtures/ble_goldens/` holds framed hex goldens for client test suites + (`scripts/ble/generate_goldens.py`). - **PAR-TEE connector.** `"type": "partee"` in `config/sim.json` streams shots to the [PAR-TEE](https://playpartee.com) iPhone app over OpenConnect V1 on the phone's Wi-Fi address (port 921 by default). Same shared codec as GSPro and diff --git a/docs/ios-ble.md b/docs/ios-ble.md index 4a8d1d9cd..310cf47ec 100644 --- a/docs/ios-ble.md +++ b/docs/ios-ble.md @@ -1,4 +1,4 @@ -# iOS app connection +# Phone app connection (Bluetooth LE and Wi-Fi) > **BLE blocker — check the Raspberry Pi kernel first:** Raspberry Pi kernel > `6.18.34+rpt-rpi-2712` has a confirmed regression that rejects every BLE @@ -6,10 +6,16 @@ > Wi-Fi transport or boot a working kernel such as 6.12.x; there is no userspace > workaround. See the [full diagnosis](#known-bad-raspberry-pi-kernel-61834rpt-rpi-2712). -OpenFlight sends each completed shot from a Raspberry Pi to the included SwiftUI -app over one of two local transports, chosen with the picker at the top of the -app. Both carry the identical versioned payload described below, so the app -behaves the same either way. +OpenFlight sends each completed shot from a Raspberry Pi to a phone app over one +of two local transports. Both carry the identical versioned payload described +below, so an app behaves the same either way. Two apps speak this protocol: + +- jake-fishtech's SwiftUI app, on the + [`feat/iOS-ble` branch of his fork](https://github.com/jake-fishtech/openflight/tree/feat/iOS-ble/ios) + (schema version 1). +- The Kotlin Multiplatform companion for Android and iOS, + [`btripp/openflight-mobile-kmp`](https://github.com/btripp/openflight-mobile-kmp) + (schema version 1, and [schema v2](#schema-v2) where the Pi offers it). | Transport | Pi setup | Use it when | |---|---|---| @@ -26,8 +32,7 @@ browser UI, and exposes nothing the browser UI does not already broadcast. - Mac with Xcode 16 or newer to build the app - The normal OpenFlight radar setup -The app uses only Apple frameworks and has no package dependencies. The Pi -uses [Bless](https://github.com/kevincar/bless) to expose a small GATT server +The Pi uses [Bless](https://github.com/kevincar/bless) to expose a small GATT server through BlueZ. ## Run the Pi @@ -82,10 +87,11 @@ beyond that, so a forgotten `curl` cannot crowd out a phone. ## Build and run the iOS app -For complete Xcode, signing, physical-device, simulator, testing, and -troubleshooting instructions, start with [`ios/README.md`](../ios/README.md). +The SwiftUI app is not part of this repository. For complete Xcode, signing, +physical-device, simulator, testing, and troubleshooting instructions, see +[`ios/README.md` in jake-fishtech's fork](https://github.com/jake-fishtech/openflight/blob/feat/iOS-ble/ios/README.md). -1. Open `ios/OpenFlight.xcodeproj` in Xcode. +1. In a checkout of that branch, open `ios/OpenFlight.xcodeproj` in Xcode. 2. Select the `OpenFlight` target, choose your development team, and use a unique bundle identifier if Xcode requests one. 3. Connect an iPhone, select it as the run destination, and press Run. @@ -194,15 +200,233 @@ has a five-byte, big-endian header followed by up to 15 payload bytes: Consumers should group frames by sequence, ignore duplicate fragment indexes, order fragments by index, and decode only after all fragments arrive. The -shared contract fixture is -`ios/OpenFlightTests/Fixtures/shot_v1.json`; both Python and Swift tests decode -that file. +shared contract fixture is `tests/fixtures/shot_v1.json` in this repository; +the Python tests and the app tests decode that file. Framed byte-level goldens +for every message type live in `tests/fixtures/ble_goldens/` (see +[Testing without hardware](#testing-without-hardware)). Control writes and responses use the same framing. A command contains `schema_version`, a unique `request_id`, a `type`, and a JSON `payload`. The Pi notifies a response with the matching `request_id`, `ok`, and either `result` or -`error`. Version one supports `set_club` and -`iwr6843_orientation_calibration`. +`error`: + +```json +{"payload":{"club":"7-iron"},"request_id":"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","schema_version":1,"type":"set_club"} +{"ok":true,"request_id":"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","result":{"club":"7-iron","status":"applied"},"schema_version":1} +``` + +Responses and unsolicited events share the control characteristic, so match +responses by `request_id` and treat messages with a `type` as events. Version +one supports these commands: + +| Command | Payload | Result | +|---|---|---| +| `set_club` | `{"club":"7-iron"}` | `{"status":"applied","club":"7-iron"}` | +| `get_club` | `{}` | `{"status":"current","club":"7-iron"}`: the Pi-owned club, unchanged | +| `iwr6843_orientation_calibration` | phone gravity measurement | `status, persistent, measured_mount_tilt_deg, enclosure_pitch_deg, configured_iwr_tilt_deg, roll_deg, azimuth_offset_deg` | +| `hello` | `{"client_schema_max":2}` | negotiation, see [schema v2](#schema-v2) | + +Every club change, from any client, is notified on the control characteristic +as `{"club":"7-iron","schema_version":1,"type":"club_changed"}`. Unknown +commands fail with `"error":"Unsupported phone command: "`. + +## Schema v2 + +Schema v2 adds what the browser UI already gets over Socket.IO: profiles, shot +numbering, the shot-processing state, battery status, and provisional shots +that are replaced by their final version. Version one stays exactly as it was, +byte for byte, so the version-one iOS app (whose decoder rejects any +`schema_version` other than `1`) keeps working next to a v2 phone. + +### Schema v2 design decision + +*Status: accepted for review, 2026-09-25.* + +**Question.** Can the Pi send v2 notifications only to centrals that negotiated +v2, on the existing characteristics? + +**Finding.** No, not with Bless 0.3.0 on BlueZ, and not with BlueZ's GATT D-Bus +API at all: + +- A notification is a write of the characteristic's `Value` property + (`BlessServerBlueZDBus.update_value` sets `gatt.Value`, which emits + `PropertiesChanged`). `bluetoothd` then notifies **every** central whose CCCD + is enabled on that characteristic. The D-Bus API has no per-device notify. +- Bless drops the `options` argument of `WriteValue`, which is where BlueZ puts + the writing device's object path, so the server cannot even tell which + central sent `hello`. +- `StartNotify`/`StopNotify` carry no device either. BlueZ calls them once per + characteristic (first subscriber in, last subscriber out). + +CoreBluetooth could target centrals (`updateValue:forCharacteristic:onSubscribedCentrals:`), +but Bless passes `nil` (all centrals), and macOS is not a deployment target. + +**Decision.** Schema v2 gets its own shot and control characteristics in the +same service. A v1 central never subscribes to them, so BlueZ never delivers a +v2 frame to it, and the v1 characteristics carry exactly the v1 traffic they +always did. `hello` works on both control characteristics, so a client can +negotiate before it commits to a pair, and an older Pi answers it with +`Unsupported phone command: hello`. + +**Consequences.** + +- One GATT service now has four characteristics. Discovery of the v2 pair is + itself a capability signal. +- A final shot is sent twice over the air when a v1 and a v2 phone are + connected at once (once per pair). The Pi's radio time is not the bottleneck + at golf-shot rates. +- A v2 phone that subscribes to both pairs gets both copies; clients subscribe + to one pair only. +- Per-characteristic subscription state now drives delivery. The publisher + reads Bless's `app.subscribed_characteristics` after each `StartNotify` / + `StopNotify`, so v2 frames are only pushed while a v2 central is subscribed + and a v2 phone unsubscribing no longer stops v1 delivery. + +**Rejected.** + +- *Per-central state on shared characteristics:* impossible here (above). +- *A v2 flag inside v1 messages:* the v1 decoder rejects unknown + `schema_version` values, and new fields would still reach v1 phones. +- *A second GATT service:* works, but an extra service UUID in the + advertisement costs scarce advertising bytes and buys nothing over two more + characteristics. + +### Characteristics + +| Attribute | UUID | Properties | +|---|---|---| +| Shot notification, v2 | `ED365FE6-3ABF-4FC3-8E44-D9525A22DABD` | notify | +| Control, v2 | `7BA96E63-12C2-4CE0-BB84-3513C7FD1474` | write with response, notify | + +Both live in the same service, `B6F633F2-E6E3-45AE-84B4-968ECCA2D9C7`, and use +the same 20-byte framing as version one. Sequence numbers are counted +separately per characteristic. + +**Encoding.** v2 messages are compact JSON with sorted keys, like version one, +but text is **UTF-8** instead of `\uXXXX` escapes (that is what lets twelve +40-character profile names fit in one message). A fragment boundary can split a +multi-byte character, so decode UTF-8 only after reassembling the whole +message. + +### Negotiation + +1. Discover the service. If the v2 characteristics are missing, the Pi is + version one: use the v1 pair. +2. Subscribe to the v2 control characteristic and write `hello`: + ```json + {"payload":{"client_schema_max":2},"request_id":"","schema_version":2,"type":"hello"} + ``` + The result names the negotiated schema, the features and the v2 pair: + ```json + {"ok":true,"request_id":"","result":{"characteristics":{"control":"7BA96E63-12C2-4CE0-BB84-3513C7FD1474","shot":"ED365FE6-3ABF-4FC3-8E44-D9525A22DABD"},"features":["provisional_shots","shot_processing","profiles","power_status","session_clear","delete_shot","club"],"schema_version":2},"schema_version":2} + ``` +3. Subscribe to the v2 shot characteristic. The latest v2 shot is replayed. +4. Ask for state: `get_club`, `get_profiles` and, if wanted, `get_power_status`. + +`hello` also works on the v1 control characteristic, in a v1 envelope +(`"schema_version":1`); the response envelope is then v1 while the `result` is +the same. An older Pi answers `ok:false` with +`Unsupported phone command: hello`. Treat that, or no answer within 10 seconds, +as version one. `client_schema_max: 1` returns `{"schema_version":1,"features":[]}`. +The v2 control characteristic accepts envelopes with `schema_version` 1 or 2 +and always answers with `schema_version: 2`. + +### v2 shot + +Sent on the v2 shot characteristic. It carries every version-one field, plus: + +| Field | Type | Meaning | +|---|---|---| +| `type` | `"shot"` | | +| `final` | bool | `false`: OPS-only provisional shot, sent while optional hardware (IWR6843, camera) is still working. `true`: the final shot | +| `event_id` | UUID string | Stable per shot: the provisional and final versions of one shot share it. **Upsert by `event_id`** | +| `shot_number` | int or null | Per-monitor-run sequence; not reused after a delete | +| `profile_id`, `profile_name` | string or null | Profile the shot was attributed to at detection | +| `carry_range` | `[low, high]` or null | Carry range in yards | +| `spin_source` | string or null | Where `spin_rpm` came from | +| `launch_angle_confidence` | number or null | 0–1 | +| `enrichment` | object or null | `{"status":"pending"}` on a provisional shot; `{"status":"complete"}` or `{"status":"skipped","reason":"deadline"|"capacity"|"queue_full"|"worker_unavailable"}` on a final shot that had a provisional; `null` when the shot never waited for optional hardware | + +Every key is always present; unknown values are `null`. A shot with no optional +hardware configured is sent once, final. The provisional shot is not sent at all +to v1 phones, which only ever receive final shots. The contract fixture is +`tests/fixtures/shot_v2.json`, built from a real mock shot: + +```json +{"ball_speed_mph":106.1,"carry_range":[144,160],"club":"7-iron","club_path_deg":2.5,"club_speed_mph":83.5,"enrichment":{"status":"complete"},"estimated_carry_yards":152,"event_id":"05dd37ec-49ed-596b-b1a4-953d54e4f239","final":true,"launch_angle_confidence":0.6,"launch_angle_horizontal":-0.7,"launch_angle_vertical":21.2,"profile_id":"0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b","profile_name":"Zoë","schema_version":2,"shot_number":7,"smash_factor":1.27,"spin_axis_deg":-1.6,"spin_rpm":6482,"spin_source":null,"timestamp":"2026-09-25T14:03:07.412345","type":"shot"} +``` + +### v2 events + +Notified on the v2 control characteristic. Each has `schema_version: 2` and a +`type`, and never a `request_id`: + +| `type` | Fields | When | +|---|---|---| +| `club_changed` | `club` | Any club change (kiosk, phone, simulator) | +| `profiles` | `profiles: [{id, name}]`, `active_profile_id` | After every profile request or mutation from any client, including rejected ones | +| `session_cleared` | `profile_id` | After any client clears a profile's shots | +| `shot_processing` | `state`: `capturing`, `calculating` or `failed` | Rolling-buffer monitor progress; the next shot ends it | +| `power_status` | the Socket.IO `power_status` payload: `available, provider, state, battery_percent, battery_voltage_v, external_power, updated_at, error` | Every 5 s with `--battery geekworm` | + +```json +{"club":"7-iron","schema_version":2,"type":"club_changed"} +{"active_profile_id":"0f8e…","profiles":[{"id":"0f8e…","name":"Zoë ⛳"},{"id":"7c1d…","name":"Sam"}],"schema_version":2,"type":"profiles"} +{"profile_id":"0f8e…","schema_version":2,"type":"session_cleared"} +{"schema_version":2,"state":"calculating","type":"shot_processing"} +{"available":true,"battery_percent":76.5,"battery_voltage_v":3.98,"error":null,"external_power":false,"provider":"geekworm","schema_version":2,"state":"on_battery","type":"power_status","updated_at":"2026-09-25T14:03:05.000000+00:00"} +``` + +Profiles over BLE carry only `id` and `name`. `created_at` and the open-ended +`settings` stay on Socket.IO, because the phone only selects profiles here and +an unbounded `settings` object could not be guaranteed to fit in one message. + +### v2 commands + +Each v2 command calls the same server function as its Socket.IO counterpart, so +the kiosk and every other client see the same broadcasts. + +| Command | Payload | Result | Also broadcasts | +|---|---|---|---| +| `hello` | `{"client_schema_max":2}` | see [Negotiation](#negotiation) | | +| `get_club` | `{}` | `{"status":"current","club":…}` | | +| `set_club` | `{"club":"7-iron"}` | `{"status":"applied","club":…}` | `club_changed` | +| `iwr6843_orientation_calibration` | as version one | as version one | | +| `get_profiles` | `{}` | `{"status":"sent"}` | `profiles`: the roster arrives as the event, not in the result | +| `set_active_profile` | `{"profile_id":…}` | `{"status":"applied","active_profile_id":…}`, or `ok:false` `Unknown profile` | `profiles` (also when rejected) | +| `get_power_status` | `{}` | the `power_status` payload, or `ok:false` `Battery monitoring is not enabled` / `No battery reading yet` | | +| `clear_session` | `{"profile_id":…}` (default: active profile) | `{"status":"cleared","profile_id":…}` | `session_cleared` | +| `delete_shot` | `{"timestamp":""}` | `{"status":"deleted","timestamp":…}`, or `ok:false` `Shot not found` | Socket.IO `session_state` | + +Adding, renaming and removing profiles stay on Socket.IO and the kiosk. An event +triggered by a command is normally notified before the command's response, but +clients must accept either order. The Pi processes commands as they arrive and +enforces no busy state or timeout of its own; clients own their timeouts. +Version-one commands keep working on the v1 control characteristic, and v2-only +commands sent there fail with `Unsupported phone command`. + +### Wi-Fi: `?schema=2` + +`GET /api/shots/stream?schema=2` opts a Server-Sent Events client into schema +v2. The default (no parameter, or `schema=1`) is the unchanged version-one +stream; any other value returns `400`. A v2 stream opens with `: ping`, then +the current state as `club_changed`, `profiles` and (with a battery monitor) +`power_status`, then the latest v2 shot. Event names match the `type` of the +payload: `shot`, `shot_processing`, `profiles`, `power_status`, +`session_cleared` and `club_changed`. Commands stay on `/api/club`, the +calibration route and Socket.IO. + +```bash +curl -N 'http://raspberrypi.local:8080/api/shots/stream?schema=2' +``` + +### Size budget + +A BLE message is at most 255 fragments × 15 bytes = 3,825 bytes. Tests encode +a worst-case v2 shot (longest float representations everywhere, a 40-character +profile name of six-byte escapes) and a `profiles` event with twelve such names +(3,610 bytes) and require both to fit. The publisher refuses, and logs, any +message that would not fit instead of sending a truncated one. ## Delivery behavior @@ -214,6 +438,8 @@ notifies a response with the matching `request_id`, `ok`, and either `result` or - Disconnecting clears that client's queue but retains the latest completed shot for replay on the next connection. - The iOS app ignores a replayed event when its `event_id` is already visible. + v2 clients upsert by `event_id`, which also merges a provisional shot with + its final version. ## Security and scope @@ -224,8 +450,52 @@ Wi-Fi API as accessible to anything on the same network — the same assumption the browser UI already makes. Phone-assisted calibration can update and persist TI mount tilt, so use either transport only in a trusted environment. -Add authenticated pairing before expanding the control channel to sensitive or -destructive operations. +Schema v2 adds two destructive commands, `clear_session` and `delete_shot`, +with the same exposure the browser UI's Socket.IO already has on the local +network, but now also reachable by any nearby Bluetooth device. Authenticated +pairing is still future work; until then enable `--ble` only where that is +acceptable. + +## Testing without hardware + +Everything above the radio is covered by the normal test suite, with no Pi, +Bluetooth adapter or `bless` install: + +- `tests/ble_harness.py` runs the real `BleShotPublisher` on its own thread and + event loop against a fake Bless server that behaves like Bless 0.3.0 on + BlueZ (subscription hooks called before `app.subscribed_characteristics` + changes, notifications delivered only to centrals subscribed to that + characteristic, writes through `write_request_func`). `VirtualCentral`s play + the phones: they subscribe, write framed commands and reassemble + notifications with the real reassembler. +- `tests/test_ble_loopback.py` uses it end to end against the real server + dispatch: latest-shot replay, `hello` on both control characteristics, a + provisional-then-final shot (v2 phone gets both with one `event_id`, a v1 + phone next to it gets only the v1 final shot), club and profile commands, + the calibration `409` path, unknown commands and pair-by-pair unsubscribe. +- `tests/fixtures/ble_goldens/*.json` hold framed hex for every message type. + `server_to_client` files are generated by + `uv run python scripts/ble/generate_goldens.py` and checked by + `tests/test_ble_goldens.py`; client test suites decode them. + `client_to_server` files are the reverse: a client commits the frames its + own encoder produces (`name`, `characteristic`, `sequence`, `message`, + `payload_hex`, `frames_hex`, and an `expect` block with `ok`, + `schema_version` and expected `result` fields), and the Python tests + reassemble them, dispatch them through the loopback server and check the + answer. + +```bash +uv run pytest tests/test_ble_protocol.py tests/test_ble_protocol_v2.py \ + tests/test_ble_publisher.py tests/test_ble_loopback.py tests/test_ble_goldens.py \ + tests/test_shot_stream.py tests/test_phone_transport_server.py \ + tests/test_phone_transport_v2.py tests/test_control_commands.py -v +uv run python scripts/ble/generate_goldens.py --check +``` + +What still needs a Pi and phones: BlueZ advertising, discovery and +connection from iOS and Android, pairing and permission prompts, fragment +pacing over a real link, reconnects after a Pi restart, background behaviour, +and coexistence with Wi-Fi and Socket.IO clients. ## Troubleshooting @@ -314,11 +584,11 @@ above: it needs no Bluetooth and delivers the identical payload. ## Automated tests -```bash -uv run pytest tests/test_ble_protocol.py tests/test_ble_publisher.py \ - tests/test_shot_stream.py tests/test_phone_orientation_calibration.py \ - tests/test_control_commands.py -v +The Python side is covered in [Testing without hardware](#testing-without-hardware). +The SwiftUI app's tests run from a checkout of jake-fishtech's `feat/iOS-ble` +branch: +```bash xcodebuild test \ -project ios/OpenFlight.xcodeproj \ -scheme OpenFlight \ diff --git a/zensical.toml b/zensical.toml index 45e03b636..e384fd21e 100644 --- a/zensical.toml +++ b/zensical.toml @@ -71,6 +71,7 @@ nav = [ "using/cloud-sync.md", "using/battery.md", "using/observability.md", + "ios-ble.md", ] }, { "How it works" = [ From 96cab45a24a31a3f0787b3d2fe6e4314d0824247 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Fri, 25 Sep 2026 11:09:00 -0400 Subject: [PATCH 11/19] feat(ble): keep BLE v2 read-and-select only, add shot_deleted BLE is unauthenticated, so clear_session and delete_shot are refused over Bluetooth and stay on Socket.IO. A Socket.IO delete_shot now notifies shot_deleted to BLE v2 and SSE ?schema=2 clients. Goldens regenerated. Co-Authored-By: Claude Opus 5.5 --- scripts/ble/generate_goldens.py | 8 +++ src/openflight/ble/protocol.py | 11 +++- src/openflight/server.py | 12 ++-- .../ble_goldens/v1_response_hello.json | 50 ++++++++-------- .../ble_goldens/v2_event_shot_deleted.json | 22 +++++++ .../ble_goldens/v2_response_hello.json | 50 ++++++++-------- tests/test_ble_loopback.py | 59 ++++++++++++++----- tests/test_ble_protocol_v2.py | 19 ++++++ tests/test_phone_transport_v2.py | 45 +++++++++----- 9 files changed, 189 insertions(+), 87 deletions(-) create mode 100644 tests/fixtures/ble_goldens/v2_event_shot_deleted.json diff --git a/scripts/ble/generate_goldens.py b/scripts/ble/generate_goldens.py index c8b573e99..803423c0b 100644 --- a/scripts/ble/generate_goldens.py +++ b/scripts/ble/generate_goldens.py @@ -38,6 +38,7 @@ build_power_status_event, build_profiles_event, build_session_cleared_event, + build_shot_deleted_event, build_shot_event_v2, build_shot_processing_event, encode_club_event, @@ -266,6 +267,13 @@ def build_goldens() -> dict[str, dict]: build_session_cleared_event(PROFILE_A), 7, ), + _v2( + "v2_event_shot_deleted", + "shot_deleted notify: a shot was deleted (over Wi-Fi/Socket.IO); key is its timestamp.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_shot_deleted_event(shot_v2["timestamp"]), + 9, + ), _v2( "v2_event_club_changed", "club_changed notify on the v2 control characteristic.", diff --git a/src/openflight/ble/protocol.py b/src/openflight/ble/protocol.py index 67728f32f..5ee1251e3 100644 --- a/src/openflight/ble/protocol.py +++ b/src/openflight/ble/protocol.py @@ -58,8 +58,7 @@ "shot_processing", "profiles", "power_status", - "session_clear", - "delete_shot", + "shot_deleted", "club", ) @@ -69,6 +68,7 @@ "profiles", "power_status", "session_cleared", + "shot_deleted", "club_changed", ) @@ -286,6 +286,13 @@ def build_control_response( return response +def build_shot_deleted_event(timestamp: str) -> dict: + """``shot_deleted``: the timestamp (the delete key) of a removed shot.""" + if not isinstance(timestamp, str) or not timestamp: + raise ValueError("Deleted shot timestamp must be a non-empty string") + return build_event_v2("shot_deleted", {"timestamp": timestamp}) + + def build_hello_result(client_schema_max) -> dict: """Answer a ``hello`` command with the negotiated schema and features.""" if isinstance(client_schema_max, bool) or not isinstance(client_schema_max, int): diff --git a/src/openflight/server.py b/src/openflight/server.py index 4c1e2f64e..3ff8f27ca 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -30,6 +30,7 @@ build_power_status_event, build_profiles_event, build_session_cleared_event, + build_shot_deleted_event, build_shot_processing_event, ) from .clubs import ClubType @@ -1173,8 +1174,12 @@ def dispatch_phone_control_command_v2(command_type, payload): """Route a schema v2 BLE phone command through the Socket.IO operations. Each command calls the same function its Socket.IO counterpart does, so the - kiosk and every other client see identical broadcasts. Profile add, rename - and remove deliberately stay on Socket.IO. + kiosk and every other client see identical broadcasts. + + BLE has no authentication, so it is read-and-select only: profile add, + rename and remove, ``clear_session`` and ``delete_shot`` stay on + Socket.IO. Phones still hear about those changes through the + ``profiles``, ``session_cleared`` and ``shot_deleted`` events. """ handlers = { "iwr6843_orientation_calibration": apply_iwr6843_orientation_calibration, @@ -1183,8 +1188,6 @@ def dispatch_phone_control_command_v2(command_type, payload): "get_profiles": request_profiles, "set_active_profile": apply_active_profile, "get_power_status": current_power_status, - "clear_session": apply_clear_session, - "delete_shot": apply_delete_shot, } handler = handlers.get(command_type) if handler is None: @@ -2303,6 +2306,7 @@ def apply_delete_shot(payload): return {"error": "Shot not found"}, 404 socketio.emit("session_state", _session_state_payload()) + _publish_phone_event(build_shot_deleted_event(timestamp)) return {"status": "deleted", "timestamp": timestamp}, 200 diff --git a/tests/fixtures/ble_goldens/v1_response_hello.json b/tests/fixtures/ble_goldens/v1_response_hello.json index 42449fe8f..4e6e1c715 100644 --- a/tests/fixtures/ble_goldens/v1_response_hello.json +++ b/tests/fixtures/ble_goldens/v1_response_hello.json @@ -16,8 +16,7 @@ "shot_processing", "profiles", "power_status", - "session_clear", - "delete_shot", + "shot_deleted", "club" ], "characteristics": { @@ -26,30 +25,29 @@ } } }, - "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636861726163746572697374696373223a7b22636f6e74726f6c223a2237424139364536332d313243322d344345302d424238342d333531334337464431343734222c2273686f74223a2245443336354645362d334142462d344643332d384534342d443935323541323244414244227d2c226665617475726573223a5b2270726f766973696f6e616c5f73686f7473222c2273686f745f70726f63657373696e67222c2270726f66696c6573222c22706f7765725f737461747573222c2273657373696f6e5f636c656172222c2264656c6574655f73686f74222c22636c7562225d2c22736368656d615f76657273696f6e223a327d2c22736368656d615f76657273696f6e223a317d", + "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636861726163746572697374696373223a7b22636f6e74726f6c223a2237424139364536332d313243322d344345302d424238342d333531334337464431343734222c2273686f74223a2245443336354645362d334142462d344643332d384534342d443935323541323244414244227d2c226665617475726573223a5b2270726f766973696f6e616c5f73686f7473222c2273686f745f70726f63657373696e67222c2270726f66696c6573222c22706f7765725f737461747573222c2273686f745f64656c65746564222c22636c7562225d2c22736368656d615f76657273696f6e223a327d2c22736368656d615f76657273696f6e223a317d", "frames_hex": [ - "01000300177b226f6b223a747275652c22726571", - "0100030117756573745f6964223a223545304632", - "01000302174334412d384231442d344333452d39", - "01000303174636412d3744324231433045394138", - "010003041734222c22726573756c74223a7b2263", - "0100030517686172616374657269737469637322", - "01000306173a7b22636f6e74726f6c223a223742", - "01000307174139364536332d313243322d344345", - "0100030817302d424238342d3335313343374644", - "010003091731343734222c2273686f74223a2245", - "0100030a17443336354645362d334142462d3446", - "0100030b1743332d384534342d44393532354132", - "0100030c173244414244227d2c22666561747572", - "0100030d176573223a5b2270726f766973696f6e", - "0100030e17616c5f73686f7473222c2273686f74", - "0100030f175f70726f63657373696e67222c2270", - "0100031017726f66696c6573222c22706f776572", - "01000311175f737461747573222c227365737369", - "01000312176f6e5f636c656172222c2264656c65", - "010003131774655f73686f74222c22636c756222", - "01000314175d2c22736368656d615f7665727369", - "01000315176f6e223a327d2c22736368656d615f", - "010003161776657273696f6e223a317d" + "01000300167b226f6b223a747275652c22726571", + "0100030116756573745f6964223a223545304632", + "01000302164334412d384231442d344333452d39", + "01000303164636412d3744324231433045394138", + "010003041634222c22726573756c74223a7b2263", + "0100030516686172616374657269737469637322", + "01000306163a7b22636f6e74726f6c223a223742", + "01000307164139364536332d313243322d344345", + "0100030816302d424238342d3335313343374644", + "010003091631343734222c2273686f74223a2245", + "0100030a16443336354645362d334142462d3446", + "0100030b1643332d384534342d44393532354132", + "0100030c163244414244227d2c22666561747572", + "0100030d166573223a5b2270726f766973696f6e", + "0100030e16616c5f73686f7473222c2273686f74", + "0100030f165f70726f63657373696e67222c2270", + "0100031016726f66696c6573222c22706f776572", + "01000311165f737461747573222c2273686f745f", + "010003121664656c65746564222c22636c756222", + "01000313165d2c22736368656d615f7665727369", + "01000314166f6e223a327d2c22736368656d615f", + "010003151676657273696f6e223a317d" ] } diff --git a/tests/fixtures/ble_goldens/v2_event_shot_deleted.json b/tests/fixtures/ble_goldens/v2_event_shot_deleted.json new file mode 100644 index 000000000..5543fd6be --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_event_shot_deleted.json @@ -0,0 +1,22 @@ +{ + "name": "v2_event_shot_deleted", + "description": "shot_deleted notify: a shot was deleted (over Wi-Fi/Socket.IO); key is its timestamp.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 9, + "message": { + "schema_version": 2, + "type": "shot_deleted", + "timestamp": "2026-09-25T14:03:07.412345" + }, + "payload_hex": "7b22736368656d615f76657273696f6e223a322c2274696d657374616d70223a22323032362d30392d32355431343a30333a30372e343132333435222c2274797065223a2273686f745f64656c65746564227d", + "frames_hex": [ + "01000900067b22736368656d615f76657273696f", + "01000901066e223a322c2274696d657374616d70", + "0100090206223a22323032362d30392d32355431", + "0100090306343a30333a30372e34313233343522", + "01000904062c2274797065223a2273686f745f64", + "0100090506656c65746564227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_response_hello.json b/tests/fixtures/ble_goldens/v2_response_hello.json index 946b31308..26298b2c1 100644 --- a/tests/fixtures/ble_goldens/v2_response_hello.json +++ b/tests/fixtures/ble_goldens/v2_response_hello.json @@ -16,8 +16,7 @@ "shot_processing", "profiles", "power_status", - "session_clear", - "delete_shot", + "shot_deleted", "club" ], "characteristics": { @@ -26,30 +25,29 @@ } } }, - "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636861726163746572697374696373223a7b22636f6e74726f6c223a2237424139364536332d313243322d344345302d424238342d333531334337464431343734222c2273686f74223a2245443336354645362d334142462d344643332d384534342d443935323541323244414244227d2c226665617475726573223a5b2270726f766973696f6e616c5f73686f7473222c2273686f745f70726f63657373696e67222c2270726f66696c6573222c22706f7765725f737461747573222c2273657373696f6e5f636c656172222c2264656c6574655f73686f74222c22636c7562225d2c22736368656d615f76657273696f6e223a327d2c22736368656d615f76657273696f6e223a327d", + "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636861726163746572697374696373223a7b22636f6e74726f6c223a2237424139364536332d313243322d344345302d424238342d333531334337464431343734222c2273686f74223a2245443336354645362d334142462d344643332d384534342d443935323541323244414244227d2c226665617475726573223a5b2270726f766973696f6e616c5f73686f7473222c2273686f745f70726f63657373696e67222c2270726f66696c6573222c22706f7765725f737461747573222c2273686f745f64656c65746564222c22636c7562225d2c22736368656d615f76657273696f6e223a327d2c22736368656d615f76657273696f6e223a327d", "frames_hex": [ - "01000000177b226f6b223a747275652c22726571", - "0100000117756573745f6964223a223545304632", - "01000002174334412d384231442d344333452d39", - "01000003174636412d3744324231433045394138", - "010000041734222c22726573756c74223a7b2263", - "0100000517686172616374657269737469637322", - "01000006173a7b22636f6e74726f6c223a223742", - "01000007174139364536332d313243322d344345", - "0100000817302d424238342d3335313343374644", - "010000091731343734222c2273686f74223a2245", - "0100000a17443336354645362d334142462d3446", - "0100000b1743332d384534342d44393532354132", - "0100000c173244414244227d2c22666561747572", - "0100000d176573223a5b2270726f766973696f6e", - "0100000e17616c5f73686f7473222c2273686f74", - "0100000f175f70726f63657373696e67222c2270", - "0100001017726f66696c6573222c22706f776572", - "01000011175f737461747573222c227365737369", - "01000012176f6e5f636c656172222c2264656c65", - "010000131774655f73686f74222c22636c756222", - "01000014175d2c22736368656d615f7665727369", - "01000015176f6e223a327d2c22736368656d615f", - "010000161776657273696f6e223a327d" + "01000000167b226f6b223a747275652c22726571", + "0100000116756573745f6964223a223545304632", + "01000002164334412d384231442d344333452d39", + "01000003164636412d3744324231433045394138", + "010000041634222c22726573756c74223a7b2263", + "0100000516686172616374657269737469637322", + "01000006163a7b22636f6e74726f6c223a223742", + "01000007164139364536332d313243322d344345", + "0100000816302d424238342d3335313343374644", + "010000091631343734222c2273686f74223a2245", + "0100000a16443336354645362d334142462d3446", + "0100000b1643332d384534342d44393532354132", + "0100000c163244414244227d2c22666561747572", + "0100000d166573223a5b2270726f766973696f6e", + "0100000e16616c5f73686f7473222c2273686f74", + "0100000f165f70726f63657373696e67222c2270", + "0100001016726f66696c6573222c22706f776572", + "01000011165f737461747573222c2273686f745f", + "010000121664656c65746564222c22636c756222", + "01000013165d2c22736368656d615f7665727369", + "01000014166f6e223a327d2c22736368656d615f", + "010000151676657273696f6e223a327d" ] } diff --git a/tests/test_ble_loopback.py b/tests/test_ble_loopback.py index 84717b29c..46ee4ef99 100644 --- a/tests/test_ble_loopback.py +++ b/tests/test_ble_loopback.py @@ -277,26 +277,55 @@ class _Power: ) assert waiting["error"] == "No battery reading yet" + +def test_destructive_commands_are_not_available_over_ble(pi): + """BLE is unauthenticated, so it is read-and-select only.""" + phone = pi.central("v2-app") + phone.subscribe(*V1_UUIDS, *V2_UUIDS) + + for command, payload in ( + ("clear_session", {}), + ("delete_shot", {"timestamp": "2026-09-25T12:00:00"}), + ): + for control, schema in ( + (CONTROL_CHARACTERISTIC_UUID, 1), + (CONTROL_V2_CHARACTERISTIC_UUID, 2), + ): + answer = phone.request(command, payload, control=control, schema_version=schema) + assert answer["ok"] is False + assert answer["error"] == f"Unsupported phone command: {command}" + assert pi.emitted == [] + + +def test_wifi_clear_and_delete_reach_ble_phones_as_events(pi, monkeypatch): + shot = Shot( + ball_speed_mph=150.0, + timestamp=datetime(2026, 9, 25, 12, 0, 0, 5), + club=ClubType.DRIVER, + ) + monitor = server_module.MockLaunchMonitor() + monitor._shots.append(shot) + monkeypatch.setattr(server_module, "monitor", monitor) + phone = pi.central("v2-app") + phone.subscribe(*V2_UUIDS) active_id = server_module.get_profile_store().get_active().id - cleared = phone.request( - "clear_session", {}, control=CONTROL_V2_CHARACTERISTIC_UUID, schema_version=2 + + server_module.handle_delete_shot({"timestamp": shot.timestamp.isoformat()}) + server_module.handle_clear_session({}) + + deleted = phone.wait_for( + CONTROL_V2_CHARACTERISTIC_UUID, lambda message: message.get("type") == "shot_deleted" ) - assert cleared["result"] == {"status": "cleared", "profile_id": active_id} - event = phone.wait_for( + cleared = phone.wait_for( CONTROL_V2_CHARACTERISTIC_UUID, lambda message: message.get("type") == "session_cleared", ) - assert event == {"schema_version": 2, "type": "session_cleared", "profile_id": active_id} - - missing = phone.request( - "delete_shot", - {"timestamp": "2026-09-25T12:00:00"}, - control=CONTROL_V2_CHARACTERISTIC_UUID, - schema_version=2, - ) - assert missing["ok"] is False - assert missing["error"] == "Shot not found" - assert ("delete_shot_error", {"error": "Shot not found"}) in pi.emitted + assert deleted == { + "schema_version": 2, + "type": "shot_deleted", + "timestamp": shot.timestamp.isoformat(), + } + assert cleared == {"schema_version": 2, "type": "session_cleared", "profile_id": active_id} def test_unsubscribing_one_pair_keeps_the_other_flowing(pi): diff --git a/tests/test_ble_protocol_v2.py b/tests/test_ble_protocol_v2.py index 655dc9509..bc5204dba 100644 --- a/tests/test_ble_protocol_v2.py +++ b/tests/test_ble_protocol_v2.py @@ -17,6 +17,7 @@ build_hello_result, build_power_status_event, build_profiles_event, + build_shot_deleted_event, build_shot_event, build_shot_event_v2, encode_message_v2, @@ -169,6 +170,24 @@ def test_v2_encoding_is_compact_sorted_utf8(): assert list(decoded) == sorted(decoded) +def test_shot_deleted_event_carries_the_delete_key(): + assert build_shot_deleted_event("2026-09-25T12:00:00.000001") == { + "schema_version": 2, + "type": "shot_deleted", + "timestamp": "2026-09-25T12:00:00.000001", + } + with pytest.raises(ValueError): + build_shot_deleted_event("") + + +def test_hello_features_are_read_and_select_only(): + features = build_hello_result(2)["features"] + + assert "shot_deleted" in features + assert "delete_shot" not in features + assert "session_clear" not in features + + def test_v2_events_cannot_override_envelope_fields(): with pytest.raises(ValueError): build_event_v2("profiles", {"type": "shot"}) diff --git a/tests/test_phone_transport_v2.py b/tests/test_phone_transport_v2.py index ce6c96487..33c932948 100644 --- a/tests/test_phone_transport_v2.py +++ b/tests/test_phone_transport_v2.py @@ -216,13 +216,12 @@ def test_v2_dispatch_uses_the_socketio_operations(phones): assert second.id in {item["id"] for item in stream.events[-1]["profiles"]} -def test_v2_clear_session_broadcasts_like_socketio(phones): +def test_socket_clear_session_also_notifies_v2_phones(phones): stream, ble, emitted = phones active = server_module.get_profile_store().get_active().id - response, status = server_module.dispatch_phone_control_command_v2("clear_session", {}) + server_module.handle_clear_session({}) - assert (status, response) == (200, {"status": "cleared", "profile_id": active}) assert emitted == [("session_cleared", {"profile_id": active, "shots": []})] for transport in (stream, ble): assert transport.events == [ @@ -230,8 +229,8 @@ def test_v2_clear_session_broadcasts_like_socketio(phones): ] -def test_v2_delete_shot_reports_missing_and_deleted_rows(phones, monkeypatch): - _stream, _ble, emitted = phones +def test_socket_delete_shot_notifies_v2_phones_only_on_success(phones, monkeypatch): + stream, ble, emitted = phones shot = Shot( ball_speed_mph=150.0, timestamp=datetime(2026, 9, 25, 12, 0, 0, 1), @@ -241,17 +240,35 @@ def test_v2_delete_shot_reports_missing_and_deleted_rows(phones, monkeypatch): monitor._shots.append(shot) monkeypatch.setattr(server_module, "monitor", monitor) - missing = server_module.dispatch_phone_control_command_v2( - "delete_shot", {"timestamp": "2026-01-01T00:00:00"} - ) - deleted = server_module.dispatch_phone_control_command_v2( - "delete_shot", {"timestamp": shot.timestamp.isoformat()} - ) + server_module.handle_delete_shot({"timestamp": "2026-01-01T00:00:00"}) + for transport in (stream, ble): + assert transport.events == [] + server_module.handle_delete_shot({"timestamp": shot.timestamp.isoformat()}) - assert missing == ({"error": "Shot not found"}, 404) - assert deleted == ({"status": "deleted", "timestamp": shot.timestamp.isoformat()}, 200) assert [event for event, _ in emitted] == ["delete_shot_error", "session_state"] assert monitor.get_shots() == [] + for transport in (stream, ble): + assert transport.events == [ + { + "schema_version": 2, + "type": "shot_deleted", + "timestamp": shot.timestamp.isoformat(), + } + ] + + +@pytest.mark.parametrize("command", ["clear_session", "delete_shot"]) +def test_destructive_commands_are_not_routed_over_ble(phones, command): + _stream, _ble, emitted = phones + + for dispatch in ( + server_module.dispatch_phone_control_command, + server_module.dispatch_phone_control_command_v2, + ): + response, status = dispatch(command, {"timestamp": "2026-09-25T12:00:00"}) + assert status == 400 + assert response["error"] == f"Unsupported phone command: {command}" + assert emitted == [] def test_v2_power_status_command_returns_the_socketio_payload(phones, monkeypatch): @@ -279,7 +296,7 @@ class _Monitor: def test_v1_dispatch_does_not_grow_v2_commands(phones): - for command in ("get_profiles", "set_active_profile", "clear_session", "delete_shot"): + for command in ("get_profiles", "set_active_profile", "get_power_status"): response, status = server_module.dispatch_phone_control_command(command, {}) assert status == 400 assert response["error"] == f"Unsupported phone command: {command}" From 07d5313c95b3be75e9ad24874072353d5cde950f Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Fri, 25 Sep 2026 11:09:00 -0400 Subject: [PATCH 12/19] docs(ble): document read-and-select-only BLE v2 and shot_deleted Removes clear_session and delete_shot from the v2 command table, adds the shot_deleted event and SSE name, updates the hello example, and replaces the security note: BLE is unauthenticated, so v2 over Bluetooth only reads state and selects club or profile; deleting data and editing profiles require Wi-Fi. Changelog entry updated to match. Co-Authored-By: Claude Opus 5.5 --- docs/changelog.md | 10 ++++++---- docs/ios-ble.md | 29 ++++++++++++++++++----------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 49579334d..11b039bc5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -86,10 +86,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `shot_number`, profile, `carry_range`, `spin_source`, `launch_angle_confidence`, `final` and `enrichment`, and hardware-enriched shots now arrive twice, provisional then final, with one `event_id`. v2 phones - also get `shot_processing`, `profiles`, `power_status`, `session_cleared` and - `club_changed` events and can `get_profiles`, `set_active_profile`, - `get_power_status`, `clear_session` and `delete_shot` through the same server - functions Socket.IO uses. `GET /api/shots/stream?schema=2` opts SSE clients + also get `shot_processing`, `profiles`, `power_status`, `session_cleared`, + `shot_deleted` and `club_changed` events and can `get_profiles`, + `set_active_profile` and `get_power_status` through the same server functions + Socket.IO uses. Because BLE is unauthenticated, v2 over Bluetooth is + read-and-select only: clearing sessions, deleting shots and editing profiles + stay on Wi-Fi. `GET /api/shots/stream?schema=2` opts SSE clients into the same events. BLE delivery now follows per-characteristic subscriptions, so the latest shot is replayed when a shot characteristic is subscribed and one phone unsubscribing no longer pauses the others. Tests run diff --git a/docs/ios-ble.md b/docs/ios-ble.md index 310cf47ec..67891c481 100644 --- a/docs/ios-ble.md +++ b/docs/ios-ble.md @@ -318,7 +318,7 @@ message. ``` The result names the negotiated schema, the features and the v2 pair: ```json - {"ok":true,"request_id":"","result":{"characteristics":{"control":"7BA96E63-12C2-4CE0-BB84-3513C7FD1474","shot":"ED365FE6-3ABF-4FC3-8E44-D9525A22DABD"},"features":["provisional_shots","shot_processing","profiles","power_status","session_clear","delete_shot","club"],"schema_version":2},"schema_version":2} + {"ok":true,"request_id":"","result":{"characteristics":{"control":"7BA96E63-12C2-4CE0-BB84-3513C7FD1474","shot":"ED365FE6-3ABF-4FC3-8E44-D9525A22DABD"},"features":["provisional_shots","shot_processing","profiles","power_status","shot_deleted","club"],"schema_version":2},"schema_version":2} ``` 3. Subscribe to the v2 shot characteristic. The latest v2 shot is replayed. 4. Ask for state: `get_club`, `get_profiles` and, if wanted, `get_power_status`. @@ -365,7 +365,8 @@ Notified on the v2 control characteristic. Each has `schema_version: 2` and a |---|---|---| | `club_changed` | `club` | Any club change (kiosk, phone, simulator) | | `profiles` | `profiles: [{id, name}]`, `active_profile_id` | After every profile request or mutation from any client, including rejected ones | -| `session_cleared` | `profile_id` | After any client clears a profile's shots | +| `session_cleared` | `profile_id` | After a profile's shots are cleared (kiosk or Wi-Fi) | +| `shot_deleted` | `timestamp` (the shot's delete key) | After a shot is deleted (kiosk or Wi-Fi) | | `shot_processing` | `state`: `capturing`, `calculating` or `failed` | Rolling-buffer monitor progress; the next shot ends it | | `power_status` | the Socket.IO `power_status` payload: `available, provider, state, battery_percent, battery_voltage_v, external_power, updated_at, error` | Every 5 s with `--battery geekworm` | @@ -373,6 +374,7 @@ Notified on the v2 control characteristic. Each has `schema_version: 2` and a {"club":"7-iron","schema_version":2,"type":"club_changed"} {"active_profile_id":"0f8e…","profiles":[{"id":"0f8e…","name":"Zoë ⛳"},{"id":"7c1d…","name":"Sam"}],"schema_version":2,"type":"profiles"} {"profile_id":"0f8e…","schema_version":2,"type":"session_cleared"} +{"schema_version":2,"timestamp":"2026-09-25T14:03:07.412345","type":"shot_deleted"} {"schema_version":2,"state":"calculating","type":"shot_processing"} {"available":true,"battery_percent":76.5,"battery_voltage_v":3.98,"error":null,"external_power":false,"provider":"geekworm","schema_version":2,"state":"on_battery","type":"power_status","updated_at":"2026-09-25T14:03:05.000000+00:00"} ``` @@ -395,10 +397,13 @@ the kiosk and every other client see the same broadcasts. | `get_profiles` | `{}` | `{"status":"sent"}` | `profiles`: the roster arrives as the event, not in the result | | `set_active_profile` | `{"profile_id":…}` | `{"status":"applied","active_profile_id":…}`, or `ok:false` `Unknown profile` | `profiles` (also when rejected) | | `get_power_status` | `{}` | the `power_status` payload, or `ok:false` `Battery monitoring is not enabled` / `No battery reading yet` | | -| `clear_session` | `{"profile_id":…}` (default: active profile) | `{"status":"cleared","profile_id":…}` | `session_cleared` | -| `delete_shot` | `{"timestamp":""}` | `{"status":"deleted","timestamp":…}`, or `ok:false` `Shot not found` | Socket.IO `session_state` | -Adding, renaming and removing profiles stay on Socket.IO and the kiosk. An event +Over BLE, v2 is read-and-select only (see [Security](#security-and-scope)). +Adding, renaming and removing profiles, `clear_session` and `delete_shot` stay +on Socket.IO and the kiosk; sent over BLE they fail with +`Unsupported phone command: ` on either control characteristic. Phones +still learn about those changes from the `profiles`, `session_cleared` and +`shot_deleted` events. An event triggered by a command is normally notified before the command's response, but clients must accept either order. The Pi processes commands as they arrive and enforces no busy state or timeout of its own; clients own their timeouts. @@ -413,7 +418,7 @@ stream; any other value returns `400`. A v2 stream opens with `: ping`, then the current state as `club_changed`, `profiles` and (with a battery monitor) `power_status`, then the latest v2 shot. Event names match the `type` of the payload: `shot`, `shot_processing`, `profiles`, `power_status`, -`session_cleared` and `club_changed`. Commands stay on `/api/club`, the +`session_cleared`, `shot_deleted` and `club_changed`. Commands stay on `/api/club`, the calibration route and Socket.IO. ```bash @@ -450,11 +455,13 @@ Wi-Fi API as accessible to anything on the same network — the same assumption the browser UI already makes. Phone-assisted calibration can update and persist TI mount tilt, so use either transport only in a trusted environment. -Schema v2 adds two destructive commands, `clear_session` and `delete_shot`, -with the same exposure the browser UI's Socket.IO already has on the local -network, but now also reachable by any nearby Bluetooth device. Authenticated -pairing is still future work; until then enable `--ble` only where that is -acceptable. +BLE is unauthenticated: any nearby device can connect and write the control +characteristics. Schema v2 therefore exposes only reading state and selecting +(club, active profile) over Bluetooth, plus the calibration version one already +had. Actions that delete data, clearing a session or deleting a shot, and +profile add, rename and remove require Wi-Fi (the kiosk or Socket.IO), where +they have the same exposure the browser UI already has. Revisit this only with +authenticated pairing. ## Testing without hardware From 64649fe19930ba0943f2ec3d9b8837a5e0961937 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Sun, 27 Sep 2026 04:49:34 -0400 Subject: [PATCH 13/19] docs(phone): say "network" instead of "Wi-Fi" for the IP transports Review feedback on #282: the HTTP/SSE/Socket.IO path works over any IP link (Wi-Fi, Ethernet, ...), and OpenFlight deliberately does not use AP mode, so "Wi-Fi" was misleading. Reword docs, comments, log strings and test names; BLE golden fixtures are unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/changelog.md | 8 +++--- docs/ios-ble.md | 39 ++++++++++++++-------------- src/openflight/server.py | 12 ++++----- src/openflight/shot_stream.py | 4 +-- tests/test_ble_loopback.py | 2 +- tests/test_control_commands.py | 4 +-- tests/test_phone_transport_server.py | 4 +-- tests/test_server.py | 2 +- 8 files changed, 38 insertions(+), 37 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 11b039bc5..28bfeedf6 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -59,7 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 when Node is older than 22.12 or `npm install` fails and `ui/dist` already exists). Installing Electron needs **Node.js 22.12 or newer**. See [Electron Kiosk Shell](electron-kiosk-shell.md). -- **Phone transports: Bluetooth LE, a Wi-Fi shot stream and a club API.** +- **Phone transports: Bluetooth LE, a network shot stream and a club API.** Ported from [jake-fishtech](https://github.com/jake-fishtech)'s `feat/iOS-ble` branch. `--ble` (with the optional `ble` extra, `bless==0.3.0` on Linux) advertises a GATT service that notifies each final shot and accepts versioned @@ -77,7 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 changes: Socket.IO `set_club` now ignores `unknown` and a missing club (it used to fall back to driver), and a failed Socket.IO shot emit no longer stops BLE, SSE and simulator delivery. -- **BLE and Wi-Fi schema v2 for phone apps.** Version-one traffic is unchanged +- **BLE and network schema v2 for phone apps.** Version-one traffic is unchanged byte for byte, so jake-fishtech's iOS app keeps working. v2 lives on a second shot/control characteristic pair in the same GATT service (BlueZ cannot notify one central but not another on a shared characteristic; see the design note in @@ -91,8 +91,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `set_active_profile` and `get_power_status` through the same server functions Socket.IO uses. Because BLE is unauthenticated, v2 over Bluetooth is read-and-select only: clearing sessions, deleting shots and editing profiles - stay on Wi-Fi. `GET /api/shots/stream?schema=2` opts SSE clients - into the same events. BLE delivery now follows per-characteristic + stay on the network (Socket.IO/HTTP). `GET /api/shots/stream?schema=2` opts + SSE clients into the same events. BLE delivery now follows per-characteristic subscriptions, so the latest shot is replayed when a shot characteristic is subscribed and one phone unsubscribing no longer pauses the others. Tests run the real publisher against a loopback fake of Bless/BlueZ, and diff --git a/docs/ios-ble.md b/docs/ios-ble.md index 67891c481..880bd3648 100644 --- a/docs/ios-ble.md +++ b/docs/ios-ble.md @@ -1,9 +1,9 @@ -# Phone app connection (Bluetooth LE and Wi-Fi) +# Phone app connection (Bluetooth LE and network) > **BLE blocker — check the Raspberry Pi kernel first:** Raspberry Pi kernel > `6.18.34+rpt-rpi-2712` has a confirmed regression that rejects every BLE > advertisement. Run `uname -r` on the Pi. If it reports that version, use the -> Wi-Fi transport or boot a working kernel such as 6.12.x; there is no userspace +> network transport or boot a working kernel such as 6.12.x; there is no userspace > workaround. See the [full diagnosis](#known-bad-raspberry-pi-kernel-61834rpt-rpi-2712). OpenFlight sends each completed shot from a Raspberry Pi to a phone app over one @@ -19,10 +19,10 @@ below, so an app behaves the same either way. Two apps speak this protocol: | Transport | Pi setup | Use it when | |---|---|---| -| **Bluetooth** | start with `--ble` | No Wi-Fi at all, or the phone is not on the Pi's network | -| **Wi-Fi** | always on | The phone and Pi share a network, or Bluetooth advertising is unavailable | +| **Bluetooth** | start with `--ble` | The phone cannot reach the Pi over a network | +| **Network** | always on | The phone can reach the Pi over IP (Wi-Fi, Ethernet or any other link), or Bluetooth advertising is unavailable | -Wi-Fi needs no flag: it streams from the same HTTP server that serves the +The network transport needs no flag: it streams from the same HTTP server that serves the browser UI, and exposes nothing the browser UI does not already broadcast. ## Requirements @@ -54,7 +54,7 @@ scripts/start-kiosk.sh --ble BLE startup and delivery errors are isolated from shot recording. If Bluetooth is unavailable, the browser UI and session logger continue to work. -## Wi-Fi transport +## Network transport The server streams shots as [Server-Sent Events](https://developer.mozilla.org/docs/Web/API/Server-sent_events) at `/api/shots/stream`. Check it from any machine on the network before @@ -75,7 +75,8 @@ event: shot data: {"ball_speed_mph":151.4,"club":"driver",...,"schema_version":1} ``` -In the app, pick **Wi-Fi** and enter the Pi's address. `raspberrypi.local:8080` +In the app, pick **Wi-Fi** (the app's label; it works over any IP network, so +the Pi can be on Ethernet) and enter the Pi's address. `raspberrypi.local:8080` is the default and works on a stock Raspberry Pi OS install, which publishes its hostname over mDNS; if you renamed the Pi, use `.local:8080` or its IP. The port defaults to 8080 when you leave it off. The app reconnects on its own @@ -100,7 +101,7 @@ physical-device, simulator, testing, and troubleshooting instructions, see transport. Over Bluetooth the app scans only for the OpenFlight service, connects -automatically, and subscribes to shot and control notifications. Over Wi-Fi it +automatically, and subscribes to shot and control notifications. Over the network it opens the shot stream and keeps it open. Either way, hit a shot and its metrics should replace the empty dashboard. The most recent shot is replayed when a phone connects, so a newly connected phone does not have to wait for another @@ -114,7 +115,7 @@ confirms the change before the app updates its saved selection. The app sends the change over the currently selected transport: - Bluetooth uses the framed control characteristic described below. -- Wi-Fi sends `POST /api/club` with `{"club":"7-iron"}`. +- The network transport sends `POST /api/club` with `{"club":"7-iron"}`. The browser UI and simulator integrations use the same server operation, so a phone club change affects the same launch, spin, and carry processing state. @@ -129,7 +130,7 @@ The dashboard's **Calibrate TI Radar** button opens a guided mount-angle tool. It sends the measurement over whichever transport is selected on the dashboard. 1. Start OpenFlight with the IWR6843 enabled. Add `--ble` for Bluetooth, or - connect the phone to the Pi's network for Wi-Fi. + make sure the phone can reach the Pi over the network. 2. Remove the phone case. Hold the phone upright in portrait with its back flat against a straight reference surface parallel to the TI antenna face. Keep the screen facing the target and avoid resting on the camera bump. @@ -164,7 +165,7 @@ precision substitute for target-line alignment. ## Wire protocol -Both transports carry the same JSON event. Only the framing differs: Wi-Fi sends +Both transports carry the same JSON event. Only the framing differs: the network transport sends it whole in one SSE `data:` line, while BLE splits it across notifications. Over BLE, OpenFlight advertises one service with a shot notification and a @@ -365,8 +366,8 @@ Notified on the v2 control characteristic. Each has `schema_version: 2` and a |---|---|---| | `club_changed` | `club` | Any club change (kiosk, phone, simulator) | | `profiles` | `profiles: [{id, name}]`, `active_profile_id` | After every profile request or mutation from any client, including rejected ones | -| `session_cleared` | `profile_id` | After a profile's shots are cleared (kiosk or Wi-Fi) | -| `shot_deleted` | `timestamp` (the shot's delete key) | After a shot is deleted (kiosk or Wi-Fi) | +| `session_cleared` | `profile_id` | After a profile's shots are cleared (kiosk or network clients) | +| `shot_deleted` | `timestamp` (the shot's delete key) | After a shot is deleted (kiosk or network clients) | | `shot_processing` | `state`: `capturing`, `calculating` or `failed` | Rolling-buffer monitor progress; the next shot ends it | | `power_status` | the Socket.IO `power_status` payload: `available, provider, state, battery_percent, battery_voltage_v, external_power, updated_at, error` | Every 5 s with `--battery geekworm` | @@ -410,7 +411,7 @@ enforces no busy state or timeout of its own; clients own their timeouts. Version-one commands keep working on the v1 control characteristic, and v2-only commands sent there fail with `Unsupported phone command`. -### Wi-Fi: `?schema=2` +### Network: `?schema=2` `GET /api/shots/stream?schema=2` opts a Server-Sent Events client into schema v2. The default (no parameter, or `schema=1`) is the unchanged version-one @@ -451,7 +452,7 @@ message that would not fit instead of sending a truncated one. Version one intentionally has no application authentication or encryption layer on either transport. Enable BLE only where nearby Bluetooth devices receiving shots and issuing club or calibration commands is acceptable, and treat the -Wi-Fi API as accessible to anything on the same network — the same assumption +network API as accessible to anything on the same network — the same assumption the browser UI already makes. Phone-assisted calibration can update and persist TI mount tilt, so use either transport only in a trusted environment. @@ -459,7 +460,7 @@ BLE is unauthenticated: any nearby device can connect and write the control characteristics. Schema v2 therefore exposes only reading state and selecting (club, active profile) over Bluetooth, plus the calibration version one already had. Actions that delete data, clearing a session or deleting a shot, and -profile add, rename and remove require Wi-Fi (the kiosk or Socket.IO), where +profile add, rename and remove require the network (the kiosk or Socket.IO), where they have the same exposure the browser UI already has. Revisit this only with authenticated pairing. @@ -502,11 +503,11 @@ uv run python scripts/ble/generate_goldens.py --check What still needs a Pi and phones: BlueZ advertising, discovery and connection from iOS and Android, pairing and permission prompts, fragment pacing over a real link, reconnects after a Pi restart, background behaviour, -and coexistence with Wi-Fi and Socket.IO clients. +and coexistence with SSE and Socket.IO clients. ## Troubleshooting -**The Wi-Fi transport will not connect.** +**The network transport will not connect.** - Confirm the address with `curl -N http://:8080/api/shots/stream` from a computer on the same network. If curl works and the app does not, the problem @@ -572,7 +573,7 @@ a zero-byte payload: Nothing in userspace can shrink a zero-byte payload, so no OpenFlight or Bless setting works around this. Boot a kernel without the regression (6.12.x is -reported to work) and rerun the probe. Until then, use the Wi-Fi transport +reported to work) and rerun the probe. Until then, use the network transport above: it needs no Bluetooth and delivers the identical payload. **The Pi logs that Bluetooth is unavailable.** diff --git a/src/openflight/server.py b/src/openflight/server.py index 3ff8f27ca..f52315933 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -185,7 +185,7 @@ def get_profile_store() -> ProfileStore: active_club = ClubType.DRIVER club_selection_lock = threading.Lock() -# Wi-Fi shot delivery for the iOS app. Always available: it exposes the same +# Network shot delivery for phone apps. Always available: it exposes the same # shots the browser UI already broadcasts over WebSocket, so it adds no reach # beyond the existing HTTP server. shot_stream = ShotStreamBroker() @@ -1149,7 +1149,7 @@ def _broadcast_club_selection(club: ClubType) -> None: try: shot_stream.publish_club(club.value) except Exception: # pylint: disable=broad-exception-caught - logger.warning("[SERVER] Failed to broadcast club over Wi-Fi stream", exc_info=True) + logger.warning("[SERVER] Failed to broadcast club over network stream", exc_info=True) if ble_publisher is not None: try: ble_publisher.publish_club(club.value) @@ -1211,7 +1211,7 @@ def _phone_state_events_v2() -> list[dict]: @app.route("/api/club", methods=["GET", "POST"]) def api_club_selection(): - """Read or set the active club over Wi-Fi.""" + """Read or set the active club over the network (HTTP).""" if request.method == "GET": return current_club_selection() return apply_club_selection(request.get_json(silent=True)) @@ -3646,7 +3646,7 @@ def _finalize_shot_detected( except Exception as e: # pylint: disable=broad-exception-caught logger.warning("[SERVER] Failed to queue BLE shot: %s", e, exc_info=True) - # Wi-Fi transport is likewise independent; a stalled client cannot affect + # The network transport is likewise independent; a stalled client cannot affect # shot recording or the browser UI. if shot_data is not None: try: @@ -3792,7 +3792,7 @@ def _publish_phone_shot_v2( enrichment: dict | None, ) -> None: """Hand one v2 shot to the BLE and SSE phone transports; never raises.""" - transports = [("Wi-Fi stream", shot_stream)] + transports = [("network stream", shot_stream)] if ble_publisher is not None: transports.append(("BLE", ble_publisher)) for name, transport in transports: @@ -3804,7 +3804,7 @@ def _publish_phone_shot_v2( def _publish_phone_event(event: dict) -> None: """Hand one schema v2 event to the BLE and SSE phone transports; never raises.""" - transports = [("Wi-Fi stream", shot_stream)] + transports = [("network stream", shot_stream)] if ble_publisher is not None: transports.append(("BLE", ble_publisher)) for name, transport in transports: diff --git a/src/openflight/shot_stream.py b/src/openflight/shot_stream.py index 9426484b9..9253f8778 100644 --- a/src/openflight/shot_stream.py +++ b/src/openflight/shot_stream.py @@ -1,6 +1,6 @@ """Fan out completed shots to HTTP clients as Server-Sent Events. -This is the Wi-Fi sibling of the BLE publisher: same versioned payloads, same +This is the network sibling of the BLE publisher: same versioned payloads, same bounded-queue delivery policy, same isolation from shot recording. Clients get version one by default and schema v2 (provisional and final shots plus profile, power, processing and club events) with ``?schema=2``. It exists so @@ -152,7 +152,7 @@ def publish_event_v2(self, event: Mapping) -> bool: return True def publish_club(self, club: str) -> bool: - """Broadcast an authoritative club change to every Wi-Fi subscriber.""" + """Broadcast an authoritative club change to every network subscriber.""" try: event = StreamEvent("club_changed", encode_club_event(club)) v2_event = StreamEvent("club_changed", encode_message_v2(build_club_event_v2(club))) diff --git a/tests/test_ble_loopback.py b/tests/test_ble_loopback.py index 46ee4ef99..90bd3b18a 100644 --- a/tests/test_ble_loopback.py +++ b/tests/test_ble_loopback.py @@ -297,7 +297,7 @@ def test_destructive_commands_are_not_available_over_ble(pi): assert pi.emitted == [] -def test_wifi_clear_and_delete_reach_ble_phones_as_events(pi, monkeypatch): +def test_network_clear_and_delete_reach_ble_phones_as_events(pi, monkeypatch): shot = Shot( ball_speed_mph=150.0, timestamp=datetime(2026, 9, 25, 12, 0, 0, 5), diff --git a/tests/test_control_commands.py b/tests/test_control_commands.py index a4cfc02e8..dca8900f4 100644 --- a/tests/test_control_commands.py +++ b/tests/test_control_commands.py @@ -71,7 +71,7 @@ def test_apply_club_selection_rejects_unknown_club(monkeypatch): assert monitor.clubs == [] -def test_wifi_club_endpoint_uses_shared_selection_logic(monkeypatch): +def test_network_club_endpoint_uses_shared_selection_logic(monkeypatch): monitor = _Monitor() monkeypatch.setattr(server_module, "monitor", monitor) @@ -85,7 +85,7 @@ def test_wifi_club_endpoint_uses_shared_selection_logic(monkeypatch): assert monitor.clubs == [ClubType.PW] -def test_wifi_club_endpoint_returns_authoritative_selection(monkeypatch): +def test_network_club_endpoint_returns_authoritative_selection(monkeypatch): monkeypatch.setattr(server_module, "active_club", ClubType.WOOD_3) response = server_module.app.test_client().get("/api/club") diff --git a/tests/test_phone_transport_server.py b/tests/test_phone_transport_server.py index 63cda1483..23cd8a5fb 100644 --- a/tests/test_phone_transport_server.py +++ b/tests/test_phone_transport_server.py @@ -49,7 +49,7 @@ def test_socket_set_club_without_monitor_still_broadcasts(no_monitor_transports) assert ble.clubs == ["7-iron"] -def test_wifi_club_post_without_monitor_applies_and_broadcasts(no_monitor_transports): +def test_network_club_post_without_monitor_applies_and_broadcasts(no_monitor_transports): stream, ble, emitted = no_monitor_transports response = server_module.app.test_client().post("/api/club", json={"club": "pw"}) @@ -63,7 +63,7 @@ def test_wifi_club_post_without_monitor_applies_and_broadcasts(no_monitor_transp @pytest.mark.parametrize("payload", [{"club": "putter"}, {"club": "unknown"}, {}, None, "pw"]) -def test_wifi_club_post_rejects_invalid_selection(no_monitor_transports, payload): +def test_network_club_post_rejects_invalid_selection(no_monitor_transports, payload): stream, ble, emitted = no_monitor_transports response = server_module.app.test_client().post("/api/club", json=payload) diff --git a/tests/test_server.py b/tests/test_server.py index fad8f4de3..e8890aa8f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4137,7 +4137,7 @@ def test_ble_publish_survives_websocket_failure(self, monkeypatch): assert published[0]["ball_speed_mph"] == 100.0 def test_shot_stream_publish_survives_websocket_failure(self, monkeypatch): - """A broken web client must not suppress the independent Wi-Fi transport.""" + """A broken web client must not suppress the independent network transport.""" broker = ShotStreamBroker() subscriber = broker.subscribe() From c6a168e15073cba1a3f8b18b396b343b67e6f9e9 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Mon, 28 Sep 2026 12:41:39 -0400 Subject: [PATCH 14/19] fix(ble): disable BlueZ GATT client to stop the iOS pairing loop bluetoothd's GATT client read the iPhone's own GATT database, iOS answered Insufficient Authentication, and BlueZ sent an SMP Security Request. No agent on the headless/kiosk Pi confirmed it, so after the 30 s SMP timeout BlueZ disconnected (Authentication Failure) and the phone reconnected into a new pairing prompt, forever. Add scripts/setup/configure_bluetooth.sh, which sets Client = false under [GATT] in /etc/bluetooth/main.conf idempotently, with a timestamped backup and a bluetooth restart (--check reports only). Stock Raspberry Pi OS main.conf documents #Client under [CSIS], where bluetoothd ignores it, so the script writes it into [GATT]. setup.sh offers the step on a Pi; docs/ios-ble.md gains a troubleshooting entry. Co-Authored-By: Claude Opus 5.5 --- docs/changelog.md | 8 ++ docs/ios-ble.md | 37 ++++- scripts/setup/configure_bluetooth.sh | 114 +++++++++++++++ scripts/setup/setup.sh | 11 ++ tests/test_configure_bluetooth.py | 208 +++++++++++++++++++++++++++ 5 files changed, 377 insertions(+), 1 deletion(-) create mode 100755 scripts/setup/configure_bluetooth.sh create mode 100644 tests/test_configure_bluetooth.py diff --git a/docs/changelog.md b/docs/changelog.md index 28bfeedf6..7c0f228ed 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -22,6 +22,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [Electron Kiosk Shell](electron-kiosk-shell.md#browser-local-state-breaking-on-first-electron-launch). ### Fixed +- **iPhones no longer get a pairing prompt every 30 s over BLE.** BlueZ's own + GATT client read the phone's GATT database, iOS answered "Insufficient + Authentication", and BlueZ requested pairing that no agent on the Pi could + confirm, so the link dropped after the 30 s SMP timeout and looped. New + `scripts/setup/configure_bluetooth.sh` (offered by `setup.sh`) sets + `Client = false` under `[GATT]` in `/etc/bluetooth/main.conf`, with a + backup and a bluetooth restart. See + [Troubleshooting](ios-ble.md#troubleshooting). - **A crash-looping boot service no longer kills the desktop kiosk.** Every launcher exit ran a `pkill` that matched the Electron binary path, so an `openflight.service` that failed at startup (for example because systemd's diff --git a/docs/ios-ble.md b/docs/ios-ble.md index 880bd3648..0624bc616 100644 --- a/docs/ios-ble.md +++ b/docs/ios-ble.md @@ -44,10 +44,13 @@ installations: ./scripts/setup/setup.sh ``` -For an existing checkout, install it and start OpenFlight with BLE enabled: +For an existing checkout, install it, configure BlueZ once so iOS does not keep +prompting to pair (see [Troubleshooting](#troubleshooting)), and start +OpenFlight with BLE enabled: ```bash uv sync --extra ble +./scripts/setup/configure_bluetooth.sh scripts/start-kiosk.sh --ble ``` @@ -527,6 +530,38 @@ and coexistence with SSE and Socket.IO clients. - Keep the app in the foreground for the initial connection. - Restart OpenFlight after changing the Pi Bluetooth configuration. +**The iPhone shows a pairing prompt every 30 seconds.** + +Symptom: the phone connects, then disconnects about every 33 seconds and iOS +asks to pair again. The OpenFlight log shows `[BLE] Client subscribed (schema +v2)` followed by `unsubscribed` exactly 30 seconds later, and `bluetoothctl info +` shows `Paired: no`. + +OpenFlight never asks for pairing. BlueZ does: by default `bluetoothd` also acts +as a GATT client and reads the phone's own GATT database. The iPhone answers +`Insufficient Authentication`, BlueZ sends an SMP Security Request (the iOS +prompt), and nothing on a headless or kiosk Pi confirms the pairing. After the +30-second SMP timeout BlueZ disconnects with `Authentication Failure (0x05)`, +the phone reconnects, and the loop repeats. + +Turn off BlueZ's GATT client role (OpenFlight only needs to be a peripheral): + +```bash +./scripts/setup/configure_bluetooth.sh # or --check to only report +``` + +The script backs up `/etc/bluetooth/main.conf`, sets `Client = false` under +`[GATT]`, and restarts bluetooth. Restart OpenFlight afterwards so its BLE +server re-registers, and on the iPhone tap Forget This Device if iOS remembered +a half-finished pairing. `setup.sh` offers this step on a Pi. + +Set the key under `[GATT]`. Stock `main.conf` on Raspberry Pi OS lists the +commented `#Client = true` under `[CSIS]`, where `bluetoothd` ignores it. + +To confirm the fix, capture with `sudo btmon` while the phone connects: there +should be no `SMP: Security Request` and no `Disconnect … Authentication +Failure`, and the subscription should stay up past 30 seconds. + **The Pi logs `DBusError: Failed to register advertisement`.** BlueZ returns that one message for every advertising failure, so check what diff --git a/scripts/setup/configure_bluetooth.sh b/scripts/setup/configure_bluetooth.sh new file mode 100755 index 000000000..da55c00bd --- /dev/null +++ b/scripts/setup/configure_bluetooth.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# +# Configure BlueZ for OpenFlight's BLE phone connection. +# +# OpenFlight is a BLE peripheral with unauthenticated characteristics and +# never needs pairing. By default bluetoothd also acts as a GATT *client* +# toward every phone that connects: it reads the iPhone's own GATT database, +# the iPhone answers "Insufficient Authentication", and BlueZ responds with an +# SMP Security Request. iOS shows a pairing popup, no agent on the headless Pi +# confirms it, and after the 30 s SMP timeout BlueZ drops the link with +# "Authentication Failure". The phone reconnects and the loop repeats. +# +# Setting `Client = false` under [GATT] in /etc/bluetooth/main.conf stops that +# probing. Note: stock main.conf documents `#Client = true` under [CSIS], but +# bluetoothd reads the key from [GATT], so it is written there. +# +# Usage: +# scripts/setup/configure_bluetooth.sh # apply and restart bluetooth +# scripts/setup/configure_bluetooth.sh --check # report only, change nothing +# +# Idempotent: re-running makes no change once the setting is in place. + +set -euo pipefail + +CONF="${BLUEZ_MAIN_CONF:-/etc/bluetooth/main.conf}" + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +log() { echo -e "${GREEN}[Bluetooth Setup]${NC} $1"; } +warn() { echo -e "${YELLOW}[Bluetooth Setup]${NC} $1"; } +err() { echo -e "${RED}[Bluetooth Setup]${NC} $1"; } + +CHECK_ONLY=false +case "${1:-}" in + --check) CHECK_ONLY=true ;; + "") ;; + --help|-h) + awk 'NR>1 && !/^#/{exit} NR>1{sub(/^# ?/,""); print}' "$0" + exit 0 + ;; + *) err "Unknown option: $1 (try --help)"; exit 1 ;; +esac + +if [ ! -f "$CONF" ]; then + err "$CONF not found — is BlueZ installed?" + exit 1 +fi + +# Print $CONF with [GATT] Client set to false. Replaces any existing +# (commented or not) Client line inside [GATT], inserts one at the end of +# the section if missing, and appends a [GATT] section if there is none. +# Lines outside [GATT] (including the misplaced #Client under [CSIS]) are +# left untouched. +render_conf() { + awk ' + /^[[:space:]]*\[/ { + if (in_gatt && !done) { print "Client = false"; print ""; done = 1 } + in_gatt = ($0 ~ /^[[:space:]]*\[GATT\][[:space:]]*$/) + print + next + } + in_gatt && /^[[:space:]#]*Client[[:space:]]*=/ { + if (!done) { print "Client = false"; done = 1 } + next + } + { print } + END { + if (!done) { + if (!in_gatt) { print ""; print "[GATT]" } + print "Client = false" + } + } + ' "$CONF" +} + +TMP="$(mktemp)" +trap 'rm -f "$TMP"' EXIT +render_conf > "$TMP" + +if cmp -s "$TMP" "$CONF"; then + log "[GATT] Client = false already set in $CONF ✓" + exit 0 +fi + +if [ "$CHECK_ONLY" == "true" ]; then + warn "$CONF does not set [GATT] Client = false." + warn "iPhones may show a pairing prompt every ~30 s. Fix with:" + warn " ./scripts/setup/configure_bluetooth.sh" + exit 1 +fi + +BACKUP="$CONF.openflight-$(date +%Y%m%d%H%M%S).bak" +sudo cp -p "$CONF" "$BACKUP" +log "Backed up $CONF to $BACKUP" + +sudo install -m 644 "$TMP" "$CONF" +log "Set [GATT] Client = false in $CONF ✓" + +if command -v systemctl &> /dev/null; then + log "Restarting bluetooth (connected phones will disconnect)..." + sudo systemctl restart bluetooth + log "Bluetooth restarted ✓" + warn "Restart OpenFlight so its BLE server re-registers with bluetoothd." + if systemctl is-active --quiet openflight 2>/dev/null; then + warn " sudo systemctl restart openflight" + else + warn " (stop and re-run ./scripts/start-kiosk.sh --ble)" + fi +else + warn "Restart bluetoothd for the change to take effect." +fi diff --git a/scripts/setup/setup.sh b/scripts/setup/setup.sh index 8965a3da0..55a979e49 100755 --- a/scripts/setup/setup.sh +++ b/scripts/setup/setup.sh @@ -7,6 +7,7 @@ # - OPS243-A rolling buffer flash config # - K-LD7 device naming + FTDI low-latency rules # - Optional battery-provider telemetry +# - BlueZ config for the BLE phone app (no pairing prompts) # - Auto-start on boot (systemd service) # - Desktop shortcut # @@ -268,6 +269,16 @@ if [ "$PLATFORM" == "pi" ] && [ "$DEPS_ONLY" == "false" ] && [ "$INTERACTIVE" == info "Skipped. Run later with: ./scripts/battery/geekworm/setup.sh" fi + # --- Bluetooth (BLE phone app) --- + echo "" + if "$SCRIPT_DIR/configure_bluetooth.sh" --check > /dev/null 2>&1; then + log "Bluetooth already configured for the phone app ✓" + elif confirm "Configure Bluetooth for the iPhone app? (stops repeated pairing prompts; restarts bluetooth)" "Y"; then + "$SCRIPT_DIR/configure_bluetooth.sh" || warn "Bluetooth configuration failed. See docs/ios-ble.md → Troubleshooting." + else + info "Skipped. Run later with: ./scripts/setup/configure_bluetooth.sh" + fi + # --- Auto-start service --- echo "" if confirm "Start OpenFlight automatically on boot?" "N"; then diff --git a/tests/test_configure_bluetooth.py b/tests/test_configure_bluetooth.py new file mode 100644 index 000000000..47e28ebd9 --- /dev/null +++ b/tests/test_configure_bluetooth.py @@ -0,0 +1,208 @@ +"""Tests for the BlueZ configuration script used by the BLE phone app.""" + +import os +import subprocess +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = PROJECT_ROOT / "scripts" / "setup" / "configure_bluetooth.sh" +MAIN_SETUP_SCRIPT = PROJECT_ROOT / "scripts" / "setup" / "setup.sh" +BLE_GUIDE = PROJECT_ROOT / "docs" / "ios-ble.md" + +# Trimmed from Raspberry Pi OS trixie (BlueZ 5.82). The stock file documents +# `#Client = true` under [CSIS] even though bluetoothd reads it from [GATT]. +STOCK_MAIN_CONF = """\ +[General] +#ReverseServiceDiscovery = true + +[GATT] +#Cache = always + +# Export claimed services by plugins +#ExportClaimedServices = read-only + +[CSIS] +#Rank = 0 + +# This enables the GATT client functionally, so it can be disabled in system +# which can only operate as a peripheral. +# Defaults to 'true'. +#Client = true + +[AVDTP] +#SessionMode = basic +""" + + +def _stub_bin(tmp_path: Path) -> Path: + """Stub sudo (runs the command) and systemctl (records calls, never touches the host).""" + bin_dir = tmp_path / "bin" + if bin_dir.exists(): + return bin_dir + bin_dir.mkdir() + (bin_dir / "sudo").write_text('#!/bin/sh\nexec "$@"\n', encoding="ascii") + (bin_dir / "systemctl").write_text( + f'#!/bin/sh\necho "$*" >> "{tmp_path / "systemctl.log"}"\n' + '[ "$1" = "is-active" ] && exit 3\nexit 0\n', + encoding="ascii", + ) + for stub in bin_dir.iterdir(): + stub.chmod(0o755) + return bin_dir + + +def _run(tmp_path: Path, conf: Path, *args: str) -> subprocess.CompletedProcess[str]: + env = { + **os.environ, + "PATH": f"{_stub_bin(tmp_path)}{os.pathsep}{os.environ['PATH']}", + "BLUEZ_MAIN_CONF": str(conf), + } + return subprocess.run( + ["bash", str(SCRIPT), *args], + check=False, + capture_output=True, + text=True, + env=env, + ) + + +def _section(text: str, name: str) -> list[str]: + """Lines belonging to one INI section, header excluded.""" + lines, inside = [], False + for line in text.splitlines(): + if line.startswith("["): + inside = line.strip() == f"[{name}]" + continue + if inside: + lines.append(line) + return lines + + +def _systemctl_calls(tmp_path: Path) -> list[str]: + log = tmp_path / "systemctl.log" + return log.read_text(encoding="ascii").splitlines() if log.exists() else [] + + +def _backups(conf: Path) -> list[Path]: + return sorted(conf.parent.glob(f"{conf.name}.openflight-*.bak")) + + +def test_script_has_valid_bash_syntax(): + subprocess.run(["bash", "-n", SCRIPT], check=True) + + +def test_check_reports_stock_config_as_unconfigured(tmp_path): + conf = tmp_path / "main.conf" + conf.write_text(STOCK_MAIN_CONF, encoding="ascii") + + result = _run(tmp_path, conf, "--check") + + assert result.returncode == 1 + assert conf.read_text(encoding="ascii") == STOCK_MAIN_CONF + assert not _backups(conf) + assert not _systemctl_calls(tmp_path) + + +def test_apply_disables_gatt_client_under_gatt_not_csis(tmp_path): + conf = tmp_path / "main.conf" + conf.write_text(STOCK_MAIN_CONF, encoding="ascii") + + result = _run(tmp_path, conf) + + assert result.returncode == 0, result.stderr + text = conf.read_text(encoding="ascii") + assert "Client = false" in _section(text, "GATT") + assert "Client = false" not in _section(text, "CSIS") + assert "#Client = true" in _section(text, "CSIS") + assert text.count("Client = false") == 1 + # Everything else is preserved line for line. + assert [line for line in text.splitlines() if line not in ("Client = false", "")] == [ + line for line in STOCK_MAIN_CONF.splitlines() if line != "" + ] + + +def test_apply_backs_up_original_and_restarts_bluetooth(tmp_path): + conf = tmp_path / "main.conf" + conf.write_text(STOCK_MAIN_CONF, encoding="ascii") + + result = _run(tmp_path, conf) + + assert result.returncode == 0, result.stderr + backups = _backups(conf) + assert len(backups) == 1 + assert backups[0].read_text(encoding="ascii") == STOCK_MAIN_CONF + assert "restart bluetooth" in _systemctl_calls(tmp_path) + assert "start-kiosk.sh --ble" in result.stdout + + +def test_apply_is_idempotent(tmp_path): + conf = tmp_path / "main.conf" + conf.write_text(STOCK_MAIN_CONF, encoding="ascii") + + first = _run(tmp_path, conf) + configured = conf.read_text(encoding="ascii") + second = _run(tmp_path, conf) + check = _run(tmp_path, conf, "--check") + + assert first.returncode == 0, first.stderr + assert second.returncode == 0, second.stderr + assert check.returncode == 0, check.stderr + assert conf.read_text(encoding="ascii") == configured + assert len(_backups(conf)) == 1 + assert _systemctl_calls(tmp_path).count("restart bluetooth") == 1 + + +def test_apply_replaces_existing_gatt_client_setting(tmp_path): + conf = tmp_path / "main.conf" + conf.write_text("[GATT]\nCache = always\nClient=true\n#Client = true\n", encoding="ascii") + + result = _run(tmp_path, conf) + + assert result.returncode == 0, result.stderr + assert conf.read_text(encoding="ascii") == "[GATT]\nCache = always\nClient = false\n" + + +def test_apply_appends_gatt_section_when_missing(tmp_path): + conf = tmp_path / "main.conf" + conf.write_text("[General]\nName = OpenFlight\n", encoding="ascii") + + result = _run(tmp_path, conf) + + assert result.returncode == 0, result.stderr + assert conf.read_text(encoding="ascii") == ( + "[General]\nName = OpenFlight\n\n[GATT]\nClient = false\n" + ) + + +def test_missing_config_fails_without_changes(tmp_path): + conf = tmp_path / "absent.conf" + + result = _run(tmp_path, conf) + + assert result.returncode != 0 + assert not conf.exists() + assert not _systemctl_calls(tmp_path) + + +def test_unknown_option_is_rejected(tmp_path): + conf = tmp_path / "main.conf" + conf.write_text(STOCK_MAIN_CONF, encoding="ascii") + + result = _run(tmp_path, conf, "--bogus") + + assert result.returncode != 0 + assert conf.read_text(encoding="ascii") == STOCK_MAIN_CONF + + +def test_main_setup_offers_bluetooth_configuration(): + setup = MAIN_SETUP_SCRIPT.read_text(encoding="utf-8") + + assert '"$SCRIPT_DIR/configure_bluetooth.sh" --check' in setup + assert 'confirm "Configure Bluetooth for the iPhone app?' in setup + + +def test_ble_guide_documents_pairing_prompt_fix(): + guide = BLE_GUIDE.read_text(encoding="utf-8") + + assert "configure_bluetooth.sh" in guide + assert "Client = false" in guide From 4908b66869d4256a28c7be8b6d82d61433b9df7e Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Mon, 28 Sep 2026 12:41:39 -0400 Subject: [PATCH 15/19] test(camera): skip OpenCV-dependent tests when cv2 is missing setup.sh does not install the camera extra, so 11 tests failed on the Pi with ModuleNotFoundError: cv2. Skip them when OpenCV is absent. Co-Authored-By: Claude Opus 5.5 --- tests/test_camera_ball_flight.py | 4 ++++ tests/test_camera_club_delivery.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/test_camera_ball_flight.py b/tests/test_camera_ball_flight.py index 339be5e64..9a90e98d5 100644 --- a/tests/test_camera_ball_flight.py +++ b/tests/test_camera_ball_flight.py @@ -1,5 +1,6 @@ """Tests for experimental camera-assisted horizontal ball flight.""" +import importlib.util import math from types import SimpleNamespace @@ -22,7 +23,10 @@ from openflight.camera.club_motion import ReferenceBall from openflight.iwr6843.lcmf import BallRangeEvidence, LCMFResult +CV2_AVAILABLE = importlib.util.find_spec("cv2") is not None + +@pytest.mark.skipif(not CV2_AVAILABLE, reason="OpenCV (camera extra) not installed") def test_ball_flight_uses_established_anchor_when_detection_is_missing(monkeypatch): tracker = ReferenceBallTracker() for x in (159.0, 160.0, 161.0): diff --git a/tests/test_camera_club_delivery.py b/tests/test_camera_club_delivery.py index 45acbbfec..d59043956 100644 --- a/tests/test_camera_club_delivery.py +++ b/tests/test_camera_club_delivery.py @@ -1,5 +1,6 @@ """Tests for the live camera club-delivery estimator and fusion.""" +import importlib.util import math from dataclasses import replace @@ -28,6 +29,8 @@ from openflight.camera.club_motion import ReferenceBall, detect_reference_ball from openflight.clubs import ClubType +CV2_AVAILABLE = importlib.util.find_spec("cv2") is not None + class _Ball: x = 320.0 @@ -775,6 +778,7 @@ def _synthetic_capture( return frames, ts +@pytest.mark.skipif(not CV2_AVAILABLE, reason="OpenCV (camera extra) not installed") class TestTraceEstimation: def test_bright_scene_produces_trace(self): frames, ts = _synthetic_capture() From dfe6202dfde740ecf3be91f9197769c456dba7e6 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Mon, 28 Sep 2026 14:05:13 -0400 Subject: [PATCH 16/19] feat(phone): catch reconnecting phones up on missed shots A phone that left the app or dropped the link got only the latest shot back, so shots taken meanwhile never reached it. Schema v2 now catches it up with one rule on both transports: - BLE: hello accepts an optional last_event_id. The catch-up goes out on the v2 shot characteristic after the hello response, held until the phone subscribes to it, and queued with backpressure so more missed shots than the 8-message queue are not dropped. - Network: the v2 stream honours Last-Event-ID (or ?last_event_id=) and v2 shot frames carry id: , so EventSource resumes on its own. The named shot is resent with every current-session shot after it (the whole session when none is named or it is gone), capped at 20. Resending the anchor brings a phone that only saw a provisional up to the final. The session (monitor.get_shots) decides what exists, so cleared and deleted shots are never replayed, and PhoneShotCache replays the exact bytes last sent live. hello advertises shot_catch_up; the hello goldens change only by that entry. Catch-up failures never fail hello: the phone falls back to the latest-shot replay. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R9pYpWtxN9nJ6vpqbXwB7m --- src/openflight/ble/protocol.py | 1 + src/openflight/ble/publisher.py | 110 ++++++- src/openflight/phone_catch_up.py | 97 ++++++ src/openflight/server.py | 60 +++- src/openflight/shot_stream.py | 46 ++- tests/ble_harness.py | 3 + .../ble_goldens/v1_response_hello.json | 50 ++-- .../ble_goldens/v2_response_hello.json | 50 ++-- tests/test_ble_catch_up.py | 277 ++++++++++++++++++ tests/test_phone_catch_up.py | 113 +++++++ tests/test_phone_transport_v2.py | 14 +- tests/test_shot_stream_catch_up.py | 241 +++++++++++++++ 12 files changed, 986 insertions(+), 76 deletions(-) create mode 100644 src/openflight/phone_catch_up.py create mode 100644 tests/test_ble_catch_up.py create mode 100644 tests/test_phone_catch_up.py create mode 100644 tests/test_shot_stream_catch_up.py diff --git a/src/openflight/ble/protocol.py b/src/openflight/ble/protocol.py index 5ee1251e3..2b441f954 100644 --- a/src/openflight/ble/protocol.py +++ b/src/openflight/ble/protocol.py @@ -60,6 +60,7 @@ "power_status", "shot_deleted", "club", + "shot_catch_up", ) V2_EVENT_TYPES = ( diff --git a/src/openflight/ble/publisher.py b/src/openflight/ble/publisher.py index 53d0fa016..0cd657fcd 100644 --- a/src/openflight/ble/publisher.py +++ b/src/openflight/ble/publisher.py @@ -7,7 +7,7 @@ import logging import threading import uuid -from collections.abc import Callable +from collections.abc import Callable, Sequence from typing import Any, Mapping from .protocol import ( @@ -33,6 +33,9 @@ logger = logging.getLogger(__name__) CommandHandler = Callable[[str, Mapping], tuple[dict, int]] +# Given a client's ``last_event_id`` (or ``None``), the ``(event_id, v2 shot +# payload)`` pairs it missed, oldest first. See ``openflight.phone_catch_up``. +CatchUpProvider = Callable[[Any], Sequence[tuple[str, bytes]]] _V1_CHARACTERISTICS = frozenset( {SHOT_CHARACTERISTIC_UUID.lower(), CONTROL_CHARACTERISTIC_UUID.lower()} @@ -77,7 +80,7 @@ class BleShotPublisher: second shot/control pair, so a v1 central never receives a v2 message. """ - def __init__( + def __init__( # pylint: disable=too-many-arguments self, *, name: str = "OpenFlight", @@ -85,6 +88,7 @@ def __init__( fragment_interval_s: float = 0.01, command_handler: CommandHandler | None = None, command_handler_v2: CommandHandler | None = None, + catch_up_provider: CatchUpProvider | None = None, ): if queue_size < 1: raise ValueError("BLE queue size must be at least one") @@ -93,6 +97,7 @@ def __init__( self.fragment_interval_s = fragment_interval_s self.command_handler = command_handler self.command_handler_v2 = command_handler_v2 + self.catch_up_provider = catch_up_provider self._state_lock = threading.Lock() self._thread: threading.Thread | None = None @@ -115,6 +120,9 @@ def __init__( self._control_sequences = {key: 0 for key in _CONTROL_SCHEMAS} self._control_reassemblers = {key: FragmentReassembler() for key in _CONTROL_SCHEMAS} self._control_send_locks: dict[str, asyncio.Lock] = {} + # Missed shots computed at a v2 ``hello`` that arrived before the phone + # subscribed to the v2 shot characteristic; sent when it does. + self._pending_v2_catch_up: list[bytes] | None = None @property def subscribed(self) -> bool: @@ -254,6 +262,7 @@ def _run_thread(self) -> None: self._v2_subscribed = False self._subscriptions = set() self._control_send_locks = {} + self._pending_v2_catch_up = None async def _run(self) -> None: # Bless is an optional dependency and must not affect non-BLE installs. @@ -379,6 +388,8 @@ def _on_unsubscribe(self, characteristic, _session) -> None: def _set_subscriptions(self, subscriptions: set[str]) -> None: v1 = bool(subscriptions & _V1_CHARACTERISTICS) v2 = bool(subscriptions & _V2_CHARACTERISTICS) + shot_v1 = SHOT_CHARACTERISTIC_UUID.lower() + shot_v2 = SHOT_V2_CHARACTERISTIC_UUID.lower() with self._state_lock: previous = self._subscriptions was_v1 = self._subscribed @@ -390,6 +401,13 @@ def _set_subscriptions(self, subscriptions: set[str]) -> None: latest_v2_payload = self._latest_v2_payload v1_queue = self._queue v2_queue = self._v2_queue + v2_shot_started = shot_v2 in subscriptions and shot_v2 not in previous + pending_catch_up = None + if v2_shot_started or not v2: + # Consumed by the subscription it was waiting for, or discarded + # with the connection that asked for it. + pending_catch_up = self._pending_v2_catch_up if v2_shot_started else None + self._pending_v2_catch_up = None if v1 != was_v1: logger.info("[BLE] Client %s (v1)", "subscribed" if v1 else "unsubscribed") @@ -402,12 +420,15 @@ def _set_subscriptions(self, subscriptions: set[str]) -> None: # Replay the latest shot when its shot characteristic gains a # subscriber, so a phone that subscribes to control first still gets it. - shot_v1 = SHOT_CHARACTERISTIC_UUID.lower() - shot_v2 = SHOT_V2_CHARACTERISTIC_UUID.lower() + # A v2 phone that already sent ``hello`` gets its catch-up instead, + # which holds the latest shot unless the phone already had it. if shot_v1 in subscriptions and shot_v1 not in previous and latest_payload is not None: self._enqueue_payload(latest_payload) - if shot_v2 in subscriptions and shot_v2 not in previous and latest_v2_payload is not None: - self._enqueue_v2_payload(latest_v2_payload) + if v2_shot_started: + if pending_catch_up is not None: + self._start_v2_catch_up(pending_catch_up) + elif latest_v2_payload is not None: + self._enqueue_v2_payload(latest_v2_payload) def _enqueue_payload(self, payload: bytes) -> None: with self._state_lock: @@ -423,6 +444,56 @@ def _enqueue_v2_payload(self, payload: bytes) -> None: if subscribed: self._offer(queue, payload) + async def _load_v2_catch_up(self, last_event_id) -> list[bytes] | None: + """The shots a v2 client missed, or ``None`` when catch-up is unavailable.""" + provider = self.catch_up_provider + if provider is None: + return None + try: + entries = await asyncio.to_thread(provider, last_event_id) + return [payload for _event_id, payload in entries] + except Exception: # pylint: disable=broad-exception-caught + # Never fail ``hello`` over catch-up: the phone keeps the + # latest-shot replay it had before catch-up existed. + logger.warning("[BLE] Could not load missed shots for catch-up", exc_info=True) + return None + + def _schedule_v2_catch_up(self, payloads: list[bytes] | None) -> None: + """Send catch-up now if the v2 shot characteristic is subscribed, else on subscribe. + + ``None`` (no catch-up for this command) does nothing. + """ + if payloads is None: + return + shot_v2 = SHOT_V2_CHARACTERISTIC_UUID.lower() + with self._state_lock: + ready = shot_v2 in self._subscriptions + if not ready: + self._pending_v2_catch_up = payloads + logger.info( + "[BLE] Catch-up: %d missed shot(s) for schema v2 client%s", + len(payloads), + "" if ready else " (sent when it subscribes to shots)", + ) + if ready: + self._start_v2_catch_up(payloads) + + def _start_v2_catch_up(self, payloads: list[bytes]) -> None: + with self._state_lock: + loop = self._loop + if loop is not None and payloads: + asyncio.run_coroutine_threadsafe(self._deliver_v2_catch_up(payloads), loop) + + async def _deliver_v2_catch_up(self, payloads: list[bytes]) -> None: + """Queue catch-up shots in order, waiting for room instead of dropping any.""" + for payload in payloads: + with self._state_lock: + queue = self._v2_queue + subscribed = self._v2_subscribed + if queue is None or not subscribed: + return + await queue.put(payload) + @staticmethod def _offer(queue: asyncio.Queue[bytes] | None, payload: bytes) -> None: if queue is None: @@ -546,6 +617,7 @@ async def _process_control_payload( v2 = response_schema == SCHEMA_VERSION_V2 handler = self.command_handler_v2 if v2 else self.command_handler request_id = "unknown" + catch_up: list[bytes] | None = None try: command = json.loads(payload) if not isinstance(command, dict): @@ -563,11 +635,8 @@ async def _process_control_payload( raise ValueError("Control command payload must be an object") if command_type == "hello": - # Transport negotiation is answered by the publisher itself: it - # is about which characteristics exist, not server state. - result = build_hello_result(command_payload.get("client_schema_max")) - response = self._control_response( - request_id, result=result, schema_version=response_schema + response, catch_up = await self._answer_hello( + request_id, command_payload, response_schema ) else: if handler is None: @@ -596,6 +665,25 @@ async def _process_control_payload( encoded = encode_message_v2(response) if v2 else encode_message(response) await self._send_control_response(encoded, characteristic_uuid) + # After the answer, so a client sees ``hello`` succeed before shots arrive. + self._schedule_v2_catch_up(catch_up) + + async def _answer_hello( + self, + request_id: str, + command_payload: Mapping, + response_schema: int, + ) -> tuple[dict, list[bytes] | None]: + """Negotiate the transport and load a v2 client's catch-up shots. + + Transport negotiation is answered by the publisher itself: it is about + which characteristics exist, not server state. + """ + result = build_hello_result(command_payload.get("client_schema_max")) + response = self._control_response(request_id, result=result, schema_version=response_schema) + if result["schema_version"] < SCHEMA_VERSION_V2: + return response, None + return response, await self._load_v2_catch_up(command_payload.get("last_event_id")) @staticmethod def _control_response( diff --git a/src/openflight/phone_catch_up.py b/src/openflight/phone_catch_up.py new file mode 100644 index 000000000..bd9d99d56 --- /dev/null +++ b/src/openflight/phone_catch_up.py @@ -0,0 +1,97 @@ +"""Catch a reconnecting phone up on the session shots it missed. + +Both phone transports share this rule. A schema v2 client names the last shot it +has (``last_event_id`` in the BLE ``hello`` payload, ``Last-Event-ID`` on the +network stream) and receives that shot again plus every current-session shot +after it, oldest first. The named shot is resent because the client may hold +only its provisional version if it disconnected before the final one arrived. +A client that names no shot, or one the session no longer holds (cleared, +deleted, or from before a Pi restart), receives the whole session. Either way +the most recent ``CATCH_UP_LIMIT`` shots are sent, and clients upsert them by +``event_id`` so a shot they already have is harmless. + +The session itself (``monitor.get_shots()``) decides which shots exist, so +clears, deletes and profile changes need no bookkeeping here. ``PhoneShotCache`` +only remembers the exact v2 bytes last published per ``event_id``, so a replayed +shot carries the same ``final`` and ``enrichment`` state the phone would have +received live. +""" + +from __future__ import annotations + +import threading +from collections import OrderedDict +from typing import Sequence + +CATCH_UP_LIMIT = 20 +# Far above any realistic session; bounds memory on a Pi left running for days. +DEFAULT_CACHE_ENTRIES = 512 +# Event ids are UUIDs (36 characters); anything much longer is not one of ours. +MAX_EVENT_ID_LENGTH = 64 + +CatchUpEntry = tuple[str, bytes] + + +def normalize_last_event_id(value) -> str | None: + """Return a usable ``last_event_id``, or ``None`` when the client sent none. + + Invalid values are treated as "no anchor" rather than rejected: a client + that sends garbage still gets the whole session instead of a failed + ``hello`` that would push it back to version one. + """ + if not isinstance(value, str): + return None + value = value.strip() + if not value or len(value) > MAX_EVENT_ID_LENGTH: + return None + return value + + +def select_catch_up( + entries: Sequence[CatchUpEntry], + last_event_id: str | None, + *, + limit: int = CATCH_UP_LIMIT, +) -> list[CatchUpEntry]: + """The ``last_event_id`` shot and those after it (all shots if it is unknown). + + ``entries`` are ``(event_id, payload)`` pairs in session order. The result + keeps that order and holds at most the most recent ``limit`` shots. + """ + if limit < 1: + raise ValueError("Catch-up limit must be at least one") + start = 0 + if last_event_id is not None: + for index, (event_id, _payload) in enumerate(entries): + if event_id == last_event_id: + start = index + break + return list(entries[start:])[-limit:] + + +class PhoneShotCache: + """The latest encoded v2 shot per ``event_id``, bounded and thread-safe.""" + + def __init__(self, max_entries: int = DEFAULT_CACHE_ENTRIES): + if max_entries < 1: + raise ValueError("Phone shot cache must hold at least one entry") + self.max_entries = max_entries + self._lock = threading.Lock() + self._payloads: OrderedDict[str, bytes] = OrderedDict() + + def __len__(self) -> int: + with self._lock: + return len(self._payloads) + + def remember(self, event_id: str, payload: bytes) -> None: + """Store the newest payload for a shot (a final replaces its provisional).""" + with self._lock: + self._payloads[event_id] = payload + self._payloads.move_to_end(event_id) + while len(self._payloads) > self.max_entries: + self._payloads.popitem(last=False) + + def get(self, event_id: str) -> bytes | None: + """The last published payload for ``event_id``, if still cached.""" + with self._lock: + return self._payloads.get(event_id) diff --git a/src/openflight/server.py b/src/openflight/server.py index f52315933..ceab1d4e6 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -32,6 +32,8 @@ build_session_cleared_event, build_shot_deleted_event, build_shot_processing_event, + encode_shot_event_v2, + stable_shot_event_id, ) from .clubs import ClubType from .clubs.physics import ( @@ -47,6 +49,7 @@ SpeedReading, set_show_raw_readings, ) +from .phone_catch_up import PhoneShotCache, normalize_last_event_id, select_catch_up from .phone_orientation import ( PhoneOrientationMeasurement, PhoneOrientationValidationError, @@ -190,6 +193,10 @@ def get_profile_store() -> ProfileStore: # beyond the existing HTTP server. shot_stream = ShotStreamBroker() +# The exact v2 bytes last sent per shot, so a reconnecting phone's catch-up +# (BLE and network alike) replays what it would have received live. +phone_shot_cache = PhoneShotCache() + shutdown_lock = threading.Lock() shutdown_cleanup_started = False # One active hardware job plus two waiting shots is enough for normal golf @@ -1753,18 +1760,36 @@ def handle_get_camera_capture_settings(): socketio.emit("camera_capture_settings", _camera_capture_settings_payload()) +def _stream_catch_up_v2(last_event_id) -> list[tuple[str, bytes]] | None: + """Catch-up for a v2 stream client, or ``None`` (latest-shot replay) on failure.""" + try: + return phone_catch_up_v2(last_event_id) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Could not load missed shots for the stream", exc_info=True) + return None + + @app.route("/api/shots/stream") def shots_stream(): """Stream completed shots to phones as Server-Sent Events. ``?schema=2`` opts into schema v2 events; the default stays version one. + A v2 client resumes with ``Last-Event-ID`` (or ``?last_event_id=``) and is + seeded with the session shots it missed, as BLE ``hello`` does. """ schema_arg = request.args.get("schema", "1") if schema_arg not in ("1", "2"): return {"error": "Unsupported schema; use 1 or 2"}, 400 try: if schema_arg == "2": - subscriber = shot_stream.subscribe(schema=2, initial_events=_phone_state_events_v2()) + last_event_id = request.headers.get("Last-Event-ID") or request.args.get( + "last_event_id" + ) + subscriber = shot_stream.subscribe( + schema=2, + initial_events=_phone_state_events_v2(), + catch_up=_stream_catch_up_v2(last_event_id), + ) else: subscriber = shot_stream.subscribe() except ShotStreamFull as exc: @@ -3792,6 +3817,13 @@ def _publish_phone_shot_v2( enrichment: dict | None, ) -> None: """Hand one v2 shot to the BLE and SSE phone transports; never raises.""" + try: + phone_shot_cache.remember( + stable_shot_event_id(shot_data), + encode_shot_event_v2(shot_data, final=final, enrichment=enrichment), + ) + except (KeyError, TypeError, ValueError): + logger.warning("[SERVER] Could not cache v2 shot for phone catch-up", exc_info=True) transports = [("network stream", shot_stream)] if ble_publisher is not None: transports.append(("BLE", ble_publisher)) @@ -3802,6 +3834,31 @@ def _publish_phone_shot_v2( logger.warning("[SERVER] Failed to queue v2 shot over %s", name, exc_info=True) +def phone_catch_up_v2(last_event_id=None) -> list[tuple[str, bytes]]: + """The current-session v2 shots a reconnecting phone missed, oldest first. + + Shared by BLE ``hello`` and the network stream's ``Last-Event-ID``; see + ``openflight.phone_catch_up`` for the rule. The session decides which + shots exist, so cleared and deleted shots are never replayed. A shot is + replayed as the bytes last published for it, or rebuilt as a final shot + when that is no longer cached. + """ + session = monitor + if session is None or not hasattr(session, "get_shots"): + return [] + entries = [] + for shot in session.get_shots(): + try: + shot_data = shot_to_dict(shot) + event_id = stable_shot_event_id(shot_data) + payload = phone_shot_cache.get(event_id) or encode_shot_event_v2(shot_data, final=True) + except (AttributeError, KeyError, TypeError, ValueError): + logger.warning("[SERVER] Skipped a shot that could not be replayed", exc_info=True) + continue + entries.append((event_id, payload)) + return select_catch_up(entries, normalize_last_event_id(last_event_id)) + + def _publish_phone_event(event: dict) -> None: """Hand one schema v2 event to the BLE and SSE phone transports; never raises.""" transports = [("network stream", shot_stream)] @@ -5422,6 +5479,7 @@ def main(): ble_publisher = BleShotPublisher( command_handler=dispatch_phone_control_command, command_handler_v2=dispatch_phone_control_command_v2, + catch_up_provider=phone_catch_up_v2, ) ble_publisher.start() print("Bluetooth LE enabled (advertising as OpenFlight)") diff --git a/src/openflight/shot_stream.py b/src/openflight/shot_stream.py index 9253f8778..8b494430f 100644 --- a/src/openflight/shot_stream.py +++ b/src/openflight/shot_stream.py @@ -14,7 +14,7 @@ import logging import queue import threading -from typing import Iterable, Iterator, Mapping +from typing import Iterable, Iterator, Mapping, Sequence # The wire payload is the same versioned V1 shot event the BLE transport sends, # so both transports are validated against one contract and one test fixture. @@ -22,10 +22,10 @@ SCHEMA_VERSION, SCHEMA_VERSION_V2, build_club_event_v2, + build_shot_event_v2, encode_club_event, encode_message_v2, encode_shot_event, - encode_shot_event_v2, ) logger = logging.getLogger(__name__) @@ -44,13 +44,19 @@ class ShotStreamFull(RuntimeError): class StreamEvent(bytes): - """Encoded JSON carrying its SSE name while remaining bytes-compatible.""" + """Encoded JSON carrying its SSE name while remaining bytes-compatible. + + v2 shots also carry their ``event_id`` as the SSE ``id``, so a client's + ``Last-Event-ID`` names the last shot it received (see ``phone_catch_up``). + """ name: str + event_id: str | None - def __new__(cls, name: str, payload: bytes): + def __new__(cls, name: str, payload: bytes, event_id: str | None = None): event = super().__new__(cls, payload) event.name = name + event.event_id = event_id return event @@ -61,7 +67,9 @@ def format_event(event: StreamEvent | bytes) -> str: needs to be split across multiple ``data:`` lines. """ name = event.name if isinstance(event, StreamEvent) else "shot" - return f"event: {name}\ndata: {event.decode('utf-8')}\n\n" + event_id = event.event_id if isinstance(event, StreamEvent) else None + id_line = f"id: {event_id}\n" if event_id else "" + return f"event: {name}\n{id_line}data: {event.decode('utf-8')}\n\n" class ShotStreamBroker: @@ -91,7 +99,7 @@ def __init__( # else keeps receiving the unchanged version-one stream. self._v2_subscribers: set[int] = set() self._latest_payload: bytes | None = None - self._latest_v2_payload: bytes | None = None + self._latest_v2_event: StreamEvent | None = None @property def subscriber_count(self) -> int: @@ -124,15 +132,15 @@ def publish_v2_shot( ) -> bool: """Send a provisional or final v2 shot to schema v2 subscribers.""" try: - payload = encode_shot_event_v2(shot_data, final=final, enrichment=enrichment) + shot_event = build_shot_event_v2(shot_data, final=final, enrichment=enrichment) + event = StreamEvent("shot", encode_message_v2(shot_event), shot_event["event_id"]) except (KeyError, TypeError, ValueError): logger.warning("[STREAM] Failed to encode v2 shot payload", exc_info=True) return False with self._lock: - self._latest_v2_payload = payload + self._latest_v2_event = event subscribers = self._subscribers_for(SCHEMA_VERSION_V2) - event = StreamEvent("shot", payload) for subscriber in subscribers: self._offer(subscriber, event) return True @@ -174,11 +182,15 @@ def subscribe( *, schema: int = SCHEMA_VERSION, initial_events: Iterable[Mapping] = (), + catch_up: Sequence[tuple[str, bytes]] | None = None, ) -> queue.Queue[StreamEvent]: """Register a subscriber, seeded with the latest shot for replay. A schema v2 subscriber is first seeded with ``initial_events`` (current - state such as club and profiles), then the latest v2 shot. + state such as club and profiles), then its ``catch_up`` shots + (``(event_id, payload)`` pairs, oldest first) or, when catch-up is + unavailable (``None``), the latest v2 shot. Version one ignores + ``catch_up``: its shots carry no stable id to resume from. """ if schema not in (SCHEMA_VERSION, SCHEMA_VERSION_V2): raise ValueError(f"Unsupported shot stream schema: {schema}") @@ -192,11 +204,15 @@ def subscribe( with self._lock: if len(self._subscribers) >= self.max_subscribers: raise ShotStreamFull(f"Shot stream already has {self.max_subscribers} clients") - latest = ( - self._latest_v2_payload if schema == SCHEMA_VERSION_V2 else self._latest_payload - ) - if latest is not None: - seed.append(StreamEvent("shot", latest)) + if schema == SCHEMA_VERSION_V2 and catch_up is not None: + seed.extend( + StreamEvent("shot", payload, event_id) for event_id, payload in catch_up + ) + elif schema == SCHEMA_VERSION_V2: + if self._latest_v2_event is not None: + seed.append(self._latest_v2_event) + elif self._latest_payload is not None: + seed.append(StreamEvent("shot", self._latest_payload)) subscriber: queue.Queue[StreamEvent] = queue.Queue( maxsize=max(self.queue_size, len(seed)) ) diff --git a/tests/ble_harness.py b/tests/ble_harness.py index 8277bbf21..15f5eef70 100644 --- a/tests/ble_harness.py +++ b/tests/ble_harness.py @@ -350,6 +350,7 @@ def server_loopback(monkeypatch, tmp_path): """ from openflight import server as server_module # pylint: disable=import-outside-toplevel from openflight.launch_monitor import ClubType # pylint: disable=import-outside-toplevel + from openflight.phone_catch_up import PhoneShotCache # pylint: disable=import-outside-toplevel from openflight.profiles import ProfileStore # pylint: disable=import-outside-toplevel from openflight.shot_stream import ShotStreamBroker # pylint: disable=import-outside-toplevel @@ -376,11 +377,13 @@ def emit(event, payload=None, **_kwargs): monkeypatch.setattr(server_module, "debug_mode", False) monkeypatch.setattr(server_module, "sim_connectors", []) monkeypatch.setattr(server_module, "get_session_logger", lambda: None) + monkeypatch.setattr(server_module, "phone_shot_cache", PhoneShotCache()) loopback = BleLoopback( monkeypatch, command_handler=server_module.dispatch_phone_control_command, command_handler_v2=server_module.dispatch_phone_control_command_v2, + catch_up_provider=server_module.phone_catch_up_v2, ) with loopback: monkeypatch.setattr(server_module, "ble_publisher", loopback.publisher) diff --git a/tests/fixtures/ble_goldens/v1_response_hello.json b/tests/fixtures/ble_goldens/v1_response_hello.json index 4e6e1c715..0f8bb8e31 100644 --- a/tests/fixtures/ble_goldens/v1_response_hello.json +++ b/tests/fixtures/ble_goldens/v1_response_hello.json @@ -17,7 +17,8 @@ "profiles", "power_status", "shot_deleted", - "club" + "club", + "shot_catch_up" ], "characteristics": { "shot": "ED365FE6-3ABF-4FC3-8E44-D9525A22DABD", @@ -25,29 +26,30 @@ } } }, - "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636861726163746572697374696373223a7b22636f6e74726f6c223a2237424139364536332d313243322d344345302d424238342d333531334337464431343734222c2273686f74223a2245443336354645362d334142462d344643332d384534342d443935323541323244414244227d2c226665617475726573223a5b2270726f766973696f6e616c5f73686f7473222c2273686f745f70726f63657373696e67222c2270726f66696c6573222c22706f7765725f737461747573222c2273686f745f64656c65746564222c22636c7562225d2c22736368656d615f76657273696f6e223a327d2c22736368656d615f76657273696f6e223a317d", + "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636861726163746572697374696373223a7b22636f6e74726f6c223a2237424139364536332d313243322d344345302d424238342d333531334337464431343734222c2273686f74223a2245443336354645362d334142462d344643332d384534342d443935323541323244414244227d2c226665617475726573223a5b2270726f766973696f6e616c5f73686f7473222c2273686f745f70726f63657373696e67222c2270726f66696c6573222c22706f7765725f737461747573222c2273686f745f64656c65746564222c22636c7562222c2273686f745f63617463685f7570225d2c22736368656d615f76657273696f6e223a327d2c22736368656d615f76657273696f6e223a317d", "frames_hex": [ - "01000300167b226f6b223a747275652c22726571", - "0100030116756573745f6964223a223545304632", - "01000302164334412d384231442d344333452d39", - "01000303164636412d3744324231433045394138", - "010003041634222c22726573756c74223a7b2263", - "0100030516686172616374657269737469637322", - "01000306163a7b22636f6e74726f6c223a223742", - "01000307164139364536332d313243322d344345", - "0100030816302d424238342d3335313343374644", - "010003091631343734222c2273686f74223a2245", - "0100030a16443336354645362d334142462d3446", - "0100030b1643332d384534342d44393532354132", - "0100030c163244414244227d2c22666561747572", - "0100030d166573223a5b2270726f766973696f6e", - "0100030e16616c5f73686f7473222c2273686f74", - "0100030f165f70726f63657373696e67222c2270", - "0100031016726f66696c6573222c22706f776572", - "01000311165f737461747573222c2273686f745f", - "010003121664656c65746564222c22636c756222", - "01000313165d2c22736368656d615f7665727369", - "01000314166f6e223a327d2c22736368656d615f", - "010003151676657273696f6e223a317d" + "01000300177b226f6b223a747275652c22726571", + "0100030117756573745f6964223a223545304632", + "01000302174334412d384231442d344333452d39", + "01000303174636412d3744324231433045394138", + "010003041734222c22726573756c74223a7b2263", + "0100030517686172616374657269737469637322", + "01000306173a7b22636f6e74726f6c223a223742", + "01000307174139364536332d313243322d344345", + "0100030817302d424238342d3335313343374644", + "010003091731343734222c2273686f74223a2245", + "0100030a17443336354645362d334142462d3446", + "0100030b1743332d384534342d44393532354132", + "0100030c173244414244227d2c22666561747572", + "0100030d176573223a5b2270726f766973696f6e", + "0100030e17616c5f73686f7473222c2273686f74", + "0100030f175f70726f63657373696e67222c2270", + "0100031017726f66696c6573222c22706f776572", + "01000311175f737461747573222c2273686f745f", + "010003121764656c65746564222c22636c756222", + "01000313172c2273686f745f63617463685f7570", + "0100031417225d2c22736368656d615f76657273", + "0100031517696f6e223a327d2c22736368656d61", + "01000316175f76657273696f6e223a317d" ] } diff --git a/tests/fixtures/ble_goldens/v2_response_hello.json b/tests/fixtures/ble_goldens/v2_response_hello.json index 26298b2c1..a018b6264 100644 --- a/tests/fixtures/ble_goldens/v2_response_hello.json +++ b/tests/fixtures/ble_goldens/v2_response_hello.json @@ -17,7 +17,8 @@ "profiles", "power_status", "shot_deleted", - "club" + "club", + "shot_catch_up" ], "characteristics": { "shot": "ED365FE6-3ABF-4FC3-8E44-D9525A22DABD", @@ -25,29 +26,30 @@ } } }, - "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636861726163746572697374696373223a7b22636f6e74726f6c223a2237424139364536332d313243322d344345302d424238342d333531334337464431343734222c2273686f74223a2245443336354645362d334142462d344643332d384534342d443935323541323244414244227d2c226665617475726573223a5b2270726f766973696f6e616c5f73686f7473222c2273686f745f70726f63657373696e67222c2270726f66696c6573222c22706f7765725f737461747573222c2273686f745f64656c65746564222c22636c7562225d2c22736368656d615f76657273696f6e223a327d2c22736368656d615f76657273696f6e223a327d", + "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636861726163746572697374696373223a7b22636f6e74726f6c223a2237424139364536332d313243322d344345302d424238342d333531334337464431343734222c2273686f74223a2245443336354645362d334142462d344643332d384534342d443935323541323244414244227d2c226665617475726573223a5b2270726f766973696f6e616c5f73686f7473222c2273686f745f70726f63657373696e67222c2270726f66696c6573222c22706f7765725f737461747573222c2273686f745f64656c65746564222c22636c7562222c2273686f745f63617463685f7570225d2c22736368656d615f76657273696f6e223a327d2c22736368656d615f76657273696f6e223a327d", "frames_hex": [ - "01000000167b226f6b223a747275652c22726571", - "0100000116756573745f6964223a223545304632", - "01000002164334412d384231442d344333452d39", - "01000003164636412d3744324231433045394138", - "010000041634222c22726573756c74223a7b2263", - "0100000516686172616374657269737469637322", - "01000006163a7b22636f6e74726f6c223a223742", - "01000007164139364536332d313243322d344345", - "0100000816302d424238342d3335313343374644", - "010000091631343734222c2273686f74223a2245", - "0100000a16443336354645362d334142462d3446", - "0100000b1643332d384534342d44393532354132", - "0100000c163244414244227d2c22666561747572", - "0100000d166573223a5b2270726f766973696f6e", - "0100000e16616c5f73686f7473222c2273686f74", - "0100000f165f70726f63657373696e67222c2270", - "0100001016726f66696c6573222c22706f776572", - "01000011165f737461747573222c2273686f745f", - "010000121664656c65746564222c22636c756222", - "01000013165d2c22736368656d615f7665727369", - "01000014166f6e223a327d2c22736368656d615f", - "010000151676657273696f6e223a327d" + "01000000177b226f6b223a747275652c22726571", + "0100000117756573745f6964223a223545304632", + "01000002174334412d384231442d344333452d39", + "01000003174636412d3744324231433045394138", + "010000041734222c22726573756c74223a7b2263", + "0100000517686172616374657269737469637322", + "01000006173a7b22636f6e74726f6c223a223742", + "01000007174139364536332d313243322d344345", + "0100000817302d424238342d3335313343374644", + "010000091731343734222c2273686f74223a2245", + "0100000a17443336354645362d334142462d3446", + "0100000b1743332d384534342d44393532354132", + "0100000c173244414244227d2c22666561747572", + "0100000d176573223a5b2270726f766973696f6e", + "0100000e17616c5f73686f7473222c2273686f74", + "0100000f175f70726f63657373696e67222c2270", + "0100001017726f66696c6573222c22706f776572", + "01000011175f737461747573222c2273686f745f", + "010000121764656c65746564222c22636c756222", + "01000013172c2273686f745f63617463685f7570", + "0100001417225d2c22736368656d615f76657273", + "0100001517696f6e223a327d2c22736368656d61", + "01000016175f76657273696f6e223a327d" ] } diff --git a/tests/test_ble_catch_up.py b/tests/test_ble_catch_up.py new file mode 100644 index 000000000..457163d57 --- /dev/null +++ b/tests/test_ble_catch_up.py @@ -0,0 +1,277 @@ +"""BLE schema v2 catch-up: a reconnecting phone receives the session shots it missed. + +Driven over the loopback harness with the real publisher and server dispatch. +The phone follows the documented negotiation: subscribe to v2 control, write +``hello`` (optionally naming ``last_event_id``), then subscribe to v2 shot. +""" + +import json +from datetime import datetime + +import pytest +from ble_harness import V2_UUIDS, server_loopback, settle + +from openflight import server as server_module +from openflight.ble.protocol import ( + CONTROL_V2_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, + stable_shot_event_id, +) +from openflight.launch_monitor import ClubType, Shot +from openflight.phone_catch_up import CATCH_UP_LIMIT + + +@pytest.fixture +def pi(monkeypatch, tmp_path): + with server_loopback(monkeypatch, tmp_path) as loopback: + yield loopback + + +def _session(monkeypatch, count, *, profile_id=""): + """A mock monitor holding ``count`` shots; returns their event ids in order.""" + monitor = server_module.MockLaunchMonitor() + for index in range(count): + monitor._shots.append( + Shot( + ball_speed_mph=100.0 + index, + timestamp=datetime(2026, 9, 28, 12, index // 60, index % 60, 1000), + club=ClubType.DRIVER, + shot_number=index + 1, + profile_id=profile_id, + ) + ) + monkeypatch.setattr(server_module, "monitor", monitor) + return [stable_shot_event_id(server_module.shot_to_dict(shot)) for shot in monitor.get_shots()] + + +def _publish_latest_live(): + """Publish the session's last shot as the live path would (sets the latest replay).""" + shot_data = server_module.shot_to_dict(server_module.monitor.get_shots()[-1]) + server_module._publish_phone_shot_v2(shot_data, final=True, enrichment=None) + + +def _hello(phone, **payload): + return phone.request( + "hello", + {"client_schema_max": 2, **payload}, + control=CONTROL_V2_CHARACTERISTIC_UUID, + schema_version=2, + ) + + +def _connect(pi, name="v2-app", **hello_payload): + """Negotiate as the documented flow does: control, hello, then shot.""" + phone = pi.central(name) + phone.subscribe(CONTROL_V2_CHARACTERISTIC_UUID) + answer = _hello(phone, **hello_payload) + assert answer["ok"] is True, answer + phone.subscribe(SHOT_V2_CHARACTERISTIC_UUID) + return phone + + +def _received_ids(phone): + return [shot["event_id"] for shot in phone.decoded(SHOT_V2_CHARACTERISTIC_UUID)] + + +def test_hello_advertises_catch_up(pi): + phone = pi.central() + phone.subscribe(CONTROL_V2_CHARACTERISTIC_UUID) + + assert "shot_catch_up" in _hello(phone)["result"]["features"] + + +def test_hello_without_anchor_replays_whole_session_in_order(pi, monkeypatch): + ids = _session(monkeypatch, 3) + + phone = _connect(pi) + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 3) + settle(pi) + assert _received_ids(phone) == ids + assert all(shot["final"] for shot in phone.decoded(SHOT_V2_CHARACTERISTIC_UUID)) + + +def test_hello_with_known_anchor_resends_it_and_sends_missed_shots(pi, monkeypatch): + ids = _session(monkeypatch, 5) + + phone = _connect(pi, last_event_id=ids[1]) + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 4) + settle(pi) + assert _received_ids(phone) == ids[1:] + + +def test_hello_with_latest_anchor_resends_only_that_shot_once(pi, monkeypatch): + ids = _session(monkeypatch, 3) + _publish_latest_live() + + phone = _connect(pi, last_event_id=ids[-1]) + phone.wait_for(SHOT_V2_CHARACTERISTIC_UUID) + settle(pi, rounds=10) + + # The catch-up replaces the latest-shot replay rather than adding to it. + assert _received_ids(phone) == [ids[-1]] + + +def test_anchor_held_as_provisional_is_brought_up_to_final(pi, monkeypatch): + """The phone saw the provisional, disconnected, and missed the final.""" + ids = _session(monkeypatch, 2) + shot_data = server_module.shot_to_dict(server_module.monitor.get_shots()[-1]) + server_module._publish_phone_shot_v2(shot_data, final=False, enrichment={"status": "pending"}) + server_module._publish_phone_shot_v2(shot_data, final=True, enrichment={"status": "complete"}) + + phone = _connect(pi, last_event_id=ids[-1]) + + shot = phone.wait_for(SHOT_V2_CHARACTERISTIC_UUID) + assert shot["event_id"] == ids[-1] + assert shot["final"] is True + assert shot["enrichment"] == {"status": "complete"} + + +def test_unknown_anchor_replays_whole_session(pi, monkeypatch): + ids = _session(monkeypatch, 2) + + phone = _connect(pi, last_event_id="11111111-2222-3333-4444-555555555555") + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 2) + settle(pi) + assert _received_ids(phone) == ids + + +@pytest.mark.parametrize("anchor", [7, "", None, {"id": "x"}]) +def test_invalid_anchor_does_not_fail_hello(pi, monkeypatch, anchor): + ids = _session(monkeypatch, 2) + + phone = _connect(pi, last_event_id=anchor) + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 2) + settle(pi) + assert _received_ids(phone) == ids + + +def test_more_missed_shots_than_the_queue_holds_are_all_delivered(pi, monkeypatch): + count = pi.publisher.queue_size + 4 + ids = _session(monkeypatch, count) + + phone = _connect(pi) + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, count, timeout=15) + settle(pi) + assert _received_ids(phone) == ids + + +def test_catch_up_is_capped_to_most_recent_shots(pi, monkeypatch): + ids = _session(monkeypatch, CATCH_UP_LIMIT + 3) + + phone = _connect(pi) + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, CATCH_UP_LIMIT, timeout=20) + settle(pi, rounds=10) + assert _received_ids(phone) == ids[-CATCH_UP_LIMIT:] + + +def test_hello_after_shot_subscription_sends_catch_up_immediately(pi, monkeypatch): + ids = _session(monkeypatch, 3) + phone = pi.central() + phone.subscribe(*V2_UUIDS) + settle(pi) + + assert _hello(phone, last_event_id=ids[1])["ok"] is True + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 2) + settle(pi) + assert _received_ids(phone) == ids[1:] + + +def test_replayed_shot_is_byte_identical_to_last_published_version(pi, monkeypatch): + """A provisional-then-final shot replays as the final, enrichment included.""" + _session(monkeypatch, 1) + shot_data = server_module.shot_to_dict(server_module.monitor.get_shots()[0]) + server_module._publish_phone_shot_v2(shot_data, final=False, enrichment={"status": "pending"}) + server_module._publish_phone_shot_v2(shot_data, final=True, enrichment={"status": "complete"}) + published = server_module.phone_shot_cache.get(stable_shot_event_id(shot_data)) + + phone = _connect(pi) + + shot = phone.wait_for(SHOT_V2_CHARACTERISTIC_UUID) + settle(pi) + assert published is not None + assert shot == json.loads(published) + assert shot["final"] is True + assert shot["enrichment"] == {"status": "complete"} + assert phone.decoded(SHOT_V2_CHARACTERISTIC_UUID) == [shot] + + +def test_deleted_shots_are_not_replayed(pi, monkeypatch): + ids = _session(monkeypatch, 3) + first = server_module.monitor.get_shots()[0] + server_module.apply_delete_shot({"timestamp": first.timestamp.isoformat()}) + + phone = _connect(pi) + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 2) + settle(pi) + assert _received_ids(phone) == ids[1:] + + +def test_cleared_profile_shots_are_not_replayed(pi, monkeypatch): + active_id = server_module.get_profile_store().get_active().id + _session(monkeypatch, 2, profile_id=active_id) + server_module.apply_clear_session({}) + + phone = _connect(pi) + settle(pi, rounds=10) + + assert _received_ids(phone) == [] + + +def test_disconnect_before_shot_subscription_discards_pending_catch_up(pi, monkeypatch): + ids = _session(monkeypatch, 3) + _publish_latest_live() + phone = pi.central() + phone.subscribe(CONTROL_V2_CHARACTERISTIC_UUID) + assert _hello(phone)["ok"] is True + phone.disconnect() + settle(pi) + + # Reconnects without a new hello: only the latest-shot replay, as before. + phone.subscribe(SHOT_V2_CHARACTERISTIC_UUID) + phone.wait_for(SHOT_V2_CHARACTERISTIC_UUID) + settle(pi, rounds=10) + assert _received_ids(phone) == [ids[-1]] + + +def test_v1_hello_does_not_trigger_catch_up(pi, monkeypatch): + _session(monkeypatch, 3) + phone = pi.central() + phone.subscribe(CONTROL_V2_CHARACTERISTIC_UUID) + + answer = _hello(phone, client_schema_max=1) + phone.subscribe(SHOT_V2_CHARACTERISTIC_UUID) + settle(pi, rounds=10) + + assert answer["result"] == {"schema_version": 1, "features": []} + assert _received_ids(phone) == [] + + +def test_catch_up_failure_keeps_hello_and_falls_back_to_latest_replay(pi, monkeypatch): + ids = _session(monkeypatch, 2) + _publish_latest_live() + + def broken(_last_event_id): + raise RuntimeError("session unavailable") + + monkeypatch.setattr(pi.publisher, "catch_up_provider", broken) + + phone = _connect(pi) + + phone.wait_for(SHOT_V2_CHARACTERISTIC_UUID) + settle(pi, rounds=10) + assert _received_ids(phone) == [ids[-1]] + + +def test_no_monitor_means_no_catch_up(pi): + phone = _connect(pi) + settle(pi, rounds=10) + + assert _received_ids(phone) == [] diff --git a/tests/test_phone_catch_up.py b/tests/test_phone_catch_up.py new file mode 100644 index 000000000..e485d7af0 --- /dev/null +++ b/tests/test_phone_catch_up.py @@ -0,0 +1,113 @@ +"""Selecting and caching the session shots a reconnecting phone missed.""" + +import threading + +import pytest + +from openflight.phone_catch_up import ( + CATCH_UP_LIMIT, + PhoneShotCache, + normalize_last_event_id, + select_catch_up, +) + + +def _entries(count): + return [(f"id-{index}", f"payload-{index}".encode()) for index in range(count)] + + +def _ids(entries): + return [event_id for event_id, _payload in entries] + + +class TestSelectCatchUp: + def test_known_id_returns_that_shot_and_later_ones_in_order(self): + """The named shot is resent too: the phone may only have its provisional.""" + assert _ids(select_catch_up(_entries(5), "id-2")) == ["id-2", "id-3", "id-4"] + + def test_latest_id_returns_only_that_shot(self): + assert _ids(select_catch_up(_entries(5), "id-4")) == ["id-4"] + + def test_no_id_returns_whole_session(self): + assert _ids(select_catch_up(_entries(3), None)) == ["id-0", "id-1", "id-2"] + + def test_unknown_id_returns_whole_session(self): + """A cleared session, deleted shot or Pi restart: the phone's anchor is gone.""" + assert _ids(select_catch_up(_entries(3), "not-in-session")) == ["id-0", "id-1", "id-2"] + + def test_empty_session_returns_nothing(self): + assert select_catch_up([], None) == [] + assert select_catch_up([], "id-0") == [] + + def test_caps_to_most_recent_shots(self): + selected = select_catch_up(_entries(CATCH_UP_LIMIT + 5), None) + + assert len(selected) == CATCH_UP_LIMIT + assert selected[0][0] == "id-5" + assert selected[-1][0] == f"id-{CATCH_UP_LIMIT + 4}" + + def test_caps_missed_shots_after_a_known_id(self): + selected = select_catch_up(_entries(30), "id-2", limit=4) + + assert _ids(selected) == ["id-26", "id-27", "id-28", "id-29"] + + def test_payloads_travel_with_their_ids(self): + assert select_catch_up(_entries(2), "id-1") == [("id-1", b"payload-1")] + + def test_rejects_non_positive_limit(self): + with pytest.raises(ValueError): + select_catch_up(_entries(2), None, limit=0) + + +class TestNormalizeLastEventId: + @pytest.mark.parametrize("value", [None, "", " ", 7, True, ["id"], {"id": 1}, "x" * 65]) + def test_invalid_values_mean_no_anchor(self, value): + assert normalize_last_event_id(value) is None + + def test_strips_whitespace(self): + assert normalize_last_event_id(" abc ") == "abc" + + def test_accepts_uuid(self): + event_id = "05dd37ec-49ed-596b-b1a4-953d54e4f239" + assert normalize_last_event_id(event_id) == event_id + + +class TestPhoneShotCache: + def test_remembers_latest_payload_per_event_id(self): + cache = PhoneShotCache() + cache.remember("a", b"provisional") + cache.remember("a", b"final") + + assert cache.get("a") == b"final" + assert cache.get("missing") is None + + def test_evicts_least_recently_written_when_full(self): + cache = PhoneShotCache(max_entries=2) + cache.remember("a", b"1") + cache.remember("b", b"2") + cache.remember("a", b"1-final") # refreshes "a" + cache.remember("c", b"3") + + assert cache.get("b") is None + assert cache.get("a") == b"1-final" + assert cache.get("c") == b"3" + assert len(cache) == 2 + + def test_rejects_non_positive_size(self): + with pytest.raises(ValueError): + PhoneShotCache(max_entries=0) + + def test_concurrent_writers_stay_bounded(self): + cache = PhoneShotCache(max_entries=50) + + def write(prefix): + for index in range(500): + cache.remember(f"{prefix}-{index}", b"x") + + threads = [threading.Thread(target=write, args=(name,)) for name in "abcd"] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert len(cache) == 50 diff --git a/tests/test_phone_transport_v2.py b/tests/test_phone_transport_v2.py index 33c932948..bd4b52682 100644 --- a/tests/test_phone_transport_v2.py +++ b/tests/test_phone_transport_v2.py @@ -128,7 +128,19 @@ def test_unsupported_stream_schema_is_rejected(): def test_v2_stream_route_opts_in_and_seeds_current_state(monkeypatch, tmp_path): broker = ShotStreamBroker(heartbeat_interval_s=0.01) - broker.publish_v2_shot(_shot_data(), final=True) + # The session, not the broker's latest shot, decides what a v2 client is + # seeded with (see tests/test_shot_stream_catch_up.py). + monitor = server_module.MockLaunchMonitor() + monitor._shots.append( + Shot( + ball_speed_mph=150.0, + timestamp=datetime(2026, 9, 25, 12, 0, 0, 1), + club=ClubType.DRIVER, + shot_number=1, + ) + ) + monkeypatch.setattr(server_module, "monitor", monitor) + monkeypatch.setattr(server_module, "phone_shot_cache", server_module.PhoneShotCache()) monkeypatch.setattr(server_module, "shot_stream", broker) monkeypatch.setattr(server_module, "active_club", ClubType.IRON_7) monkeypatch.setattr(server_module, "power_monitor", None) diff --git a/tests/test_shot_stream_catch_up.py b/tests/test_shot_stream_catch_up.py new file mode 100644 index 000000000..aef897175 --- /dev/null +++ b/tests/test_shot_stream_catch_up.py @@ -0,0 +1,241 @@ +"""Network (SSE) schema v2 catch-up: the same rule BLE ``hello`` applies. + +A v2 stream client names the last shot it has with the standard +``Last-Event-ID`` header (or ``?last_event_id=``) and is seeded with the +session shots after it. v2 shot frames carry ``id: `` so an +``EventSource`` sends the header on its own when it reconnects. +""" + +import json +from datetime import datetime + +import pytest + +from openflight import server as server_module +from openflight.ble.protocol import encode_shot_event_v2, stable_shot_event_id +from openflight.launch_monitor import ClubType, Shot +from openflight.phone_catch_up import CATCH_UP_LIMIT, PhoneShotCache +from openflight.profiles import ProfileStore +from openflight.shot_stream import HEARTBEAT_FRAME, ShotStreamBroker, format_event + + +def _shot_data(number=1, ball_speed=150.0): + return { + "timestamp": f"2026-09-28T12:00:{number:02d}.000001", + "club": "driver", + "ball_speed_mph": ball_speed, + "estimated_carry_yards": 250, + "shot_number": number, + } + + +def _catch_up_entry(number): + data = _shot_data(number) + return stable_shot_event_id(data), encode_shot_event_v2(data, final=True) + + +def _drain(subscriber): + events = [] + while not subscriber.empty(): + events.append(subscriber.get_nowait()) + return events + + +def _parse(frame): + """(name, id or None, decoded data) from one SSE frame.""" + fields = dict(line.split(": ", 1) for line in frame.strip().split("\n")) + return fields["event"], fields.get("id"), json.loads(fields["data"]) + + +# -- broker --------------------------------------------------------------------------- + + +def test_catch_up_replaces_latest_seed_and_keeps_order(): + broker = ShotStreamBroker() + broker.publish_v2_shot(_shot_data(9), final=True) + entries = [_catch_up_entry(number) for number in (1, 2, 3)] + state = [{"schema_version": 2, "type": "club_changed", "club": "7-iron"}] + + subscriber = broker.subscribe(schema=2, initial_events=state, catch_up=entries) + + frames = [_parse(format_event(event)) for event in _drain(subscriber)] + assert [name for name, _id, _data in frames] == ["club_changed", "shot", "shot", "shot"] + assert [event_id for _name, event_id, _data in frames[1:]] == [eid for eid, _ in entries] + assert [data["shot_number"] for _name, _id, data in frames[1:]] == [1, 2, 3] + assert frames[0][1] is None # only shots carry an id + + +def test_empty_catch_up_seeds_no_shot(): + broker = ShotStreamBroker() + broker.publish_v2_shot(_shot_data(9), final=True) + + subscriber = broker.subscribe(schema=2, catch_up=[]) + + assert _drain(subscriber) == [] + + +def test_without_catch_up_the_latest_shot_is_still_seeded(): + broker = ShotStreamBroker() + broker.publish_v2_shot(_shot_data(9), final=True) + + events = _drain(broker.subscribe(schema=2)) + + assert [_parse(format_event(event))[2]["shot_number"] for event in events] == [9] + + +def test_v1_subscriber_ignores_catch_up(): + broker = ShotStreamBroker() + broker.publish(_shot_data(4)) + + events = _drain(broker.subscribe(catch_up=[_catch_up_entry(1)])) + + assert len(events) == 1 + name, event_id, data = _parse(format_event(events[0])) + assert (name, event_id, data["schema_version"]) == ("shot", None, 1) + + +def test_full_catch_up_fits_the_seed_queue(): + broker = ShotStreamBroker(queue_size=2) + entries = [_catch_up_entry(number) for number in range(1, CATCH_UP_LIMIT + 1)] + state = [{"schema_version": 2, "type": "club_changed", "club": "driver"}] + + events = _drain(broker.subscribe(schema=2, initial_events=state, catch_up=entries)) + + assert len(events) == CATCH_UP_LIMIT + 1 + + +def test_live_v2_shot_frames_carry_event_id_and_v1_frames_do_not(): + broker = ShotStreamBroker() + v1 = broker.subscribe() + v2 = broker.subscribe(schema=2) + + broker.publish(_shot_data(5)) + broker.publish_v2_shot(_shot_data(5), final=False, enrichment={"status": "pending"}) + broker.publish_event_v2({"schema_version": 2, "type": "shot_processing", "state": "failed"}) + + (v1_frame,) = [_parse(format_event(event)) for event in _drain(v1)] + v2_frames = [_parse(format_event(event)) for event in _drain(v2)] + assert v1_frame[1] is None + assert v2_frames[0][1] == stable_shot_event_id(_shot_data(5)) + assert v2_frames[0][2]["event_id"] == v2_frames[0][1] + assert v2_frames[1][1] is None + + +# -- route ---------------------------------------------------------------------------- + + +@pytest.fixture +def session(monkeypatch, tmp_path): + """A mock monitor with five shots behind the real stream route; returns event ids.""" + monitor = server_module.MockLaunchMonitor() + for index in range(5): + monitor._shots.append( + Shot( + ball_speed_mph=120.0 + index, + timestamp=datetime(2026, 9, 28, 12, 0, index, 1000), + club=ClubType.DRIVER, + shot_number=index + 1, + ) + ) + monkeypatch.setattr(server_module, "monitor", monitor) + monkeypatch.setattr(server_module, "shot_stream", ShotStreamBroker(heartbeat_interval_s=0.01)) + monkeypatch.setattr(server_module, "phone_shot_cache", PhoneShotCache()) + monkeypatch.setattr(server_module, "active_club", ClubType.DRIVER) + monkeypatch.setattr(server_module, "power_monitor", None) + monkeypatch.setattr(server_module, "profile_store", ProfileStore(tmp_path / "profiles.json")) + return [stable_shot_event_id(server_module.shot_to_dict(shot)) for shot in monitor.get_shots()] + + +def _stream_shot_ids(path, headers=None): + """Event ids of the shots seeded on connect (before the first idle heartbeat).""" + response = server_module.app.test_client().get(path, headers=headers or {}) + try: + assert response.status_code == 200 + frames = response.response + assert next(frames).decode("utf-8") == HEARTBEAT_FRAME + ids = [] + for raw in frames: + frame = raw.decode("utf-8") + if frame == HEARTBEAT_FRAME: + break + name, event_id, data = _parse(frame) + if name == "shot": + assert event_id == data.get("event_id") + ids.append(event_id) + return ids + finally: + response.close() + + +def test_route_without_anchor_seeds_whole_session(session): + assert _stream_shot_ids("/api/shots/stream?schema=2") == session + + +def test_route_last_event_id_header_resends_it_and_seeds_missed_shots(session): + ids = _stream_shot_ids("/api/shots/stream?schema=2", {"Last-Event-ID": session[2]}) + + assert ids == session[2:] + + +def test_route_last_event_id_query_parameter_matches_header(session): + assert _stream_shot_ids(f"/api/shots/stream?schema=2&last_event_id={session[2]}") == session[2:] + + +def test_route_header_takes_precedence_over_query(session): + ids = _stream_shot_ids( + f"/api/shots/stream?schema=2&last_event_id={session[0]}", + {"Last-Event-ID": session[3]}, + ) + + assert ids == session[3:] + + +def test_route_unknown_anchor_seeds_whole_session(session): + ids = _stream_shot_ids("/api/shots/stream?schema=2", {"Last-Event-ID": "not-a-shot"}) + + assert ids == session + + +def test_route_v1_stream_ignores_last_event_id(session): + server_module.shot_stream.publish( + server_module.shot_to_dict(server_module.monitor.get_shots()[-1]) + ) + + response = server_module.app.test_client().get( + "/api/shots/stream", headers={"Last-Event-ID": session[0]} + ) + try: + frames = response.response + next(frames) + name, event_id, data = _parse(next(frames).decode("utf-8")) + finally: + response.close() + + assert (name, event_id, data["schema_version"], data["ball_speed_mph"]) == ( + "shot", + None, + 1, + 124.0, + ) + + +def test_route_matches_ble_catch_up_for_the_same_anchor(session): + """Both transports apply one rule: same anchor, same shots, same bytes.""" + shared = server_module.phone_catch_up_v2(session[1]) + + assert _stream_shot_ids("/api/shots/stream?schema=2", {"Last-Event-ID": session[1]}) == [ + event_id for event_id, _payload in shared + ] + + +def test_route_survives_catch_up_failure_with_latest_replay(session, monkeypatch): + server_module.shot_stream.publish_v2_shot( + server_module.shot_to_dict(server_module.monitor.get_shots()[-1]), final=True + ) + + def broken(_last_event_id=None): + raise RuntimeError("session unavailable") + + monkeypatch.setattr(server_module, "phone_catch_up_v2", broken) + + assert _stream_shot_ids("/api/shots/stream?schema=2") == [session[-1]] From 0e31cad6d5a28dfe6c599d9b4dcdff43c564e44e Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Mon, 28 Sep 2026 14:05:13 -0400 Subject: [PATCH 17/19] docs(phone): document shot catch-up after a reconnect Adds the catch-up rule for BLE and the network stream to the phone guide (hello last_event_id, Last-Event-ID, 20-shot cap, anchor resend, broadcast and queue caveats), updates negotiation, the stream seed and delivery behaviour, lists the new tests, and notes it in the changelog. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R9pYpWtxN9nJ6vpqbXwB7m --- docs/changelog.md | 11 ++++++++ docs/ios-ble.md | 69 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 7c0f228ed..d9740b680 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -106,6 +106,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the real publisher against a loopback fake of Bless/BlueZ, and `tests/fixtures/ble_goldens/` holds framed hex goldens for client test suites (`scripts/ble/generate_goldens.py`). +- **Phones catch up on missed shots after a reconnect (schema v2).** A phone + that left the app or dropped the link used to get only the latest shot back. + Now `hello` accepts `last_event_id` over BLE, and the v2 network stream honours + `Last-Event-ID` (or `?last_event_id=`); both resend that shot and every + current-session shot after it, up to 20, with the whole session when no shot + is named. Cleared and deleted shots are never replayed, replays carry the + bytes last sent live, and v2 network `shot` frames now include `id:`. `hello` + advertises the `shot_catch_up` feature; the `hello` goldens changed only by + that entry. Apps that skip `last_event_id` receive the whole session and + upsert it by `event_id`. See + [catch-up](ios-ble.md#catch-up-after-a-reconnect). - **PAR-TEE connector.** `"type": "partee"` in `config/sim.json` streams shots to the [PAR-TEE](https://playpartee.com) iPhone app over OpenConnect V1 on the phone's Wi-Fi address (port 921 by default). Same shared codec as GSPro and diff --git a/docs/ios-ble.md b/docs/ios-ble.md index 0624bc616..929e86553 100644 --- a/docs/ios-ble.md +++ b/docs/ios-ble.md @@ -316,15 +316,20 @@ message. 1. Discover the service. If the v2 characteristics are missing, the Pi is version one: use the v1 pair. -2. Subscribe to the v2 control characteristic and write `hello`: +2. Subscribe to the v2 control characteristic and write `hello`. Add + `last_event_id`, the `event_id` of the newest shot the app already has, to + [catch up](#catch-up-after-a-reconnect) on shots missed while disconnected; + leave it out on a first connection: ```json - {"payload":{"client_schema_max":2},"request_id":"","schema_version":2,"type":"hello"} + {"payload":{"client_schema_max":2,"last_event_id":"05dd37ec-49ed-596b-b1a4-953d54e4f239"},"request_id":"","schema_version":2,"type":"hello"} ``` The result names the negotiated schema, the features and the v2 pair: ```json - {"ok":true,"request_id":"","result":{"characteristics":{"control":"7BA96E63-12C2-4CE0-BB84-3513C7FD1474","shot":"ED365FE6-3ABF-4FC3-8E44-D9525A22DABD"},"features":["provisional_shots","shot_processing","profiles","power_status","shot_deleted","club"],"schema_version":2},"schema_version":2} + {"ok":true,"request_id":"","result":{"characteristics":{"control":"7BA96E63-12C2-4CE0-BB84-3513C7FD1474","shot":"ED365FE6-3ABF-4FC3-8E44-D9525A22DABD"},"features":["provisional_shots","shot_processing","profiles","power_status","shot_deleted","club","shot_catch_up"],"schema_version":2},"schema_version":2} ``` -3. Subscribe to the v2 shot characteristic. The latest v2 shot is replayed. +3. Subscribe to the v2 shot characteristic. The catch-up shots arrive. A client + that skipped `hello` gets only the latest v2 shot, as before catch-up + existed. 4. Ask for state: `get_club`, `get_profiles` and, if wanted, `get_power_status`. `hello` also works on the v1 control characteristic, in a v1 envelope @@ -335,6 +340,49 @@ as version one. `client_schema_max: 1` returns `{"schema_version":1,"features":[ The v2 control characteristic accepts envelopes with `schema_version` 1 or 2 and always answers with `schema_version: 2`. +### Catch-up after a reconnect + +A phone that leaves the app, walks out of range or loses the network misses the +shots taken meanwhile. Schema v2 catches it up on reconnect, with one rule for +both transports: + +| | BLE | Network | +|---|---|---| +| Name the newest shot you have | `last_event_id` in the `hello` payload | `Last-Event-ID` request header, or `?last_event_id=` (the header wins) | +| Catch-up arrives | On the v2 shot characteristic, after the `hello` response; held until the phone subscribes to it | Seeded after the state events, before live events | +| Without catch-up | A client that skips `hello` gets the latest v2 shot | — (every v2 connection gets catch-up) | + +- The Pi sends the named shot again, then every current-session shot after + it, oldest first. Resending the named shot means a phone that only had its + provisional version (it disconnected before the final arrived) ends up with + the final. +- No `last_event_id`, or one the session no longer holds (the session was + cleared, that shot was deleted, or the Pi restarted), means the whole + current session. +- At most the 20 most recent shots are sent. Use `shot_number` gaps to tell + that older shots were not synced (deleted shots also leave gaps). +- Replayed shots are the exact bytes last sent live, including `final` and + `enrichment`. A shot no longer cached is rebuilt as a final shot with + `enrichment: null`. +- Cleared and deleted shots are never replayed: the Pi's session decides what + exists. +- Upsert by `event_id` as for live shots; replays of shots the app already has + are harmless. Invalid `last_event_id` values are treated as absent and never + fail `hello`. +- Every network v2 `shot` frame carries `id: `, so an `EventSource` + resends `Last-Event-ID` on its own when it reconnects. Other events carry no + `id`, which leaves the last shot id in place. Version-one streams and the v1 + characteristics have no catch-up: their `event_id` is not stable. +- Over BLE a notification reaches every subscribed phone, so another connected + phone receives the catch-up too and upserts it. +- A shot taken while a BLE catch-up is still being sent can push the oldest + queued catch-up shot out of the eight-message delivery queue. The phone then + lacks that one shot until its session is replayed in full (for example after + it reconnects without `last_event_id`). + +The Pi advertises support with the `shot_catch_up` feature in the `hello` +result. An older Pi ignores `last_event_id` and only replays the latest shot. + ### v2 shot Sent on the v2 shot characteristic. It carries every version-one field, plus: @@ -420,7 +468,9 @@ commands sent there fail with `Unsupported phone command`. v2. The default (no parameter, or `schema=1`) is the unchanged version-one stream; any other value returns `400`. A v2 stream opens with `: ping`, then the current state as `club_changed`, `profiles` and (with a battery monitor) -`power_status`, then the latest v2 shot. Event names match the `type` of the +`power_status`, then the [catch-up](#catch-up-after-a-reconnect) shots +(the whole session unless `Last-Event-ID` names a shot). Each `shot` frame +carries `id: `. Event names match the `type` of the payload: `shot`, `shot_processing`, `profiles`, `power_status`, `session_cleared`, `shot_deleted` and `club_changed`. Commands stay on `/api/club`, the calibration route and Socket.IO. @@ -444,8 +494,9 @@ message that would not fit instead of sending a truncated one. - Each connected client gets a bounded queue of eight unsent events; the oldest queued event is dropped if that client cannot keep up. One stalled phone cannot slow down another. -- Disconnecting clears that client's queue but retains the latest completed shot - for replay on the next connection. +- Disconnecting clears that client's queue. On the next connection a v2 client + is [caught up](#catch-up-after-a-reconnect) on the session shots it missed + (up to 20); a version-one client gets the latest completed shot replayed. - The iOS app ignores a replayed event when its `event_id` is already visible. v2 clients upsert by `event_id`, which also merges a provisional shot with its final version. @@ -499,7 +550,9 @@ Bluetooth adapter or `bless` install: uv run pytest tests/test_ble_protocol.py tests/test_ble_protocol_v2.py \ tests/test_ble_publisher.py tests/test_ble_loopback.py tests/test_ble_goldens.py \ tests/test_shot_stream.py tests/test_phone_transport_server.py \ - tests/test_phone_transport_v2.py tests/test_control_commands.py -v + tests/test_phone_transport_v2.py tests/test_control_commands.py \ + tests/test_phone_catch_up.py tests/test_ble_catch_up.py \ + tests/test_shot_stream_catch_up.py -v uv run python scripts/ble/generate_goldens.py --check ``` From bbefbda8a0fe5fe60f8afd94fde229f986c5dd25 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Mon, 28 Sep 2026 16:20:00 -0400 Subject: [PATCH 18/19] feat(mock): simulate the optional hardware phone apps react to A Pi with no radar, UPS or camera could not produce shot_processing, provisional-then-final shots or power_status, so phone apps could not be tested against them. Mock mode now simulates each one through the real server paths: - MockLaunchMonitor reports capturing then calculating to the processing callback like the rolling-buffer radar, and simulate_shot(fail=True) (Socket.IO {"fail": true}) reports failed without recording a shot. - --mock-enrichment-ms MS makes mock shots take the slow-enrichment path: recorded without horizontal launch, club path and spin axis, published provisional, then finalized once MockLaunchMonitor.enrich supplies them after MS. Past the 20 s deadline they finalize as skipped, as real hardware does. Off by default, so plain --mock is unchanged. - --battery mock adds a deterministic simulated battery that cycles from full through low and critical, then charges back to full. It never falls back to a real Linux battery, so a laptop's own battery cannot stand in for it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R9pYpWtxN9nJ6vpqbXwB7m --- src/openflight/power/factory.py | 6 + src/openflight/power/providers/__init__.py | 3 +- src/openflight/power/providers/mock.py | 74 ++++++ src/openflight/server.py | 156 +++++++++--- tests/test_mock_hardware.py | 267 +++++++++++++++++++++ tests/test_mock_power.py | 137 +++++++++++ 6 files changed, 610 insertions(+), 33 deletions(-) create mode 100644 src/openflight/power/providers/mock.py create mode 100644 tests/test_mock_hardware.py create mode 100644 tests/test_mock_power.py diff --git a/src/openflight/power/factory.py b/src/openflight/power/factory.py index 3526eb514..e03ae344f 100644 --- a/src/openflight/power/factory.py +++ b/src/openflight/power/factory.py @@ -8,6 +8,7 @@ from .providers.geekworm import GeekwormPowerReader from .providers.linux import LinuxPowerReader +from .providers.mock import MockPowerReader from .reader import PowerReader @@ -15,6 +16,8 @@ class BatteryProvider(str, Enum): """Battery hardware integrations supported by OpenFlight.""" GEEKWORM = "geekworm" + # A simulated battery for testing the UI and phone apps without a UPS. + MOCK = "mock" SUPPORTED_BATTERY_PROVIDERS = tuple(provider.value for provider in BatteryProvider) @@ -39,6 +42,9 @@ def create_power_reader( ) -> PowerReader: """Prefer standard Linux telemetry and fall back to the selected provider.""" normalized = normalize_battery_provider(provider) + if normalized is BatteryProvider.MOCK: + # Never let a real battery (e.g. a laptop's) stand in for the simulation. + return provider_factory() if provider_factory is not None else MockPowerReader() try: return LinuxPowerReader(power_supply_path=power_supply_path) except OSError: diff --git a/src/openflight/power/providers/__init__.py b/src/openflight/power/providers/__init__.py index 592e582a6..044f6b19b 100644 --- a/src/openflight/power/providers/__init__.py +++ b/src/openflight/power/providers/__init__.py @@ -2,5 +2,6 @@ from .geekworm import GeekwormPowerReader from .linux import LinuxPowerReader +from .mock import MockPowerReader -__all__ = ["GeekwormPowerReader", "LinuxPowerReader"] +__all__ = ["GeekwormPowerReader", "LinuxPowerReader", "MockPowerReader"] diff --git a/src/openflight/power/providers/mock.py b/src/openflight/power/providers/mock.py new file mode 100644 index 000000000..7c8d20b32 --- /dev/null +++ b/src/openflight/power/providers/mock.py @@ -0,0 +1,74 @@ +"""A simulated battery for exercising power status without UPS hardware.""" + +from __future__ import annotations + +from ..models import PowerSample + +# Li-ion cell voltage mapped linearly from empty to full; only needs to look +# plausible to the UI and phones. +_EMPTY_VOLTAGE_V = 3.3 +_FULL_VOLTAGE_V = 4.2 + + +class MockPowerReader: + """Cycle through a deterministic discharge and charge, one step per read. + + Starting full on battery, each read drops ``discharge_step`` percent down + to ``floor_percent``, passing the low (<=20%) and critical (<=10%) states; + then external power comes on and each read adds ``charge_step`` percent back + to 100%. The cycle then repeats. At the default 5 s poll it takes about + 100 s, so every power state is seen within two minutes. + """ + + def __init__( + self, + *, + discharge_step: float = 8.0, + charge_step: float = 12.0, + floor_percent: float = 5.0, + ): + if discharge_step <= 0 or charge_step <= 0: + raise ValueError("Mock battery steps must be positive") + if not 0.0 < floor_percent < 100.0: + raise ValueError("Mock battery floor must be between 0 and 100 percent") + self._cycle = self._build_cycle(discharge_step, charge_step, floor_percent) + self._index = 0 + + @property + def cycle_length(self) -> int: + """Number of reads before the simulated cycle repeats.""" + return len(self._cycle) + + @staticmethod + def _build_cycle( + discharge_step: float, charge_step: float, floor_percent: float + ) -> list[PowerSample]: + levels: list[tuple[float, bool]] = [] + percent = 100.0 + while percent > floor_percent: + levels.append((percent, False)) + percent -= discharge_step + levels.append((floor_percent, False)) + percent = floor_percent + while percent < 100.0: + percent = min(100.0, percent + charge_step) + levels.append((percent, True)) + return [ + PowerSample( + battery_percent=round(level, 1), + battery_voltage_v=round( + _EMPTY_VOLTAGE_V + (_FULL_VOLTAGE_V - _EMPTY_VOLTAGE_V) * level / 100.0, 3 + ), + external_power=plugged_in, + ) + for level, plugged_in in levels + ] + + def read(self) -> PowerSample: + """Return the next simulated sample.""" + sample = self._cycle[self._index] + self._index = (self._index + 1) % len(self._cycle) + return sample + + def close(self) -> None: + """The simulation holds no resources.""" diff --git a/src/openflight/server.py b/src/openflight/server.py index ceab1d4e6..94d26e91b 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -2342,9 +2342,14 @@ def handle_delete_shot(data): @socketio.on("simulate_shot") -def handle_simulate_shot(): - """Simulate a shot (only works in mock mode).""" - if monitor and isinstance(monitor, (MockLaunchMonitor, MockSwingSpeedMonitor)): +def handle_simulate_shot(data=None): + """Simulate a shot (only works in mock mode). + + ``{"fail": true}`` simulates a capture the radar could not process. + """ + if isinstance(monitor, MockLaunchMonitor): + monitor.simulate_shot(fail=bool(_payload_dict(data).get("fail"))) + elif isinstance(monitor, MockSwingSpeedMonitor): monitor.simulate_shot() @@ -3245,6 +3250,8 @@ def _attach_camera_replay(shot: Shot, camera_capture) -> None: def _enrich_shot_from_optional_hardware(shot: Shot) -> _ShotEnrichmentResult: """Mutate a shot with available radar/camera measurements and timings.""" + if shot.mode == "mock" and _mock_enrichment_enabled(): + return _ShotEnrichmentResult(iwr6843_ms=monitor.enrich(shot)) # Snapshot orientation before IWR capture can block, and select only data # timestamped before impact so impact vibration cannot bias the geometry. @@ -3933,11 +3940,16 @@ def _emit_ops_enrichment_skipped(shot: Shot, *, reason: str) -> None: ) +def _mock_enrichment_enabled() -> bool: + """Whether the mock monitor simulates optional hardware (``--mock-enrichment-ms``).""" + return getattr(monitor, "enrichment_ms", 0) > 0 and hasattr(monitor, "enrich") + + def _has_slow_shot_enrichment(shot: Shot) -> bool: """Whether optional hardware can add seconds to this shot callback.""" - return shot.mode != "mock" and ( - iwr6843_runtime is not None or camera_capture_runtime is not None - ) + if shot.mode == "mock": + return _mock_enrichment_enabled() + return iwr6843_runtime is not None or camera_capture_runtime is not None def _drain_shot_enrichment_queue() -> None: @@ -4176,6 +4188,7 @@ def start_monitor( swing_speed_mode: bool = False, swing_speed_kwargs: Optional[dict] = None, ops_baud: Optional[int] = None, + mock_enrichment_ms: float = 0.0, ): """ Start the monitor in launch monitor or swing speed mode. @@ -4186,6 +4199,8 @@ def start_monitor( trigger_type: Trigger strategy (sound or speed) debug: Enable verbose debug output ops_baud: Target UART baud when the OPS243 is on the GPIO header + mock_enrichment_ms: In mock mode, simulate optional hardware taking this + long, so shots go provisional then final (0 disables) """ global monitor, mock_mode, mock_swing_speed_mode, debug_mode, radar_config @@ -4202,7 +4217,7 @@ def start_monitor( print("[MODE] Mock swing speed training mode") elif mock: # Mock mode for testing without radar - monitor = MockLaunchMonitor() + monitor = MockLaunchMonitor(enrichment_ms=mock_enrichment_ms) elif swing_speed_mode: from .swing_speed import SwingSpeedMonitor @@ -4318,7 +4333,11 @@ def on_trigger_diagnostic(data: dict): if iwr6843_runtime is not None: iwr6843_runtime.capture_monitor.arm() else: - monitor.start(shot_callback=on_shot_detected, live_callback=on_live_reading) + monitor.start( + shot_callback=on_shot_detected, + live_callback=on_live_reading, + processing_callback=on_shot_processing, + ) def _fire_cloud_push(session_logger): @@ -4414,13 +4433,26 @@ def stop_monitor(): class MockLaunchMonitor: - """Mock launch monitor for UI development without radar hardware.""" + """Mock launch monitor for UI development without radar hardware. + + Like the rolling-buffer radar it reports ``capturing`` then ``calculating`` + to ``processing_callback`` (and ``failed`` for ``simulate_shot(fail=True)``). + With ``enrichment_ms`` it also stands in for IWR6843/camera hardware: shots + are recorded without horizontal launch, club path and spin axis, and + ``enrich`` supplies them after that delay, so mock shots go provisional then + final through the real enrichment pipeline. + """ - def __init__(self): + def __init__(self, *, enrichment_ms: float = 0.0, processing_step_s: float = 0.15): """Initialize mock monitor.""" + if enrichment_ms < 0: + raise ValueError("Mock enrichment delay must not be negative") + self.enrichment_ms = enrichment_ms + self.processing_step_s = processing_step_s self._shots: List[Shot] = [] self._running = False self._shot_callback = None + self._processing_callback = None self._current_club = ClubType.DRIVER def connect(self): @@ -4431,9 +4463,10 @@ def disconnect(self): """Disconnect from mock radar.""" self.stop() - def start(self, shot_callback=None, live_callback=None): # pylint: disable=unused-argument + def start(self, shot_callback=None, live_callback=None, processing_callback=None): # pylint: disable=unused-argument """Start mock monitoring.""" self._shot_callback = shot_callback + self._processing_callback = processing_callback self._running = True print("Mock monitor started - simulate shots via WebSocket") @@ -4441,8 +4474,64 @@ def stop(self): """Stop mock monitoring.""" self._running = False - def simulate_shot(self, ball_speed: float = None): - """Simulate a shot for testing using realistic TrackMan-based values.""" + def _notify_processing(self, state: str, *, pause: bool = True) -> None: + """Report a processing state; UI errors must not break the simulated shot.""" + if self._processing_callback is None: + return + try: + self._processing_callback(state) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[MOCK] Processing callback failed", exc_info=True) + if pause and self.processing_step_s > 0: + time.sleep(self.processing_step_s) + + @staticmethod + def _simulated_direction(confidence: float) -> dict: + """Horizontal launch, club path and spin axis: what IWR6843/camera measure.""" + defaults = SHOT_SIMULATION_DEFAULTS + launch_h = random.gauss(0, defaults.horizontal_launch_std_dev_deg) + return { + "launch_angle_horizontal": round(launch_h, 1), + "launch_angle_horizontal_confidence": confidence, + "launch_angle_horizontal_source": "mock", + "club_path_deg": round( + random.uniform(-defaults.club_path_max_abs_deg, defaults.club_path_max_abs_deg), + 1, + ), + "spin_axis_deg": round( + launch_h + - random.uniform( + -defaults.spin_axis_error_max_abs_deg, + defaults.spin_axis_error_max_abs_deg, + ), + 1, + ), + } + + def enrich(self, shot: Shot) -> float: + """Simulate optional hardware: wait ``enrichment_ms``, then add direction. + + Returns the elapsed milliseconds, as the hardware timings are reported. + """ + started = time.monotonic() + time.sleep(self.enrichment_ms / 1000.0) + confidence = shot.launch_angle_confidence or SHOT_SIMULATION_DEFAULTS.confidence_min + for field, value in self._simulated_direction(confidence).items(): + setattr(shot, field, value) + return (time.monotonic() - started) * 1000.0 + + def simulate_shot(self, ball_speed: float = None, *, fail: bool = False): + """Simulate a shot for testing using realistic TrackMan-based values. + + ``fail`` simulates a capture the radar could not process: ``failed`` is + reported and no shot is recorded (returns ``None``). + """ + self._notify_processing("capturing") + self._notify_processing("calculating", pause=not fail) + if fail: + self._notify_processing("failed", pause=False) + return None + physics = get_club_physics(self._current_club) profile = get_club_simulation_profile(self._current_club) defaults = SHOT_SIMULATION_DEFAULTS @@ -4473,10 +4562,15 @@ def simulate_shot(self, ball_speed: float = None): defaults.min_launch_deg, random.gauss(physics.optimal_launch_deg, profile.launch_std_dev_deg), ) - launch_h = random.gauss(0, defaults.horizontal_launch_std_dev_deg) launch_confidence = round( random.uniform(defaults.confidence_min, defaults.confidence_max), 2 ) + # With simulated enrichment these arrive later, as from IWR6843/camera. + direction = ( + {field: None for field in self._simulated_direction(launch_confidence)} + if self.enrichment_ms > 0 + else self._simulated_direction(launch_confidence) + ) club_aoa = round( random.gauss( @@ -4494,30 +4588,13 @@ def simulate_shot(self, ball_speed: float = None): spin_rpm=spin_rpm, spin_confidence=random.choice(defaults.spin_confidence_choices), launch_angle_vertical=round(launch_v, 1), - launch_angle_horizontal=round(launch_h, 1), launch_angle_confidence=launch_confidence, launch_angle_vertical_confidence=launch_confidence, - launch_angle_horizontal_confidence=launch_confidence, launch_angle_vertical_source="mock", - launch_angle_horizontal_source="mock", angle_source="mock", club_angle_deg=club_aoa, - club_path_deg=round( - random.uniform( - -defaults.club_path_max_abs_deg, - defaults.club_path_max_abs_deg, - ), - 1, - ), - spin_axis_deg=round( - launch_h - - random.uniform( - -defaults.spin_axis_error_max_abs_deg, - defaults.spin_axis_error_max_abs_deg, - ), - 1, - ), mode="mock", + **direction, ) self._shots.append(shot) @@ -4751,6 +4828,16 @@ def main(): action="store_true", help="Run swing speed training mode with simulated reps and no OPS radar", ) + parser.add_argument( + "--mock-enrichment-ms", + type=float, + default=0.0, + metavar="MS", + help=( + "With --mock, simulate IWR6843/camera enrichment taking MS milliseconds: " + "shots arrive provisional, then final with direction data (default: off)" + ), + ) parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)") parser.add_argument( "--web-port", type=int, default=8080, help="Web server port (default: 8080)" @@ -5155,6 +5242,10 @@ def main(): # launch angle), so require it whenever the K-LD7 radars are enabled. if args.kld7 and args.kld7_mount_tilt is None: parser.error("--kld7-mount-tilt is required when --kld7 is passed") + if args.mock_enrichment_ms < 0: + parser.error("--mock-enrichment-ms must not be negative") + if args.mock_enrichment_ms > 0 and (not args.mock or args.mock_swing_speed): + parser.error("--mock-enrichment-ms requires --mock (launch monitor mode)") if args.mock_swing_speed: args.mock = True args.swing_speed = True @@ -5450,6 +5541,7 @@ def main(): swing_speed_mode=args.swing_speed, swing_speed_kwargs=swing_speed_kwargs, ops_baud=args.ops_baud, + mock_enrichment_ms=args.mock_enrichment_ms, ) except Exception: monitor_recovery = ( diff --git a/tests/test_mock_hardware.py b/tests/test_mock_hardware.py new file mode 100644 index 000000000..69942206f --- /dev/null +++ b/tests/test_mock_hardware.py @@ -0,0 +1,267 @@ +"""Mock mode simulating the optional-hardware shot path. + +``MockLaunchMonitor`` reports ``capturing`` then ``calculating`` like the +rolling-buffer radar, can fail a shot on request, and with ``enrichment_ms`` +holds back the direction fields (horizontal launch, club path, spin axis) that +IWR6843/camera hardware measures, so mock shots go provisional then final +through the real enrichment pipeline. +""" + +import threading +from datetime import datetime + +import pytest +from ble_harness import V2_UUIDS, server_loopback, settle + +from openflight import server as server_module +from openflight.ble.protocol import CONTROL_V2_CHARACTERISTIC_UUID, SHOT_V2_CHARACTERISTIC_UUID +from openflight.launch_monitor import ClubType, Shot + +DIRECTION_FIELDS = ("launch_angle_horizontal", "club_path_deg", "spin_axis_deg") + + +def _monitor(**kwargs): + kwargs.setdefault("processing_step_s", 0) + return server_module.MockLaunchMonitor(**kwargs) + + +# -- monitor -------------------------------------------------------------------------- + + +class TestProcessingStates: + def test_shot_reports_capturing_then_calculating(self): + states, shots = [], [] + monitor = _monitor() + monitor.start(shot_callback=shots.append, processing_callback=states.append) + + shot = monitor.simulate_shot() + + assert states == ["capturing", "calculating"] + assert shots == [shot] + + def test_failed_shot_reports_failed_and_records_nothing(self): + states, shots = [], [] + monitor = _monitor() + monitor.start(shot_callback=shots.append, processing_callback=states.append) + + assert monitor.simulate_shot(fail=True) is None + + assert states == ["capturing", "calculating", "failed"] + assert shots == [] + assert monitor.get_shots() == [] + + def test_without_processing_callback_shots_still_work(self): + monitor = _monitor() + monitor.start() + + assert monitor.simulate_shot() is not None + assert monitor.simulate_shot(fail=True) is None + + def test_processing_callback_errors_do_not_stop_the_shot(self): + def broken(_state): + raise RuntimeError("UI went away") + + monitor = _monitor() + monitor.start(processing_callback=broken) + + assert monitor.simulate_shot() is not None + assert len(monitor.get_shots()) == 1 + + +class TestSimulatedEnrichment: + def test_disabled_by_default_and_shots_are_complete(self): + monitor = _monitor() + monitor.start() + + shot = monitor.simulate_shot() + + assert monitor.enrichment_ms == 0 + assert all(getattr(shot, field) is not None for field in DIRECTION_FIELDS) + + def test_enabled_shot_waits_for_enrichment_to_get_direction(self): + monitor = _monitor(enrichment_ms=1) + monitor.start() + shot = monitor.simulate_shot() + assert all(getattr(shot, field) is None for field in DIRECTION_FIELDS) + + elapsed_ms = monitor.enrich(shot) + + assert elapsed_ms >= 1 + assert all(getattr(shot, field) is not None for field in DIRECTION_FIELDS) + assert shot.launch_angle_horizontal_source == "mock" + + def test_rejects_negative_enrichment(self): + with pytest.raises(ValueError): + server_module.MockLaunchMonitor(enrichment_ms=-1) + + +# -- server --------------------------------------------------------------------------- + + +def _mock_shot(): + return Shot( + ball_speed_mph=150.0, + timestamp=datetime(2026, 9, 28, 12, 0, 0), + club=ClubType.DRIVER, + mode="mock", + ) + + +def test_mock_shots_skip_slow_enrichment_by_default(monkeypatch): + monkeypatch.setattr(server_module, "monitor", _monitor()) + + assert server_module._has_slow_shot_enrichment(_mock_shot()) is False + + +def test_mock_enrichment_routes_mock_shots_through_slow_path(monkeypatch): + monkeypatch.setattr(server_module, "monitor", _monitor(enrichment_ms=1)) + + assert server_module._has_slow_shot_enrichment(_mock_shot()) is True + + +def test_simulate_shot_handler_can_fail_a_shot(monkeypatch): + monitor = _monitor() + states, shots = [], [] + monitor.start(shot_callback=shots.append, processing_callback=states.append) + monkeypatch.setattr(server_module, "monitor", monitor) + + server_module.handle_simulate_shot({"fail": True}) + server_module.handle_simulate_shot() + + assert states == ["capturing", "calculating", "failed", "capturing", "calculating"] + assert len(shots) == 1 + + +# -- phone, over the BLE loopback ----------------------------------------------------- + + +@pytest.fixture +def pi(monkeypatch, tmp_path): + with server_loopback(monkeypatch, tmp_path) as loopback: + yield loopback + + +def _run_enrichment_in_threads(monkeypatch): + monkeypatch.setattr(server_module, "shot_enrichment_task", None) + monkeypatch.setattr( + server_module, + "shot_enrichment_queue", + server_module.queue.Queue(maxsize=server_module._SHOT_ENRICHMENT_QUEUE_CAPACITY), + ) + + def start_background_task(target, *args, **kwargs): + thread = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True) + thread.start() + return thread + + monkeypatch.setattr(server_module.socketio, "start_background_task", start_background_task) + + +def _wait_idle(): + with server_module._shot_finalization_condition: + assert server_module._shot_finalization_condition.wait_for( + lambda: ( + not server_module._shot_finalization_order + and not server_module._shot_finalization_running + ), + timeout=5, + ) + + +def _started_monitor(monkeypatch, **kwargs): + monitor = _monitor(**kwargs) + monitor.start( + shot_callback=server_module.on_shot_detected, + processing_callback=server_module.on_shot_processing, + ) + monkeypatch.setattr(server_module, "monitor", monitor) + return monitor + + +def _processing_states(phone): + return [ + message["state"] + for message in phone.decoded(CONTROL_V2_CHARACTERISTIC_UUID) + if message.get("type") == "shot_processing" + ] + + +def test_phone_sees_processing_then_provisional_then_final(pi, monkeypatch): + _run_enrichment_in_threads(monkeypatch) + monitor = _started_monitor(monkeypatch, enrichment_ms=50) + phone = pi.central("v2-app") + phone.subscribe(*V2_UUIDS) + settle(pi) + + monitor.simulate_shot() + _wait_idle() + + provisional, final = phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 2) + assert (provisional["final"], final["final"]) == (False, True) + assert provisional["event_id"] == final["event_id"] + assert provisional["enrichment"] == {"status": "pending"} + assert final["enrichment"] == {"status": "complete"} + assert provisional["club_path_deg"] is None + assert final["club_path_deg"] is not None + phone.wait_for( + CONTROL_V2_CHARACTERISTIC_UUID, + lambda message: message.get("state") == "calculating", + ) + assert _processing_states(phone) == ["capturing", "calculating"] + + +def test_enrichment_past_the_deadline_finalizes_as_skipped(pi, monkeypatch): + """What ``--mock-enrichment-ms`` above the 20 s deadline shows, sped up.""" + _run_enrichment_in_threads(monkeypatch) + monkeypatch.setattr(server_module, "_SHOT_ENRICHMENT_DEADLINE_S", 0.2) + monitor = _started_monitor(monkeypatch, enrichment_ms=1500) + phone = pi.central("v2-app") + phone.subscribe(*V2_UUIDS) + settle(pi) + + monitor.simulate_shot() + + provisional, final = phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 2, timeout=10) + assert provisional["enrichment"] == {"status": "pending"} + assert final["final"] is True + assert final["enrichment"] == {"status": "skipped", "reason": "deadline"} + assert final["event_id"] == provisional["event_id"] + # Let the late enrichment finish so it cannot leak into the next test. + worker = server_module.shot_enrichment_task + if worker is not None: + worker.join(timeout=5) + _wait_idle() + + +def test_phone_sees_failed_processing_and_no_shot(pi, monkeypatch): + monitor = _started_monitor(monkeypatch) + phone = pi.central("v2-app") + phone.subscribe(*V2_UUIDS) + settle(pi) + + monitor.simulate_shot(fail=True) + + phone.wait_for(CONTROL_V2_CHARACTERISTIC_UUID, lambda message: message.get("state") == "failed") + settle(pi, rounds=5) + assert _processing_states(phone) == ["capturing", "calculating", "failed"] + assert phone.decoded(SHOT_V2_CHARACTERISTIC_UUID) == [] + + +# -- command line --------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "argv", + [ + ["--mock-enrichment-ms", "500"], + ["--mock-swing-speed", "--mock-enrichment-ms", "500"], + ["--mock", "--mock-enrichment-ms", "-1"], + ], +) +def test_mock_enrichment_flag_is_validated_before_startup(monkeypatch, argv): + monkeypatch.setattr(server_module.sys, "argv", ["openflight-server", *argv]) + + with pytest.raises(SystemExit) as exit_info: + server_module.main() + + assert exit_info.value.code == 2 diff --git a/tests/test_mock_power.py b/tests/test_mock_power.py new file mode 100644 index 000000000..da1a4359c --- /dev/null +++ b/tests/test_mock_power.py @@ -0,0 +1,137 @@ +"""The simulated battery (``--battery mock``) for testing power status without a UPS.""" + +import argparse + +import pytest + +from openflight import server as server_module +from openflight.power import ( + SUPPORTED_BATTERY_PROVIDERS, + BatteryProvider, + PowerMonitor, + PowerState, + create_power_reader, +) +from openflight.power.providers import MockPowerReader + + +def _samples(reader, count): + return [reader.read() for _ in range(count)] + + +def _fake_linux_battery(root): + """A sysfs tree that LinuxPowerReader would happily read.""" + battery = root / "battery" + battery.mkdir() + (battery / "type").write_text("Battery\n", encoding="ascii") + (battery / "capacity").write_text("47\n", encoding="ascii") + (battery / "voltage_now").write_text("3787500\n", encoding="ascii") + mains = root / "charger@0" + mains.mkdir() + (mains / "type").write_text("Mains\n", encoding="ascii") + (mains / "online").write_text("1\n", encoding="ascii") + + +def test_mock_is_a_supported_provider(): + assert "mock" in SUPPORTED_BATTERY_PROVIDERS + assert BatteryProvider("mock") is BatteryProvider.MOCK + + +def test_cycle_starts_full_on_battery(): + first = MockPowerReader().read() + + assert first.battery_percent == 100.0 + assert first.external_power is False + + +def test_cycle_reaches_every_available_state(): + reader = MockPowerReader() + + states = {PowerMonitor._state_for(sample) for sample in _samples(reader, reader.cycle_length)} + + assert states == { + PowerState.ON_BATTERY, + PowerState.LOW, + PowerState.CRITICAL, + PowerState.PLUGGED_IN, + } + + +def test_discharges_on_battery_then_charges_when_plugged_in(): + reader = MockPowerReader() + cycle = _samples(reader, reader.cycle_length) + unplugged = [sample for sample in cycle if not sample.external_power] + plugged = [sample for sample in cycle if sample.external_power] + + assert unplugged and plugged + # One unplugged stretch, then one plugged-in stretch. + assert cycle == unplugged + plugged + unplugged_levels = [sample.battery_percent for sample in unplugged] + plugged_levels = [sample.battery_percent for sample in plugged] + assert unplugged_levels == sorted(unplugged_levels, reverse=True) + assert plugged_levels == sorted(plugged_levels) + assert plugged_levels[-1] == 100.0 + + +def test_cycle_repeats_exactly(): + reader = MockPowerReader() + + first = _samples(reader, reader.cycle_length) + second = _samples(reader, reader.cycle_length) + + assert first == second + + +def test_readings_stay_plausible(): + reader = MockPowerReader() + + for sample in _samples(reader, reader.cycle_length): + assert 0.0 <= sample.battery_percent <= 100.0 + assert 3.3 <= sample.battery_voltage_v <= 4.2 + + +def test_voltage_tracks_charge(): + reader = MockPowerReader() + unplugged = [ + sample for sample in _samples(reader, reader.cycle_length) if not sample.external_power + ] + + voltages = [sample.battery_voltage_v for sample in unplugged] + assert voltages == sorted(voltages, reverse=True) + + +def test_factory_uses_mock_even_when_a_real_battery_exists(tmp_path): + """A laptop's own battery must not silently replace the simulated one.""" + _fake_linux_battery(tmp_path) + + reader = create_power_reader("mock", power_supply_path=tmp_path) + + assert isinstance(reader, MockPowerReader) + + +def test_power_monitor_publishes_mock_statuses(): + published = [] + power = PowerMonitor(provider="mock", on_status=published.append) + + for _ in range(3): + power.poll_once() + + assert [status.available for status in published] == [True, True, True] + assert {status.provider for status in published} == {"mock"} + assert published[0].battery_percent == 100.0 + assert published[1].battery_percent < published[0].battery_percent + assert power.status == published[-1] + + +def test_cli_accepts_mock_provider(): + parser = argparse.ArgumentParser() + server_module._add_battery_arguments(parser) + + assert parser.parse_args(["--battery", "mock"]).battery == "mock" + + +def test_rejects_non_positive_steps(): + with pytest.raises(ValueError): + MockPowerReader(discharge_step=0) + with pytest.raises(ValueError): + MockPowerReader(charge_step=-1) From 78de599f1b483a9904624907d7f5a9b556494ebd Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Mon, 28 Sep 2026 16:20:00 -0400 Subject: [PATCH 19/19] docs(mock): document simulating hardware for phone testing Adds a phone-guide section on running a hardware-free Pi that produces every phone event (--battery mock, --mock-enrichment-ms, failed shots), lists the mock battery provider, and notes it in the changelog. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R9pYpWtxN9nJ6vpqbXwB7m --- docs/changelog.md | 8 ++++++++ docs/ios-ble.md | 32 ++++++++++++++++++++++++++++++++ docs/using/battery.md | 1 + 3 files changed, 41 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index d9740b680..eed85bd9d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -117,6 +117,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 that entry. Apps that skip `last_event_id` receive the whole session and upsert it by `event_id`. See [catch-up](ios-ble.md#catch-up-after-a-reconnect). +- **Mock mode simulates the optional hardware phones react to.** So a Pi + with no radar, UPS or camera can exercise every phone event: mock shots + now report `shot_processing` `capturing` then `calculating` like the radar, + `simulate_shot` with `{"fail": true}` reports `failed` without a shot, + `--mock-enrichment-ms MS` sends mock shots provisional then final through the + real enrichment pipeline (above the 20 s deadline they finalize as skipped), + and `--battery mock` cycles `power_status` through every state. See + [simulating hardware](ios-ble.md#simulating-hardware-on-a-pi-without-it). - **PAR-TEE connector.** `"type": "partee"` in `config/sim.json` streams shots to the [PAR-TEE](https://playpartee.com) iPhone app over OpenConnect V1 on the phone's Wi-Fi address (port 921 by default). Same shared codec as GSPro and diff --git a/docs/ios-ble.md b/docs/ios-ble.md index 929e86553..37bc9b0b7 100644 --- a/docs/ios-ble.md +++ b/docs/ios-ble.md @@ -561,6 +561,38 @@ connection from iOS and Android, pairing and permission prompts, fragment pacing over a real link, reconnects after a Pi restart, background behaviour, and coexistence with SSE and Socket.IO clients. +### Simulating hardware on a Pi without it + +Mock mode can produce every phone event, so a Pi with no radar, UPS or camera +can still exercise the app end to end over the real radio: + +```bash +scripts/start-kiosk.sh --mock --ble --battery mock --mock-enrichment-ms 1500 +``` + +| What | How | The phone sees | +|---|---|---| +| Shots | Tap **Simulate shot** on the kiosk, or emit `simulate_shot` over Socket.IO | `shot_processing` `capturing` then `calculating`, then the shot | +| A failed capture | Emit `simulate_shot` with `{"fail": true}` | `shot_processing` `capturing`, `calculating`, `failed`, and no shot | +| Provisional then final | `--mock-enrichment-ms MS` | A provisional shot (`enrichment: pending`, no horizontal launch, club path or spin axis), then the final one with them, sharing one `event_id` | +| Skipped enrichment | `--mock-enrichment-ms` above the 20 s deadline, e.g. `25000` | The final shot with `enrichment: {"status":"skipped","reason":"deadline"}` | +| Battery | `--battery mock` | `power_status` cycling from 100% on battery through `low` and `critical`, then `plugged_in` back to 100%, about every 100 s; `get_power_status` answers with the latest reading | + +Without `--battery`, `get_power_status` fails with `Battery monitoring is not +enabled`. Without `--iwr6843`, the calibration command fails with `409` `TI +IWR6843 radar is not enabled`; both are the expected error paths for a phone to +show. + +To fire shots from another machine without the kiosk: + +```bash +ssh 'cd /tmp && ~/.local/bin/uv run -q --no-project --with "python-socketio[client]" python -c " +import socketio, time +sio = socketio.Client(); sio.connect(\"http://localhost:8080\") +sio.emit(\"simulate_shot\") # or: sio.emit(\"simulate_shot\", {\"fail\": True}) +time.sleep(1); sio.disconnect()"' +``` + ## Troubleshooting **The network transport will not connect.** diff --git a/docs/using/battery.md b/docs/using/battery.md index 8b7b88823..c1592a121 100644 --- a/docs/using/battery.md +++ b/docs/using/battery.md @@ -10,6 +10,7 @@ down Linux automatically. | Provider | CLI value | Hardware | Setup guide | |---|---|---|---| | Geekworm | `geekworm` | X1202 and X1206 | [Geekworm X1202/X1206](../build/battery.md) | +| Simulated | `mock` | None: cycles through every power state for testing the UI and phone apps | [Simulating hardware](../ios-ble.md#simulating-hardware-on-a-pi-without-it) | Start OpenFlight with an installed provider: