From 3dd3a149ea30dd1140ef48f330efd3fccdac1092 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Mon, 28 Sep 2026 17:58:34 -0400 Subject: [PATCH] feat(ble): Bluetooth LE connection for phone apps `--ble` advertises one GATT service with a shot and a control characteristic, speaking phone schema version 2 (the only version; none was released before it). Shots carry event_id, shot_number, profile, carry_range, spin_source, launch_angle_confidence, final and enrichment, and hardware-enriched shots arrive provisional then final with one event_id. Phones get club_changed, profiles, power_status, shot_processing, session_cleared and shot_deleted events, and can get_club, set_club, get_profiles, set_active_profile and get_power_status through the same server functions Socket.IO uses. BLE is unauthenticated, so it is read-and-select only. Club selection becomes one Pi-owned value (apply_club_selection) that the kiosk, phones and simulators share and that is broadcast over Socket.IO and BLE. scripts/setup/configure_bluetooth.sh (offered by setup.sh) sets [GATT] Client = false so BlueZ stops asking iPhones to pair every 30 s. The optional `ble` extra pins bless 0.3.0 on Linux, and start-kiosk.sh --ble syncs it. Tests drive the real publisher over a loopback fake of Bless/BlueZ, and tests/fixtures/ble_goldens/ holds framed hex goldens for client test suites; they are byte-identical to what the current phone app was tested against on a Pi 5. Ported from jake-fishtech's feat/iOS-ble work and #282, split out so the network transport, catch-up, phone calibration and mock hardware simulation follow as separate PRs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R9pYpWtxN9nJ6vpqbXwB7m --- docs/changelog.md | 25 + docs/ios-ble.md | 403 +++++++++++++++ pyproject.toml | 16 + scripts/ble/generate_goldens.py | 266 ++++++++++ scripts/hardware-test/test_ble_advertise.py | 197 +++++++ scripts/setup/configure_bluetooth.sh | 114 ++++ scripts/setup/setup.sh | 15 +- scripts/start-kiosk.sh | 3 + src/openflight/ble/__init__.py | 25 + src/openflight/ble/protocol.py | 354 +++++++++++++ src/openflight/ble/publisher.py | 485 ++++++++++++++++++ src/openflight/server.py | 287 +++++++++-- tests/ble_harness.py | 387 ++++++++++++++ .../ble_goldens/client_v2_get_profiles.json | 32 ++ .../ble_goldens/client_v2_set_club.json | 36 ++ .../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_deleted.json | 22 + .../ble_goldens/v2_event_shot_processing.json | 21 + .../ble_goldens/v2_response_error.json | 25 + .../ble_goldens/v2_response_hello.json | 53 ++ .../v2_response_set_active_profile.json | 32 ++ tests/fixtures/ble_goldens/v2_shot_final.json | 78 +++ .../ble_goldens/v2_shot_provisional.json | 78 +++ tests/fixtures/shot_v2.json | 29 ++ tests/test_ble_goldens.py | 113 ++++ tests/test_ble_loopback.py | 327 ++++++++++++ tests/test_ble_protocol.py | 427 +++++++++++++++ tests/test_ble_publisher.py | 380 ++++++++++++++ tests/test_configure_bluetooth.py | 208 ++++++++ tests/test_control_commands.py | 87 ++++ tests/test_phone_transport.py | 246 +++++++++ tests/test_phone_transport_server.py | 73 +++ tests/test_project_metadata.py | 10 + tests/test_server.py | 31 ++ tests/test_start_kiosk.py | 28 +- zensical.toml | 1 + 40 files changed, 5309 insertions(+), 35 deletions(-) create mode 100644 docs/ios-ble.md create mode 100644 scripts/ble/generate_goldens.py create mode 100755 scripts/hardware-test/test_ble_advertise.py create mode 100755 scripts/setup/configure_bluetooth.sh 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 tests/ble_harness.py 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/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_deleted.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/fixtures/shot_v2.json create mode 100644 tests/test_ble_goldens.py create mode 100644 tests/test_ble_loopback.py create mode 100644 tests/test_ble_protocol.py create mode 100644 tests/test_ble_publisher.py create mode 100644 tests/test_configure_bluetooth.py create mode 100644 tests/test_control_commands.py create mode 100644 tests/test_phone_transport.py create mode 100644 tests/test_phone_transport_server.py diff --git a/docs/changelog.md b/docs/changelog.md index b29cdd7fe..f71c6a2bb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -53,6 +53,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 full horizontal speed, overstating attack angle on any shot with club path. ### Added +- **Bluetooth LE connection for phone apps.** `--ble` (with the optional `ble` + extra, `bless==0.3.0` on Linux) advertises one GATT service with a shot and a + control characteristic, speaking schema version 2; without BlueZ or the + extra, `--ble` logs that Bluetooth is unavailable and the server carries on. + Shots carry `event_id`, `shot_number`, profile, `carry_range`, + `spin_source`, `launch_angle_confidence`, `final` and `enrichment`; + hardware-enriched shots arrive twice, provisional then final, with one + `event_id`. Phones also get `club_changed`, `profiles`, `power_status`, + `shot_processing`, `session_cleared` and `shot_deleted` events, and can + `get_club`, `set_club`, `get_profiles`, `set_active_profile` and + `get_power_status` through the same server functions Socket.IO uses. BLE is + unauthenticated, so it is read-and-select only: clearing sessions, deleting + shots and editing profiles stay on the kiosk. Every club change (kiosk, + phone, simulator) is broadcast over Socket.IO and BLE. `hello` reports the + schema, features and characteristics. New + `scripts/setup/configure_bluetooth.sh` (offered by `setup.sh`) sets + `Client = false` under `[GATT]` in `/etc/bluetooth/main.conf`, which stops + iPhones being asked to pair every 30 s. `start-kiosk.sh --ble` syncs the + `ble` extra and `setup.sh` installs it. 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`). See + [phone app connection](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 and simulator delivery. - **Electron kiosk shell.** `scripts/start-kiosk.sh` now opens the UI in a pinned Electron window (`electron@44`) instead of whichever system browser happens to be installed. Chromium remains a fallback if Electron is not installed (including diff --git a/docs/ios-ble.md b/docs/ios-ble.md new file mode 100644 index 000000000..d3ca0f4b5 --- /dev/null +++ b/docs/ios-ble.md @@ -0,0 +1,403 @@ +# Phone app connection (Bluetooth LE) + +> **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, 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 +Bluetooth LE, using the versioned payload described below (schema version 2). +The Kotlin Multiplatform companion for Android and iOS, +[`btripp/openflight-mobile-kmp`](https://github.com/btripp/openflight-mobile-kmp), +speaks it. 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) +speaks an earlier, unreleased version one that the Pi does not serve; it needs +updating to schema 2. + +## 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 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, 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 +``` + +BLE startup and delivery errors are isolated from shot recording. If Bluetooth +is unavailable, the browser UI and session logger continue to work. + +## Connect the phone app + +The phone apps are not part of this repository; build and install one from its +own repository. Start OpenFlight on the Pi with `--ble`. The app scans only for +the OpenFlight service, connects automatically, and subscribes to shot and +control notifications. 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 change +goes over the framed control characteristic described below. + +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. + +## Wire protocol + +Phones speak JSON messages, schema version 2, split across BLE notifications. +There is no version one on the wire: no app was released against it. + +### Characteristics + +| Attribute | UUID | Properties | +|---|---|---| +| Service | `B6F633F2-E6E3-45AE-84B4-968ECCA2D9C7` | | +| Shot notification | `ED365FE6-3ABF-4FC3-8E44-D9525A22DABD` | notify | +| Control | `7BA96E63-12C2-4CE0-BB84-3513C7FD1474` | write with response, notify | + +BlueZ notifies every central subscribed to a characteristic from that +characteristic's single value: the GATT D-Bus API has no per-device notify, and +Bless drops the writing device's path. So every connected phone receives every +shot, event and command response; match responses to your own requests by +`request_id`. + +### Encoding and framing + +Messages are compact JSON with sorted keys, and text is **UTF-8** rather than +`\uXXXX` escapes (that is what lets twelve 40-character profile names fit in one +message). Over BLE each message is split into conservative 20-byte +notifications, each 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, counted per characteristic | +| 3 | Zero-based fragment index | +| 4 | Total fragment count | +| 5–19 | JSON payload fragment | + +Group frames by sequence, ignore duplicate fragment indexes, order fragments by +index, and decode UTF-8 only after all fragments arrive: a fragment boundary can +split a multi-byte character. The shot contract fixture is +`tests/fixtures/shot_v2.json`, and framed byte-level goldens for every message +type live in `tests/fixtures/ble_goldens/` (see +[Testing without hardware](#testing-without-hardware)). + +### Commands and responses + +Commands are written to the control characteristic in 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`: + +```json +{"payload":{"club":"7-iron"},"request_id":"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","schema_version":2,"type":"set_club"} +{"ok":true,"request_id":"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","result":{"club":"7-iron","status":"applied"},"schema_version":2} +``` + +Responses and events share the control characteristic: messages with a `type` +are events, the rest are responses. A request envelope may carry +`schema_version` 1 or 2 (so a client can reuse one encoder for `hello`); +responses always carry `schema_version: 2`. Each 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":"7-iron"}`: the Pi-owned club, unchanged | | +| `set_club` | `{"club":"7-iron"}` | `{"status":"applied","club":"7-iron"}` | `club_changed` | +| `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` | | + +Over BLE the phone 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 and any unknown command fail +with `Unsupported phone command: `. 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. + +### Negotiation + +1. Discover the service and subscribe to the control characteristic. +2. Write `hello`: + ```json + {"payload":{"client_schema_max":2},"request_id":"","schema_version":2,"type":"hello"} + ``` + The result names the schema, the features and the characteristics: + ```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} + ``` +3. Subscribe to the shot characteristic. The latest shot is replayed. +4. Ask for state: `get_club`, `get_profiles` and, if wanted, `get_power_status`. + +A `client_schema_max` below 2 fails with `ok:false`: the Pi speaks schema 2 only. + +### Shot + +Sent on the shot characteristic: + +| Field | Type | Meaning | +|---|---|---| +| `schema_version` | `2` | | +| `type` | `"shot"` | | +| `event_id` | UUID string | Stable per shot: the provisional and final versions of one shot share it. **Upsert by `event_id`** | +| `final` | bool | `false`: OPS-only provisional shot, sent while optional hardware (IWR6843, camera) is still working. `true`: the final shot | +| `timestamp`, `club` | string | Detection time and the club the shot was hit with | +| `ball_speed_mph`, `estimated_carry_yards` | number | | +| `club_speed_mph`, `smash_factor`, `launch_angle_vertical`, `launch_angle_horizontal`, `spin_rpm`, `club_path_deg`, `spin_axis_deg` | number or null | `null` when the active hardware could not produce it | +| `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 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"} +``` + +### Events + +Notified on the 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 a profile's shots are cleared on the kiosk | +| `shot_deleted` | `timestamp` (the shot's delete key) | After a shot is deleted on the kiosk | +| `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` | + +```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,"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"} +``` + +Profiles 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 BLE message. + +### Size budget + +A BLE message is at most 255 fragments × 15 bytes = 3,825 bytes. Tests encode +a worst-case 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 + +- Shot processing never waits for Bluetooth, and a BLE failure cannot affect + 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; the latest completed shot is + replayed on the next connection. +- Clients upsert shots by `event_id`, which ignores replays of shots they + already have and merges a provisional shot with its final version. + +## Security and scope + +The phone protocol intentionally has no application authentication or +encryption layer. Enable BLE only where nearby Bluetooth devices receiving +shots and issuing club and profile selections is acceptable. + +BLE is unauthenticated: any nearby device can connect and write the control +characteristic. The protocol therefore exposes only reading state, selecting +(club, active profile) over Bluetooth. Actions that delete data, clearing a session or deleting a shot, and +profile add, rename and remove stay on the kiosk (Socket.IO), where +they have the same exposure the browser UI already has. Revisit this only with +authenticated pairing. + +## 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` in either request envelope, a + provisional-then-final shot reaching every phone with one `event_id`, club + and profile commands, unknown commands, and one phone leaving while another + keeps receiving. +- `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_publisher.py tests/test_ble_loopback.py tests/test_ble_goldens.py \ + tests/test_phone_transport_server.py tests/test_phone_transport.py \ + tests/test_control_commands.py tests/test_configure_bluetooth.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 Socket.IO clients. + +## Troubleshooting + +**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 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` followed +by `[BLE] Client 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 +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. + +**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 + +The Python side is covered in [Testing without hardware](#testing-without-hardware). +Phone apps run their own test suites and decode the committed goldens in +`tests/fixtures/ble_goldens/`. 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/ble/generate_goldens.py b/scripts/ble/generate_goldens.py new file mode 100644 index 000000000..b5d212ce0 --- /dev/null +++ b/scripts/ble/generate_goldens.py @@ -0,0 +1,266 @@ +#!/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, + SHOT_CHARACTERISTIC_UUID, + build_club_event, + build_control_response, + build_hello_result, + build_power_status_event, + build_profiles_event, + build_session_cleared_event, + build_shot_deleted_event, + build_shot_event, + build_shot_processing_event, + encode_message, + fragment_payload, +) + +ROOT = Path(__file__).resolve().parents[2] +FIXTURES = ROOT / "tests" / "fixtures" +GOLDENS_DIR = FIXTURES / "ble_goldens" +# "v2" names the wire schema (``schema_version: 2``); client test suites use these names. +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 shot 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( + server.shot_to_dict(shot), + final=True, + enrichment={"status": "complete"}, + ) + + +def _golden(name: str, description: str, characteristic: str, message: dict, sequence: int) -> dict: + payload = encode_message(message) + 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 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_v2 = json.loads(SHOT_V2_FIXTURE.read_text(encoding="utf-8")) + provisional = build_shot_event( + 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( + "v2_shot_provisional", + "Provisional OPS-only v2 shot (final:false); same event_id as v2_shot_final.", + SHOT_CHARACTERISTIC_UUID, + provisional, + 0, + ), + _golden( + "v2_shot_final", + "Final v2 shot (tests/fixtures/shot_v2.json).", + SHOT_CHARACTERISTIC_UUID, + shot_v2, + 1, + ), + _golden( + "v2_response_hello", + "hello answered on the v2 control characteristic.", + CONTROL_CHARACTERISTIC_UUID, + build_control_response(REQUEST_ID, result=build_hello_result(2)), + 0, + ), + _golden( + "v2_response_set_active_profile", + "set_active_profile accepted.", + CONTROL_CHARACTERISTIC_UUID, + build_control_response( + REQUEST_ID, + result={"status": "applied", "active_profile_id": PROFILE_B}, + ), + 1, + ), + _golden( + "v2_response_error", + "A failed v2 command.", + CONTROL_CHARACTERISTIC_UUID, + build_control_response(REQUEST_ID, error="Unknown profile"), + 2, + ), + _golden( + "v2_event_shot_processing", + "shot_processing notify (capturing | calculating | failed).", + CONTROL_CHARACTERISTIC_UUID, + build_shot_processing_event("calculating"), + 3, + ), + _golden( + "v2_event_profiles", + "profiles notify: ids and names only, UTF-8 (not \\u-escaped) names.", + CONTROL_CHARACTERISTIC_UUID, + build_profiles_event(profiles), + 4, + ), + _golden( + "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_CHARACTERISTIC_UUID, + build_profiles_event(worst_case_profiles_snapshot()), + 5, + ), + _golden( + "v2_event_power_status", + "power_status notify: the Socket.IO payload beside type.", + CONTROL_CHARACTERISTIC_UUID, + build_power_status_event(power), + 6, + ), + _golden( + "v2_event_session_cleared", + "session_cleared notify.", + CONTROL_CHARACTERISTIC_UUID, + build_session_cleared_event(PROFILE_A), + 7, + ), + _golden( + "v2_event_shot_deleted", + "shot_deleted notify: a shot was deleted (over Wi-Fi/Socket.IO); key is its timestamp.", + CONTROL_CHARACTERISTIC_UUID, + build_shot_deleted_event(shot_v2["timestamp"]), + 9, + ), + _golden( + "v2_event_club_changed", + "club_changed notify on the v2 control characteristic.", + CONTROL_CHARACTERISTIC_UUID, + build_club_event("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/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/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 72de33bd3..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 # @@ -174,9 +175,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 @@ -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/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/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..5e7f9e4ae --- /dev/null +++ b/src/openflight/ble/protocol.py @@ -0,0 +1,354 @@ +"""OpenFlight phone protocol: versioned JSON messages and BLE framing helpers. + +Phones speak schema version 2 over BLE and the network stream. There is no +version one on the wire: no app was released against it. +""" + +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 = "ED365FE6-3ABF-4FC3-8E44-D9525A22DABD" +CONTROL_CHARACTERISTIC_UUID = "7BA96E63-12C2-4CE0-BB84-3513C7FD1474" + +SCHEMA_VERSION = 2 +# Control requests may arrive in a version-one envelope (a client reusing one +# encoder for ``hello``); responses always carry ``SCHEMA_VERSION``. +ACCEPTED_REQUEST_SCHEMAS = (1, SCHEMA_VERSION) +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 + +# Present on every shot, ``null`` when unknown. +_OPTIONAL_SHOT_FIELDS = ( + "club_speed_mph", + "smash_factor", + "launch_angle_vertical", + "launch_angle_horizontal", + "spin_rpm", + "club_path_deg", + "spin_axis_deg", + "shot_number", + "profile_id", + "profile_name", + "carry_range", + "spin_source", + "launch_angle_confidence", +) +_FIRST_SESSION_FIELD = _OPTIONAL_SHOT_FIELDS.index("shot_number") +# Session fields where the server uses "" for "none"; phones get ``null``. +_BLANK_AS_NULL_FIELDS = frozenset( + { + "shot_number", + "profile_id", + "profile_name", + "carry_range", + "spin_source", + "launch_angle_confidence", + } +) + +# Stable namespace for per-shot event ids. Changing it changes every id. +_SHOT_EVENT_NAMESPACE = uuid.UUID("D49C99A9-A305-49CA-A8C2-7D30B7645988") + +FEATURES = ( + "provisional_shots", + "shot_processing", + "profiles", + "power_status", + "shot_deleted", + "club", +) + +EVENT_TYPES = ( + "shot", + "shot_processing", + "profiles", + "power_status", + "session_cleared", + "shot_deleted", + "club_changed", +) + +ENRICHMENT_STATUSES = ("pending", "complete", "skipped") + + +def encode_message(message: Mapping) -> bytes: + """Encode one message: compact, sorted UTF-8 JSON. + + 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 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 build_enrichment(status: str, reason: str | None = None) -> dict: + """Build the ``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( + shot_data: Mapping, + *, + final: bool, + enrichment: Mapping | None = None, + event_id: str | None = None, +) -> dict: + """Build a shot event from ``shot_to_dict`` output. + + ``final`` is false for an OPS-only provisional shot sent while optional + hardware is still working; the final shot reuses its ``event_id``. + """ + # Key order only shows in pretty-printed fixtures (the wire sorts keys); it + # is kept stable so the committed cross-language goldens do not churn. + event = { + "schema_version": SCHEMA_VERSION, + "event_id": event_id or stable_shot_event_id(shot_data), + "timestamp": shot_data["timestamp"], + "club": shot_data["club"], + "ball_speed_mph": shot_data["ball_speed_mph"], + "estimated_carry_yards": shot_data["estimated_carry_yards"], + } + for field in _OPTIONAL_SHOT_FIELDS[:_FIRST_SESSION_FIELD]: + event[field] = shot_data.get(field) + event["type"] = "shot" + event["final"] = bool(final) + for field in _OPTIONAL_SHOT_FIELDS[_FIRST_SESSION_FIELD:]: + value = shot_data.get(field) + event[field] = None if field in _BLANK_AS_NULL_FIELDS and value == "" else value + 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( + shot_data: Mapping, + *, + final: bool, + enrichment: Mapping | None = None, + event_id: str | None = None, +) -> bytes: + """Encode a shot event.""" + return encode_message( + build_shot_event(shot_data, final=final, enrichment=enrichment, event_id=event_id) + ) + + +def build_event(event_type: str, fields: Mapping | None = None) -> dict: + """Build one notify event: ``{"schema_version":2,"type":...,**fields}``.""" + if event_type not in EVENT_TYPES: + raise ValueError(f"Unknown event type: {event_type!r}") + fields = dict(fields or {}) + if "schema_version" in fields or "type" in fields: + raise ValueError("Event fields must not override schema_version or type") + return {"schema_version": SCHEMA_VERSION, "type": event_type, **fields} + + +def build_club_event(club: str) -> dict: + """``club_changed``: the authoritative club, broadcast on every change.""" + if not isinstance(club, str) or not club: + raise ValueError("Club must be a non-empty string") + return build_event("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("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( + "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("power_status", status) + + +def build_session_cleared_event(profile_id: str) -> dict: + """``session_cleared``: the profile whose shots were removed.""" + return build_event("session_cleared", {"profile_id": profile_id}) + + +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("shot_deleted", {"timestamp": timestamp}) + + +def build_control_response( + request_id: str, + *, + result: Mapping | None = None, + error: str | None = None, +) -> 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 schema, features and characteristics.""" + 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(f"client_schema_max must be at least {SCHEMA_VERSION}") + return { + "schema_version": SCHEMA_VERSION, + "features": list(FEATURES), + "characteristics": { + "shot": SHOT_CHARACTERISTIC_UUID, + "control": CONTROL_CHARACTERISTIC_UUID, + }, + } + + +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..cf667c8b9 --- /dev/null +++ b/src/openflight/ble/publisher.py @@ -0,0 +1,485 @@ +"""Non-blocking BLE GATT publisher backed by Bless/BlueZ.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import threading +import uuid +from collections.abc import Callable +from typing import Mapping + +from .protocol import ( + ACCEPTED_REQUEST_SCHEMAS, + CONTROL_CHARACTERISTIC_UUID, + SERVICE_UUID, + SHOT_CHARACTERISTIC_UUID, + FragmentReassembler, + build_club_event, + build_control_response, + build_hello_result, + encode_message, + encode_shot_event, + fragment_payload, +) + +logger = logging.getLogger(__name__) + +CommandHandler = Callable[[str, Mapping], tuple[dict, int]] + +_SHOT = SHOT_CHARACTERISTIC_UUID.lower() +_CONTROL = CONTROL_CHARACTERISTIC_UUID.lower() +_ALL_CHARACTERISTICS = frozenset({_SHOT, _CONTROL}) + + +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 shots and phone events without coupling the radar thread to Bluetooth. + + One GATT service holds a notify-only shot characteristic and a + write-and-notify control characteristic for commands, responses and + events. BlueZ notifies every subscribed central from one characteristic + value, so every connected phone receives every message. + """ + + def __init__( + self, + *, + name: str = "OpenFlight", + queue_size: int = 8, + fragment_interval_s: float = 0.01, + command_handler: CommandHandler | 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 + # Follows BlueZ's per-characteristic subscriptions when those are + # visible; any subscription opens delivery. + self._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 + + @property + def subscribed(self) -> bool: + """Whether a central is subscribed to 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_shot( + self, + shot_data: Mapping, + *, + final: bool, + enrichment: Mapping | None = None, + ) -> bool: + """Store the latest shot (provisional or final) and notify subscribed centrals.""" + try: + payload = encode_shot_event(shot_data, final=final, enrichment=enrichment) + fragment_payload(payload, sequence=0) + 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: + event = build_club_event(club) + except (TypeError, ValueError): + logger.warning("[BLE] Failed to encode club payload", exc_info=True) + return False + return self.publish_event(event) + + def publish_event(self, event: Mapping) -> bool: + """Notify subscribed centrals of one event on the control characteristic.""" + try: + payload = encode_message(event) + fragment_payload(payload, sequence=0) + except (TypeError, ValueError): + logger.warning("[BLE] Failed to encode event", 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._subscriptions = set() + 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. + + 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_bluez_notify_change(app, session, True) + app.StopNotify = lambda session: self._on_bluez_notify_change(app, session, False) + + def _on_bluez_notify_change(self, app, session, started: bool) -> None: + tracked = getattr(app, "subscribed_characteristics", None) + with self._state_lock: + 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 _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 both. + 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: + subscribed = bool(subscriptions & _ALL_CHARACTERISTICS) + with self._state_lock: + previous = self._subscriptions + was_subscribed = self._subscribed + self._subscriptions = set(subscriptions) + self._subscribed = subscribed + latest_payload = self._latest_payload + queue = self._queue + shot_started = _SHOT in subscriptions and _SHOT not in previous + + if subscribed != was_subscribed: + logger.info("[BLE] Client %s", "subscribed" if subscribed else "unsubscribed") + if was_subscribed and not subscribed: + self._clear_queue(queue) + + # Replay the latest shot when the shot characteristic gains a + # subscriber, so a phone that subscribes to control first still gets it. + if shot_started and latest_payload is not None: + self._enqueue_payload(latest_payload) + + def _enqueue_payload(self, payload: bytes) -> None: + with self._state_lock: + queue = self._queue + subscribed = self._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: + queue.get_nowait() + queue.task_done() + logger.warning("[BLE] Delivery queue full; dropped oldest unsent shot") + except asyncio.QueueEmpty: + pass + queue.put_nowait(payload) + + @staticmethod + def _clear_queue(queue: asyncio.Queue[bytes] | None) -> None: + 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: + sequence = self._sequence + self._sequence = (self._sequence + 1) & 0xFFFF + await self._notify_frames(SHOT_CHARACTERISTIC_UUID, payload, sequence) + + async def _notify_frames( + self, + characteristic_uuid: str, + payload: bytes, + sequence: int, + ) -> None: + with self._state_lock: + server = self._server + if server is None or not self.subscribed: + return + + characteristic = server.get_characteristic(characteristic_uuid) + if characteristic is None: + raise RuntimeError(f"BLE characteristic {characteristic_uuid} is unavailable") + + for frame in fragment_payload(payload, sequence=sequence): + if not self.subscribed: + return + characteristic.value = bytearray(frame) + 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 a writable GATT value.""" + if _characteristic_uuid(characteristic) != _CONTROL: + 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") not in ACCEPTED_REQUEST_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") + 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 command_type == "hello": + # Answered by the publisher itself: ``hello`` is about which + # characteristics exist, not server state. + result = build_hello_result(command_payload.get("client_schema_max")) + response = build_control_response(request_id, result=result) + else: + 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 = build_control_response(request_id, error=str(error)) + else: + response = build_control_response(request_id, result=result) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as error: + response = build_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 = build_control_response( + str(request_id or "unknown"), + error="OpenFlight could not apply the phone command", + ) + + await self._send_control_response(encode_message(response)) + + async def _send_control_response(self, payload: bytes) -> None: + """Send one complete control message without interleaving fragments.""" + # Created lazily on the publisher's event loop, where every send runs. + 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: + with self._state_lock: + server = self._server + sequence = self._control_sequence + self._control_sequence = (sequence + 1) & 0xFFFF + if server is None or not self.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/server.py b/src/openflight/server.py index 96fe07ecb..2759fb573 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_power_status_event, + build_profiles_event, + build_session_cleared_event, + build_shot_deleted_event, + build_shot_processing_event, +) from .clubs import ClubType from .clubs.physics import ( SHOT_SIMULATION_DEFAULTS, @@ -157,6 +164,15 @@ 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() + shutdown_lock = threading.Lock() shutdown_cleanup_started = False # One active hardware job plus two waiting shots is enough for normal golf @@ -191,6 +207,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) @@ -333,7 +352,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): @@ -420,6 +441,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 +980,82 @@ def display(): return send_from_directory(_react_app_dir(), "index.html") +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) + 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 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. + + 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 = { + "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, + } + handler = handlers.get(command_type) + if handler is None: + return {"error": f"Unsupported phone command: {command_type}"}, 400 + return handler(payload) + + @app.route("/") def static_files(path): """Serve static files.""" @@ -1751,8 +1850,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 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: @@ -1782,6 +1893,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 +1914,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: @@ -1823,20 +1928,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") @@ -1928,16 +2054,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") @@ -1953,17 +2086,24 @@ 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()) + _publish_phone_event(build_shot_deleted_event(timestamp)) + 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") @@ -2111,6 +2251,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: @@ -2223,6 +2367,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 +2381,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 +3404,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 +3428,16 @@ def _finalize_shot_detected( context={"stage": f"emit_{emit_event}", "ball_speed_mph": shot.ball_speed_mph}, exc=e, ) - return + + # Phones get the final shot, marked final and carrying the event_id of any + # provisional shot published for it. Phone transports are independent of + # WebSocket delivery; a stalled client cannot affect recording or the UI. + if shot_data is not None: + _publish_phone_shot( + shot_data, + final=True, + enrichment=_final_phone_enrichment(emit_event, enrichment), + ) # Forward to simulator connectors (optional) _forward_shot_to_simulators(shot) @@ -3385,6 +3545,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 phone 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( + shot_data: dict, + *, + final: bool, + enrichment: dict | None, +) -> None: + """Hand one shot to the phone transports; never raises.""" + transports = [] + if ble_publisher is not None: + transports.append(("BLE", ble_publisher)) + for name, transport in transports: + try: + transport.publish_shot(shot_data, final=final, enrichment=enrichment) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to queue shot over %s", name, exc_info=True) + + +def _publish_phone_event(event: dict) -> None: + """Hand one event to the phone transports; never raises.""" + transports = [] + if ble_publisher is not None: + transports.append(("BLE", ble_publisher)) + for name, transport in transports: + try: + transport.publish_event(event) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to queue event over %s", name, exc_info=True) + + def _emit_initial_ops_shot(shot: Shot) -> bool: """Publish immediately available OPS metrics before slow enrichments.""" try: @@ -3403,6 +3608,9 @@ def _emit_initial_ops_shot(shot: Shot) -> bool: "pending": pending, }, ) + # Phones get the same provisional shot. The final one follows + # from _finalize_shot_detected with the same event_id. + _publish_phone_shot(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) @@ -3569,6 +3777,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( @@ -3582,6 +3791,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"), ) @@ -4377,6 +4587,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 +5191,16 @@ 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/ble_harness.py b/tests/ble_harness.py new file mode 100644 index 000000000..d657b7db6 --- /dev/null +++ b/tests/ble_harness.py @@ -0,0 +1,387 @@ +"""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, + SCHEMA_VERSION, + SHOT_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 = SCHEMA_VERSION, + 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 + + 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, "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, + ) + with loopback: + monkeypatch.setattr(server_module, "ble_publisher", loopback.publisher) + loopback.emitted = emitted + yield loopback + + +PHONE_UUIDS = (SHOT_CHARACTERISTIC_UUID, CONTROL_CHARACTERISTIC_UUID) 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/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_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_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..26298b2c1 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_response_hello.json @@ -0,0 +1,53 @@ +{ + "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", + "shot_deleted", + "club" + ], + "characteristics": { + "shot": "ED365FE6-3ABF-4FC3-8E44-D9525A22DABD", + "control": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474" + } + } + }, + "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636861726163746572697374696373223a7b22636f6e74726f6c223a2237424139364536332d313243322d344345302d424238342d333531334337464431343734222c2273686f74223a2245443336354645362d334142462d344643332d384534342d443935323541323244414244227d2c226665617475726573223a5b2270726f766973696f6e616c5f73686f7473222c2273686f745f70726f63657373696e67222c2270726f66696c6573222c22706f7765725f737461747573222c2273686f745f64656c65746564222c22636c7562225d2c22736368656d615f76657273696f6e223a327d2c22736368656d615f76657273696f6e223a327d", + "frames_hex": [ + "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/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/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_goldens.py b/tests/test_ble_goldens.py new file mode 100644 index 000000000..5a45b1381 --- /dev/null +++ b/tests/test_ble_goldens.py @@ -0,0 +1,113 @@ +"""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 PHONE_UUIDS, server_loopback + +from openflight.ble.protocol import ( + CONTROL_CHARACTERISTIC_UUID, + FRAME_SIZE, + MAX_MESSAGE_SIZE, + SHOT_CHARACTERISTIC_UUID, + 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_goldens_are_schema_2_utf8_on_the_phone_characteristics(): + for golden in GENERATOR.build_goldens().values(): + assert golden["schema_version"] == 2 + assert golden["characteristic"] in (SHOT_CHARACTERISTIC_UUID, CONTROL_CHARACTERISTIC_UUID) + 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(*PHONE_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..653196aa4 --- /dev/null +++ b/tests/test_ble_loopback.py @@ -0,0 +1,327 @@ +"""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. +""" + +import json +import threading +from datetime import datetime + +import pytest +from ble_harness import PHONE_UUIDS, server_loopback, settle + +from openflight import server as server_module +from openflight.ble.protocol import ( + CONTROL_CHARACTERISTIC_UUID, + SHOT_CHARACTERISTIC_UUID, + build_club_event, + encode_message, +) +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_central_gets_latest_shot_replayed_on_subscribe(pi): + pi.publisher.publish_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("app") + # Control first, as the negotiation flow does, then the shot characteristic. + phone.subscribe(CONTROL_CHARACTERISTIC_UUID) + settle(pi) + phone.subscribe(SHOT_CHARACTERISTIC_UUID) + + shot = phone.wait_for(SHOT_CHARACTERISTIC_UUID) + + assert shot["schema_version"] == 2 + assert shot["shot_number"] == 3 + assert shot["final"] is True + assert phone.raw(SHOT_CHARACTERISTIC_UUID)[0] == pi.publisher._latest_payload + settle(pi) + assert len(phone.decoded(SHOT_CHARACTERISTIC_UUID)) == 1 + + +def test_hello_answers_in_either_request_envelope(pi): + phone = pi.central("app") + phone.subscribe(*PHONE_UUIDS) + + answer = phone.request("hello", {"client_schema_max": 2}) + v1_envelope = phone.request("hello", {"client_schema_max": 2}, schema_version=1) + + assert answer["schema_version"] == 2 + assert answer["ok"] is True + assert answer["result"]["schema_version"] == 2 + assert answer["result"]["characteristics"] == { + "shot": SHOT_CHARACTERISTIC_UUID, + "control": CONTROL_CHARACTERISTIC_UUID, + } + assert "profiles" in answer["result"]["features"] + assert v1_envelope["schema_version"] == 2 + assert v1_envelope["result"] == answer["result"] + + +def test_hello_rejects_clients_that_cannot_speak_schema_2(pi): + phone = pi.central("app") + phone.subscribe(*PHONE_UUIDS) + + too_old = phone.request("hello", {"client_schema_max": 1}) + invalid = phone.request("hello", {"client_schema_max": "two"}) + + assert too_old["ok"] is False + assert "at least 2" in too_old["error"] + assert invalid["ok"] is False + assert "client_schema_max" in invalid["error"] + + +def test_provisional_and_final_shot_share_event_id_on_every_phone(pi, monkeypatch): + _slow_enrichment(monkeypatch) + first_phone = pi.central("first") + second_phone = pi.central("second") + first_phone.subscribe(*PHONE_UUIDS) + second_phone.subscribe(*PHONE_UUIDS) + + server_module.on_shot_detected(_hardware_shot()) + _wait_idle() + + for phone in (first_phone, second_phone): + provisional, final = phone.wait_for_count(SHOT_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 + + +def test_club_command_and_club_changed_reach_every_phone(pi): + first_phone = pi.central("first") + second_phone = pi.central("second") + first_phone.subscribe(*PHONE_UUIDS) + second_phone.subscribe(*PHONE_UUIDS) + + answer = first_phone.request("set_club", {"club": "7-iron"}) + assert answer == { + "schema_version": 2, + "request_id": answer["request_id"], + "ok": True, + "result": {"status": "applied", "club": "7-iron"}, + } + + for phone in (first_phone, second_phone): + event = phone.wait_for( + CONTROL_CHARACTERISTIC_UUID, lambda message: message.get("type") == "club_changed" + ) + assert event == {"schema_version": 2, "type": "club_changed", "club": "7-iron"} + raw_events = [ + raw for raw in phone.raw(CONTROL_CHARACTERISTIC_UUID) if b"club_changed" in raw + ] + assert raw_events == [encode_message(build_club_event("7-iron"))] + assert ("club_changed", {"club": "7-iron"}) in pi.emitted + + current = second_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("app") + phone.subscribe(*PHONE_UUIDS) + + ack = phone.request("get_profiles") + assert ack["result"] == {"status": "sent"} + profiles = phone.wait_for( + CONTROL_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}) + 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"}) + assert rejected["ok"] is False + assert rejected["error"] == "Unknown profile" + + +def test_unknown_commands_and_envelopes_are_rejected(pi): + phone = pi.central("app") + phone.subscribe(*PHONE_UUIDS) + + unknown = phone.request("launch_rocket") + future_envelope = phone.request("get_club", schema_version=3) + + assert unknown == { + "schema_version": 2, + "request_id": unknown["request_id"], + "ok": False, + "error": "Unsupported phone command: launch_rocket", + } + assert future_envelope["error"] == "Unsupported control schema version" + + +def test_session_and_power_commands(pi, monkeypatch): + class _Power: + status = None + + phone = pi.central("app") + phone.subscribe(*PHONE_UUIDS) + + disabled = phone.request("get_power_status") + assert disabled["error"] == "Battery monitoring is not enabled" + monkeypatch.setattr(server_module, "power_monitor", _Power()) + waiting = phone.request("get_power_status") + 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("app") + phone.subscribe(*PHONE_UUIDS) + + for command, payload in ( + ("clear_session", {}), + ("delete_shot", {"timestamp": "2026-09-25T12:00:00"}), + ): + for envelope in (1, 2): + answer = phone.request(command, payload, schema_version=envelope) + assert answer["ok"] is False + assert answer["error"] == f"Unsupported phone command: {command}" + assert pi.emitted == [] + + +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), + club=ClubType.DRIVER, + ) + monitor = server_module.MockLaunchMonitor() + monitor._shots.append(shot) + monkeypatch.setattr(server_module, "monitor", monitor) + phone = pi.central("app") + phone.subscribe(*PHONE_UUIDS) + active_id = server_module.get_profile_store().get_active().id + + server_module.handle_delete_shot({"timestamp": shot.timestamp.isoformat()}) + server_module.handle_clear_session({}) + + deleted = phone.wait_for( + CONTROL_CHARACTERISTIC_UUID, lambda message: message.get("type") == "shot_deleted" + ) + cleared = phone.wait_for( + CONTROL_CHARACTERISTIC_UUID, + lambda message: message.get("type") == "session_cleared", + ) + 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_one_phone_leaving_keeps_the_other_receiving(pi): + staying = pi.central("staying") + leaving = pi.central("leaving") + staying.subscribe(*PHONE_UUIDS) + leaving.subscribe(*PHONE_UUIDS) + assert pi.publisher.subscribed + + leaving.disconnect() + settle(pi) + assert pi.publisher.subscribed + + server_module.apply_club_selection({"club": "pw"}) + event = staying.wait_for( + CONTROL_CHARACTERISTIC_UUID, lambda message: message.get("type") == "club_changed" + ) + assert event["club"] == "pw" + + staying.disconnect() + settle(pi) + assert not pi.publisher.subscribed + + +def test_client_frames_for_two_commands_do_not_interleave_responses(pi): + phone = pi.central("app") + phone.subscribe(*PHONE_UUIDS) + first = phone.command("get_club") + second = phone.command("hello", {"client_schema_max": 2}) + + answers = { + message["request_id"]: message + for message in ( + phone.wait_for(CONTROL_CHARACTERISTIC_UUID, lambda m: m.get("request_id") == first), + phone.wait_for(CONTROL_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_CHARACTERISTIC_UUID): + json.loads(raw) diff --git a/tests/test_ble_protocol.py b/tests/test_ble_protocol.py new file mode 100644 index 000000000..67a062aa8 --- /dev/null +++ b/tests/test_ble_protocol.py @@ -0,0 +1,427 @@ +"""Tests for the OpenFlight phone protocol: payloads, negotiation, size budget and framing.""" + +import json +from pathlib import Path + +import pytest + +from openflight.ble.protocol import ( + ACCEPTED_REQUEST_SCHEMAS, + CONTROL_CHARACTERISTIC_UUID, + FEATURES, + FRAGMENT_PAYLOAD_SIZE, + FRAME_SIZE, + MAX_MESSAGE_SIZE, + SCHEMA_VERSION, + SERVICE_UUID, + SHOT_CHARACTERISTIC_UUID, + FragmentReassembler, + build_event, + build_hello_result, + build_power_status_event, + build_profiles_event, + build_shot_deleted_event, + build_shot_event, + encode_message, + encode_shot_event, + fragment_payload, + parse_fragment, + reassemble_fragments, + stable_shot_event_id, +) +from openflight.profiles import MAX_NAME_LENGTH, MAX_PROFILES + +FIXTURES = Path(__file__).parent / "fixtures" +SHOT_FIXTURE = FIXTURES / "shot_v2.json" + +SHOT_KEYS = { + "schema_version", + "type", + "event_id", + "timestamp", + "club", + "ball_speed_mph", + "estimated_carry_yards", + "final", + "club_speed_mph", + "smash_factor", + "launch_angle_vertical", + "launch_angle_horizontal", + "spin_rpm", + "club_path_deg", + "spin_axis_deg", + "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_service_and_characteristics_are_distinct(): + assert len({SERVICE_UUID, SHOT_CHARACTERISTIC_UUID, CONTROL_CHARACTERISTIC_UUID}) == 3 + + +def test_shot_carries_every_documented_field_and_nothing_else(): + event = build_shot_event(_shot_data(), final=True) + + assert set(event) == SHOT_KEYS + assert event["schema_version"] == 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_missing_measurements_are_explicit_nulls(): + data = _shot_data() + for key in ( + "club_speed_mph", + "smash_factor", + "launch_angle_vertical", + "launch_angle_horizontal", + "spin_rpm", + "club_path_deg", + "spin_axis_deg", + ): + data.pop(key) + + event = build_shot_event(data, final=True) + + for key in ("club_speed_mph", "launch_angle_vertical", "spin_rpm", "spin_axis_deg"): + assert event[key] is None + + +def test_missing_session_fields_are_null_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(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(_shot_data(), final=False, enrichment={"status": "pending"}) + final = build_shot_event( + _shot_data(ball_speed_mph=107.0), final=True, enrichment={"status": "complete"} + ) + other_shot = build_shot_event(_shot_data(shot_number=8), final=True) + other_session = build_shot_event(_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_explicit_event_id_wins(): + assert build_shot_event(_shot_data(), final=True, event_id="given")["event_id"] == "given" + + +def test_enrichment_status_is_validated(): + with pytest.raises(ValueError): + build_shot_event(_shot_data(), final=True, enrichment={"status": "done"}) + + skipped = build_shot_event( + _shot_data(), final=True, enrichment={"status": "skipped", "reason": "deadline"} + ) + assert skipped["enrichment"] == {"status": "skipped", "reason": "deadline"} + + +def test_encoding_is_compact_sorted_utf8_and_deterministic(): + payload = encode_shot_event(_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) + assert payload == encode_shot_event(_shot_data(), final=True) + + +def test_non_finite_numbers_are_rejected(): + with pytest.raises(ValueError, match="Out of range float values"): + encode_shot_event(_shot_data(ball_speed_mph=float("nan")), final=True) + + +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_events_cannot_override_envelope_fields(): + with pytest.raises(ValueError): + build_event("profiles", {"type": "shot"}) + with pytest.raises(ValueError): + build_event("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", + } + + +# -- hello ------------------------------------------------------------------------------ + + +@pytest.mark.parametrize("client_max", [2, 9]) +def test_hello_answers_schema_features_and_characteristics(client_max): + assert build_hello_result(client_max) == { + "schema_version": 2, + "features": list(FEATURES), + "characteristics": { + "shot": SHOT_CHARACTERISTIC_UUID, + "control": CONTROL_CHARACTERISTIC_UUID, + }, + } + + +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 + + +@pytest.mark.parametrize("bad", [None, "2", 2.0, True, 0, -1, 1]) +def test_hello_rejects_clients_that_cannot_speak_schema_2(bad): + with pytest.raises(ValueError): + build_hello_result(bad) + + +def test_requests_may_use_a_version_one_envelope(): + assert ACCEPTED_REQUEST_SCHEMAS == (1, 2) + + +# -- size budget: every 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_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( + 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(build_profiles_event(snapshot)) + + assert len(payload) <= MAX_MESSAGE_SIZE + fragment_payload(payload, sequence=0) + + +# -- contract fixture ------------------------------------------------------------------- + + +def test_shot_fixture_is_a_final_shot(): + fixture = json.loads(SHOT_FIXTURE.read_text(encoding="utf-8")) + + assert set(fixture) == SHOT_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_fixture_round_trips_through_the_encoder(): + fixture = json.loads(SHOT_FIXTURE.read_text(encoding="utf-8")) + + rebuilt = build_shot_event(fixture, final=fixture["final"], enrichment=fixture["enrichment"]) + + assert rebuilt == fixture + + +# -- framing ---------------------------------------------------------------------------- + + +@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_incremental_reassembler_completes_control_payload(): + payload = json.dumps( + { + "schema_version": 2, + "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..196fc3198 --- /dev/null +++ b/tests/test_ble_publisher.py @@ -0,0 +1,380 @@ +"""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(_shot_data(140.0), final=True) + assert publisher.publish_shot(_shot_data(151.0), final=True) + + 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_shot({}, final=True) 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":2,"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(_shot_data(), final=True) + 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(_shot_data(151.0), final=True) + 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(): + """A version-one request envelope is still accepted; the response is schema 2.""" + + 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": 2, + "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": 2, + "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": 2, + "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": 2, + "type": "club_changed", + "club": "3-wood", + } + + asyncio.run(run()) + + +def test_writes_to_the_shot_characteristic_are_ignored(): + received = [] + publisher = BleShotPublisher(command_handler=lambda *args: received.append(args) or ({}, 200)) + publisher._loop = object() # pylint: disable=protected-access + + class _ShotCharacteristic: + uuid = SHOT_CHARACTERISTIC_UUID + value = bytearray() + + frame = fragment_payload(b'{"schema_version":2}', sequence=1)[0] + publisher._on_write_request(_ShotCharacteristic(), bytearray(frame)) # pylint: disable=protected-access + + assert received == [] + assert _ShotCharacteristic.value == bytearray() 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 diff --git a/tests/test_control_commands.py b/tests/test_control_commands.py new file mode 100644 index 000000000..79c38531a --- /dev/null +++ b/tests/test_control_commands.py @@ -0,0 +1,87 @@ +"""Tests for transport-independent phone control commands.""" + +import pytest + +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 + + +@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 and BLE, so every test gets its own. + """ + monkeypatch.setattr(server_module, "active_club", ClubType.DRIVER) + 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() + ble = _ClubPublisher() + emitted = [] + monkeypatch.setattr(server_module, "monitor", monitor) + 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 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_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_transport.py b/tests/test_phone_transport.py new file mode 100644 index 000000000..d083e262a --- /dev/null +++ b/tests/test_phone_transport.py @@ -0,0 +1,246 @@ +"""Server-level phone behaviour: event fan-out and command routing.""" + +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 + + +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 + + +class _PhoneTransport: + """Records what the server hands to the phone ble.""" + + def __init__(self): + self.shots = [] + self.events = [] + self.clubs = [] + + def publish_shot(self, shot_data, *, final, enrichment=None): + self.shots.append((shot_data, final, enrichment)) + return True + + def publish_event(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 and BLE output of a monitor-less server.""" + ble = _PhoneTransport() + emitted = [] + 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 ble, emitted + + +# -- event fan-out --------------------------------------------------------------------- + + +def test_shot_processing_power_and_profiles_reach_phones(phones): + 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() + + types = [event["type"] for event in ble.events] + assert types == ["shot_processing", "power_status", "profiles"] + assert ble.events[0]["state"] == "calculating" + assert ble.events[1] == { + "schema_version": 2, + "type": "power_status", + **status.to_dict(), + } + assert [event for event, _ in emitted] == ["shot_processing", "power_status", "profiles"] + + +def test_club_changes_reach_every_transport(phones): + ble, emitted = phones + + server_module.apply_club_selection({"club": "pw"}) + + assert ble.clubs == ["pw"] + assert ("club_changed", {"club": "pw"}) in emitted + + +# -- command routing ------------------------------------------------------------------- + + +def test_dispatch_uses_the_socketio_operations(phones): + 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( + "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 ble.events[-1]["active_profile_id"] == first.id + assert second.id in {item["id"] for item in ble.events[-1]["profiles"]} + + +def test_socket_clear_session_also_notifies_phones(phones): + ble, emitted = phones + active = server_module.get_profile_store().get_active().id + + server_module.handle_clear_session({}) + + assert emitted == [("session_cleared", {"profile_id": active, "shots": []})] + assert ble.events == [{"schema_version": 2, "type": "session_cleared", "profile_id": active}] + + +def test_socket_delete_shot_notifies_phones_only_on_success(phones, monkeypatch): + 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) + + server_module.handle_delete_shot({"timestamp": "2026-01-01T00:00:00"}) + assert ble.events == [] + server_module.handle_delete_shot({"timestamp": shot.timestamp.isoformat()}) + + assert [event for event, _ in emitted] == ["delete_shot_error", "session_state"] + assert monitor.get_shots() == [] + assert ble.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): + _ble, emitted = phones + + response, status = server_module.dispatch_phone_control_command( + command, {"timestamp": "2026-09-25T12:00:00"} + ) + + assert status == 400 + assert response["error"] == f"Unsupported phone command: {command}" + assert emitted == [] + + +def test_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("get_power_status", {}) == ( + status.to_dict(), + 200, + ) + + +# -- shot publication ---------------------------------------------------------------------- + + +def test_fast_path_publishes_one_final_shot_without_enrichment(phones, monkeypatch): + 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, + ) + + [(shot_data, final, enrichment)] = ble.shots + assert final is True + assert enrichment is None + assert shot_data["ball_speed_mph"] == 150.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 diff --git a/tests/test_phone_transport_server.py b/tests/test_phone_transport_server.py new file mode 100644 index 000000000..763d911f5 --- /dev/null +++ b/tests/test_phone_transport_server.py @@ -0,0 +1,73 @@ +"""Server-level behaviour of the phone transports (Socket.IO 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.""" + ble = _ClubPublisher() + emitted = [] + monkeypatch.setattr(server_module, "monitor", None) + 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 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.""" + 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 ble.clubs == ["7-iron"] + + +@pytest.mark.parametrize("payload", [{"club": "putter"}, {"club": "unknown"}, None]) +def test_socket_set_club_ignores_invalid_selection(no_monitor_transports, payload): + ble, emitted = no_monitor_transports + + server_module.handle_set_club(payload) + + assert server_module.active_club is ClubType.DRIVER + assert emitted == [] + 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_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_server.py b/tests/test_server.py index 4772ae051..8e54c1020 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4038,6 +4038,37 @@ 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_shot=lambda shot_data, **_kwargs: published.append(shot_data) + ) + + 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_implausible_club_aoa_is_rejected(self, monkeypatch): """A +31° club AoA is physically impossible and should be discarded.""" diff --git a/tests/test_start_kiosk.py b/tests/test_start_kiosk.py index 740796105..31eae0c34 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() @@ -246,7 +263,9 @@ def test_kiosk_shell_scripts_use_unix_newlines(): "scripts/require-node.sh", ): data = (REPO_ROOT / relative).read_bytes() - assert b"\r" not in data, f"{relative} must use LF newlines so sourced path checks match on the Pi" + assert b"\r" not in data, ( + f"{relative} must use LF newlines so sourced path checks match on the Pi" + ) def test_ui_is_ensured_before_the_kiosk_browser_launches(): @@ -346,7 +365,12 @@ def _run_ensure_kiosk_ui( scripts_dir = tmp_path / "scripts" scripts_dir.mkdir() for name in ("ensure-kiosk-ui.sh", "require-node.sh"): - text = (repo_scripts / name).read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n") + text = ( + (repo_scripts / name) + .read_text(encoding="utf-8") + .replace("\r\n", "\n") + .replace("\r", "\n") + ) (scripts_dir / name).write_bytes(text.encode("utf-8")) project_dir = tmp_path / "project" ui_dir = project_dir / "ui" 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" = [