diff --git a/docs/changelog.md b/docs/changelog.md index b29cdd7fe..eed85bd9d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -22,6 +22,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [Electron Kiosk Shell](electron-kiosk-shell.md#browser-local-state-breaking-on-first-electron-launch). ### Fixed +- **iPhones no longer get a pairing prompt every 30 s over BLE.** BlueZ's own + GATT client read the phone's GATT database, iOS answered "Insufficient + Authentication", and BlueZ requested pairing that no agent on the Pi could + confirm, so the link dropped after the 30 s SMP timeout and looped. New + `scripts/setup/configure_bluetooth.sh` (offered by `setup.sh`) sets + `Client = false` under `[GATT]` in `/etc/bluetooth/main.conf`, with a + backup and a bluetooth restart. See + [Troubleshooting](ios-ble.md#troubleshooting). - **A crash-looping boot service no longer kills the desktop kiosk.** Every launcher exit ran a `pkill` that matched the Electron binary path, so an `openflight.service` that failed at startup (for example because systemd's @@ -59,6 +67,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 when Node is older than 22.12 or `npm install` fails and `ui/dist` already exists). Installing Electron needs **Node.js 22.12 or newer**. See [Electron Kiosk Shell](electron-kiosk-shell.md). +- **Phone transports: Bluetooth LE, a network shot stream and a club API.** + Ported from [jake-fishtech](https://github.com/jake-fishtech)'s `feat/iOS-ble` + branch. `--ble` (with the optional `ble` extra, `bless==0.3.0` on Linux) + advertises a GATT service that notifies each final shot and accepts versioned + phone commands (`set_club`, `get_club`, IWR6843 orientation). Without BlueZ or + the extra, `--ble` logs that Bluetooth is unavailable and the server carries + on. `GET /api/shots/stream` sends the same final shots and `club_changed` as + Server-Sent Events, replaying the latest shot on connect and sending a + keep-alive `: ping`. `GET`/`POST /api/club` reads or sets the Pi-owned club, + and every club change (kiosk, phone, simulator) is broadcast over Socket.IO, + SSE and BLE. `POST /api/calibration/iwr6843/orientation` applies a + gravity-referenced phone measurement as the IWR6843 mount tilt and persists it + to `~/.config/openflight/iwr6843_phone_orientation.json`; an explicit + `--iwr6843-tilt-deg` still wins. `start-kiosk.sh --ble` syncs the `ble` extra + and `setup.sh` installs it. Wire format: [iOS BLE](ios-ble.md). Behaviour + changes: Socket.IO `set_club` now ignores `unknown` and a missing club (it used + to fall back to driver), and a failed Socket.IO shot emit no longer stops BLE, + SSE and simulator delivery. +- **BLE and network schema v2 for phone apps.** Version-one traffic is unchanged + byte for byte, so jake-fishtech's iOS app keeps working. v2 lives on a second + shot/control characteristic pair in the same GATT service (BlueZ cannot notify + one central but not another on a shared characteristic; see the design note in + [phone app connection](ios-ble.md#schema-v2-design-decision)). A `hello` + command negotiates it on either control characteristic. v2 shots add + `shot_number`, profile, `carry_range`, `spin_source`, + `launch_angle_confidence`, `final` and `enrichment`, and hardware-enriched + shots now arrive twice, provisional then final, with one `event_id`. v2 phones + also get `shot_processing`, `profiles`, `power_status`, `session_cleared`, + `shot_deleted` and `club_changed` events and can `get_profiles`, + `set_active_profile` and `get_power_status` through the same server functions + Socket.IO uses. Because BLE is unauthenticated, v2 over Bluetooth is + read-and-select only: clearing sessions, deleting shots and editing profiles + stay on the network (Socket.IO/HTTP). `GET /api/shots/stream?schema=2` opts + SSE clients into the same events. BLE delivery now follows per-characteristic + subscriptions, so the latest shot is replayed when a shot characteristic is + subscribed and one phone unsubscribing no longer pauses the others. Tests run + the real publisher against a loopback fake of Bless/BlueZ, and + `tests/fixtures/ble_goldens/` holds framed hex goldens for client test suites + (`scripts/ble/generate_goldens.py`). +- **Phones catch up on missed shots after a reconnect (schema v2).** A phone + that left the app or dropped the link used to get only the latest shot back. + Now `hello` accepts `last_event_id` over BLE, and the v2 network stream honours + `Last-Event-ID` (or `?last_event_id=`); both resend that shot and every + current-session shot after it, up to 20, with the whole session when no shot + is named. Cleared and deleted shots are never replayed, replays carry the + bytes last sent live, and v2 network `shot` frames now include `id:`. `hello` + advertises the `shot_catch_up` feature; the `hello` goldens changed only by + that entry. Apps that skip `last_event_id` receive the whole session and + upsert it by `event_id`. See + [catch-up](ios-ble.md#catch-up-after-a-reconnect). +- **Mock mode simulates the optional hardware phones react to.** So a Pi + with no radar, UPS or camera can exercise every phone event: mock shots + now report `shot_processing` `capturing` then `calculating` like the radar, + `simulate_shot` with `{"fail": true}` reports `failed` without a shot, + `--mock-enrichment-ms MS` sends mock shots provisional then final through the + real enrichment pipeline (above the 20 s deadline they finalize as skipped), + and `--battery mock` cycles `power_status` through every state. See + [simulating hardware](ios-ble.md#simulating-hardware-on-a-pi-without-it). - **PAR-TEE connector.** `"type": "partee"` in `config/sim.json` streams shots to the [PAR-TEE](https://playpartee.com) iPhone app over OpenConnect V1 on the phone's Wi-Fi address (port 921 by default). Same shared codec as GSPro and diff --git a/docs/ios-ble.md b/docs/ios-ble.md new file mode 100644 index 000000000..37bc9b0b7 --- /dev/null +++ b/docs/ios-ble.md @@ -0,0 +1,727 @@ +# Phone app connection (Bluetooth LE and network) + +> **BLE blocker — check the Raspberry Pi kernel first:** Raspberry Pi kernel +> `6.18.34+rpt-rpi-2712` has a confirmed regression that rejects every BLE +> advertisement. Run `uname -r` on the Pi. If it reports that version, use the +> network transport or boot a working kernel such as 6.12.x; there is no userspace +> workaround. See the [full diagnosis](#known-bad-raspberry-pi-kernel-61834rpt-rpi-2712). + +OpenFlight sends each completed shot from a Raspberry Pi to a phone app over one +of two local transports. Both carry the identical versioned payload described +below, so an app behaves the same either way. Two apps speak this protocol: + +- jake-fishtech's SwiftUI app, on the + [`feat/iOS-ble` branch of his fork](https://github.com/jake-fishtech/openflight/tree/feat/iOS-ble/ios) + (schema version 1). +- The Kotlin Multiplatform companion for Android and iOS, + [`btripp/openflight-mobile-kmp`](https://github.com/btripp/openflight-mobile-kmp) + (schema version 1, and [schema v2](#schema-v2) where the Pi offers it). + +| Transport | Pi setup | Use it when | +|---|---|---| +| **Bluetooth** | start with `--ble` | The phone cannot reach the Pi over a network | +| **Network** | always on | The phone can reach the Pi over IP (Wi-Fi, Ethernet or any other link), or Bluetooth advertising is unavailable | + +The network transport needs no flag: it streams from the same HTTP server that serves the +browser UI, and exposes nothing the browser UI does not already broadcast. + +## Requirements + +- 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. + +## Network transport + +The server streams shots as [Server-Sent Events](https://developer.mozilla.org/docs/Web/API/Server-sent_events) +at `/api/shots/stream`. Check it from any machine on the network before +involving a phone: + +```bash +curl -N http://raspberrypi.local:8080/api/shots/stream +``` + +A connection opens with a `: ping` comment, replays the most recent shot if +there is one, then emits one `event: shot` message per shot with a heartbeat +every 15 seconds while idle: + +```text +: ping + +event: shot +data: {"ball_speed_mph":151.4,"club":"driver",...,"schema_version":1} +``` + +In the app, pick **Wi-Fi** (the app's label; it works over any IP network, so +the Pi can be on Ethernet) and enter the Pi's address. `raspberrypi.local:8080` +is the default and works on a stock Raspberry Pi OS install, which publishes its +hostname over mDNS; if you renamed the Pi, use `.local:8080` or its IP. +The port defaults to 8080 when you leave it off. The app reconnects on its own +with backoff, and iOS asks once for permission to talk to devices on the local +network. + +The server accepts up to eight simultaneous stream clients and answers `503` +beyond that, so a forgotten `curl` cannot crowd out a phone. + +## Build and run the iOS app + +The SwiftUI app is not part of this repository. For complete Xcode, signing, +physical-device, simulator, testing, and troubleshooting instructions, see +[`ios/README.md` in jake-fishtech's fork](https://github.com/jake-fishtech/openflight/blob/feat/iOS-ble/ios/README.md). + +1. In a checkout of that branch, open `ios/OpenFlight.xcodeproj` in Xcode. +2. Select the `OpenFlight` target, choose your development team, and use a + unique bundle identifier if Xcode requests one. +3. Connect an iPhone, select it as the run destination, and press Run. +4. Accept the Bluetooth permission prompt. +5. Start OpenFlight on the Pi, adding `--ble` if you want the Bluetooth + transport. + +Over Bluetooth the app scans only for the OpenFlight service, connects +automatically, and subscribes to shot and control notifications. Over the network it +opens the shot stream and keeps it open. Either way, hit a shot and its metrics +should replace the empty dashboard. The most recent shot is replayed when a +phone connects, so a newly connected phone does not have to wait for another +shot. + +## Select the club from the iPhone + +Use **Club for next shot** on the dashboard to select any supported wood, +hybrid, iron, or wedge. OpenFlight applies the club to subsequent shots and +confirms the change before the app updates its saved selection. The app sends +the change over the currently selected transport: + +- Bluetooth uses the framed control characteristic described below. +- The network transport sends `POST /api/club` with `{"club":"7-iron"}`. + +The browser UI and simulator integrations use the same server operation, so a +phone club change affects the same launch, spin, and carry processing state. + +> The iOS Simulator can run the automated tests, but CoreBluetooth does not +> provide a useful end-to-end BLE hardware test there. Use a physical iPhone +> and Raspberry Pi for manual connection testing. + +## Calibrate TI radar tilt with the iPhone + +The dashboard's **Calibrate TI Radar** button opens a guided mount-angle tool. +It sends the measurement over whichever transport is selected on the dashboard. + +1. Start OpenFlight with the IWR6843 enabled. Add `--ble` for Bluetooth, or + make sure the phone can reach the Pi over the network. +2. Remove the phone case. Hold the phone upright in portrait with its back flat + against a straight reference surface parallel to the TI antenna face. Keep + the screen facing the target and avoid resting on the camera bump. +3. Keep the radar and phone still while the app averages 120 gravity samples + over about two seconds. +4. Confirm left/right roll is within 3 degrees, then tap **Apply Calibration**. + +While the phone is moving, the angle cards are labeled **Live sensor reading** +and show the latest Core Motion gravity angles without the two-second averaging +lag. Once the sample window passes the stability and roll checks, the cards turn +green and switch to the **Stable 2-second average** that will actually be sent +to OpenFlight. + +The app sends the averaged gravity vector, calculated mount tilt and roll, +sample count, and stability statistics through the BLE control characteristic +or `POST /api/calibration/iwr6843/orientation`. Both paths call the same server +operation. The Pi independently recomputes the angles from gravity and rejects +inconsistent or unstable measurements. If the optional enclosure LIS3DH is +active, OpenFlight subtracts its current calibrated enclosure pitch so the +saved value remains the TI antenna's angle relative to the enclosure. Otherwise, +the measured phone tilt is used directly. + +The applied value takes effect immediately and is saved at +`~/.config/openflight/iwr6843_phone_orientation.json`. It is restored on startup +unless an explicit `--iwr6843-tilt-deg` value is supplied, which always wins. +Session logs record the change with source `ios_companion`. + +This process measures pitch and verifies roll. It deliberately does not change +`--iwr6843-azimuth-offset-deg`: an accelerometer cannot establish yaw relative +to the target line, and phone compass readings near radar electronics are not a +precision substitute for target-line alignment. + +## Wire protocol + +Both transports carry the same JSON event. Only the framing differs: the network transport sends +it whole in one SSE `data:` line, while BLE splits it across notifications. + +Over BLE, OpenFlight advertises one service with a shot notification and a +bidirectional control characteristic: + +| Attribute | UUID | +|---|---| +| Shot service | `B6F633F2-E6E3-45AE-84B4-968ECCA2D9C7` | +| Shot notification | `2B28F67E-9011-41D2-98ED-562B47D7A5E4` | +| Control write + notification | `7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11` | + +Each event is compact UTF-8 JSON with `schema_version: 1`. Optional +measurements are present as `null` when the active hardware could not produce +them. The version-one fields are: + +```text +schema_version, event_id, timestamp, club, ball_speed_mph, +club_speed_mph, smash_factor, estimated_carry_yards, +launch_angle_vertical, launch_angle_horizontal, spin_rpm, +club_path_deg, spin_axis_deg +``` + +Over BLE the JSON is split into conservative 20-byte notifications. Every notification +has a five-byte, big-endian header followed by up to 15 payload bytes: + +| Byte(s) | Meaning | +|---|---| +| 0 | Frame version (`1`) | +| 1–2 | Unsigned 16-bit message sequence | +| 3 | Zero-based fragment index | +| 4 | Total fragment count | +| 5–19 | JSON payload fragment | + +Consumers should group frames by sequence, ignore duplicate fragment indexes, +order fragments by index, and decode only after all fragments arrive. The +shared contract fixture is `tests/fixtures/shot_v1.json` in this repository; +the Python tests and the app tests decode that file. Framed byte-level goldens +for every message type live in `tests/fixtures/ble_goldens/` (see +[Testing without hardware](#testing-without-hardware)). + +Control writes and responses use the same framing. A command contains +`schema_version`, a unique `request_id`, a `type`, and a JSON `payload`. The Pi +notifies a response with the matching `request_id`, `ok`, and either `result` or +`error`: + +```json +{"payload":{"club":"7-iron"},"request_id":"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","schema_version":1,"type":"set_club"} +{"ok":true,"request_id":"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","result":{"club":"7-iron","status":"applied"},"schema_version":1} +``` + +Responses and unsolicited events share the control characteristic, so match +responses by `request_id` and treat messages with a `type` as events. Version +one supports these commands: + +| Command | Payload | Result | +|---|---|---| +| `set_club` | `{"club":"7-iron"}` | `{"status":"applied","club":"7-iron"}` | +| `get_club` | `{}` | `{"status":"current","club":"7-iron"}`: the Pi-owned club, unchanged | +| `iwr6843_orientation_calibration` | phone gravity measurement | `status, persistent, measured_mount_tilt_deg, enclosure_pitch_deg, configured_iwr_tilt_deg, roll_deg, azimuth_offset_deg` | +| `hello` | `{"client_schema_max":2}` | negotiation, see [schema v2](#schema-v2) | + +Every club change, from any client, is notified on the control characteristic +as `{"club":"7-iron","schema_version":1,"type":"club_changed"}`. Unknown +commands fail with `"error":"Unsupported phone command: "`. + +## Schema v2 + +Schema v2 adds what the browser UI already gets over Socket.IO: profiles, shot +numbering, the shot-processing state, battery status, and provisional shots +that are replaced by their final version. Version one stays exactly as it was, +byte for byte, so the version-one iOS app (whose decoder rejects any +`schema_version` other than `1`) keeps working next to a v2 phone. + +### Schema v2 design decision + +*Status: accepted for review, 2026-09-25.* + +**Question.** Can the Pi send v2 notifications only to centrals that negotiated +v2, on the existing characteristics? + +**Finding.** No, not with Bless 0.3.0 on BlueZ, and not with BlueZ's GATT D-Bus +API at all: + +- A notification is a write of the characteristic's `Value` property + (`BlessServerBlueZDBus.update_value` sets `gatt.Value`, which emits + `PropertiesChanged`). `bluetoothd` then notifies **every** central whose CCCD + is enabled on that characteristic. The D-Bus API has no per-device notify. +- Bless drops the `options` argument of `WriteValue`, which is where BlueZ puts + the writing device's object path, so the server cannot even tell which + central sent `hello`. +- `StartNotify`/`StopNotify` carry no device either. BlueZ calls them once per + characteristic (first subscriber in, last subscriber out). + +CoreBluetooth could target centrals (`updateValue:forCharacteristic:onSubscribedCentrals:`), +but Bless passes `nil` (all centrals), and macOS is not a deployment target. + +**Decision.** Schema v2 gets its own shot and control characteristics in the +same service. A v1 central never subscribes to them, so BlueZ never delivers a +v2 frame to it, and the v1 characteristics carry exactly the v1 traffic they +always did. `hello` works on both control characteristics, so a client can +negotiate before it commits to a pair, and an older Pi answers it with +`Unsupported phone command: hello`. + +**Consequences.** + +- One GATT service now has four characteristics. Discovery of the v2 pair is + itself a capability signal. +- A final shot is sent twice over the air when a v1 and a v2 phone are + connected at once (once per pair). The Pi's radio time is not the bottleneck + at golf-shot rates. +- A v2 phone that subscribes to both pairs gets both copies; clients subscribe + to one pair only. +- Per-characteristic subscription state now drives delivery. The publisher + reads Bless's `app.subscribed_characteristics` after each `StartNotify` / + `StopNotify`, so v2 frames are only pushed while a v2 central is subscribed + and a v2 phone unsubscribing no longer stops v1 delivery. + +**Rejected.** + +- *Per-central state on shared characteristics:* impossible here (above). +- *A v2 flag inside v1 messages:* the v1 decoder rejects unknown + `schema_version` values, and new fields would still reach v1 phones. +- *A second GATT service:* works, but an extra service UUID in the + advertisement costs scarce advertising bytes and buys nothing over two more + characteristics. + +### Characteristics + +| Attribute | UUID | Properties | +|---|---|---| +| Shot notification, v2 | `ED365FE6-3ABF-4FC3-8E44-D9525A22DABD` | notify | +| Control, v2 | `7BA96E63-12C2-4CE0-BB84-3513C7FD1474` | write with response, notify | + +Both live in the same service, `B6F633F2-E6E3-45AE-84B4-968ECCA2D9C7`, and use +the same 20-byte framing as version one. Sequence numbers are counted +separately per characteristic. + +**Encoding.** v2 messages are compact JSON with sorted keys, like version one, +but text is **UTF-8** instead of `\uXXXX` escapes (that is what lets twelve +40-character profile names fit in one message). A fragment boundary can split a +multi-byte character, so decode UTF-8 only after reassembling the whole +message. + +### Negotiation + +1. Discover the service. If the v2 characteristics are missing, the Pi is + version one: use the v1 pair. +2. Subscribe to the v2 control characteristic and write `hello`. Add + `last_event_id`, the `event_id` of the newest shot the app already has, to + [catch up](#catch-up-after-a-reconnect) on shots missed while disconnected; + leave it out on a first connection: + ```json + {"payload":{"client_schema_max":2,"last_event_id":"05dd37ec-49ed-596b-b1a4-953d54e4f239"},"request_id":"","schema_version":2,"type":"hello"} + ``` + The result names the negotiated schema, the features and the v2 pair: + ```json + {"ok":true,"request_id":"","result":{"characteristics":{"control":"7BA96E63-12C2-4CE0-BB84-3513C7FD1474","shot":"ED365FE6-3ABF-4FC3-8E44-D9525A22DABD"},"features":["provisional_shots","shot_processing","profiles","power_status","shot_deleted","club","shot_catch_up"],"schema_version":2},"schema_version":2} + ``` +3. Subscribe to the v2 shot characteristic. The catch-up shots arrive. A client + that skipped `hello` gets only the latest v2 shot, as before catch-up + existed. +4. Ask for state: `get_club`, `get_profiles` and, if wanted, `get_power_status`. + +`hello` also works on the v1 control characteristic, in a v1 envelope +(`"schema_version":1`); the response envelope is then v1 while the `result` is +the same. An older Pi answers `ok:false` with +`Unsupported phone command: hello`. Treat that, or no answer within 10 seconds, +as version one. `client_schema_max: 1` returns `{"schema_version":1,"features":[]}`. +The v2 control characteristic accepts envelopes with `schema_version` 1 or 2 +and always answers with `schema_version: 2`. + +### Catch-up after a reconnect + +A phone that leaves the app, walks out of range or loses the network misses the +shots taken meanwhile. Schema v2 catches it up on reconnect, with one rule for +both transports: + +| | BLE | Network | +|---|---|---| +| Name the newest shot you have | `last_event_id` in the `hello` payload | `Last-Event-ID` request header, or `?last_event_id=` (the header wins) | +| Catch-up arrives | On the v2 shot characteristic, after the `hello` response; held until the phone subscribes to it | Seeded after the state events, before live events | +| Without catch-up | A client that skips `hello` gets the latest v2 shot | — (every v2 connection gets catch-up) | + +- The Pi sends the named shot again, then every current-session shot after + it, oldest first. Resending the named shot means a phone that only had its + provisional version (it disconnected before the final arrived) ends up with + the final. +- No `last_event_id`, or one the session no longer holds (the session was + cleared, that shot was deleted, or the Pi restarted), means the whole + current session. +- At most the 20 most recent shots are sent. Use `shot_number` gaps to tell + that older shots were not synced (deleted shots also leave gaps). +- Replayed shots are the exact bytes last sent live, including `final` and + `enrichment`. A shot no longer cached is rebuilt as a final shot with + `enrichment: null`. +- Cleared and deleted shots are never replayed: the Pi's session decides what + exists. +- Upsert by `event_id` as for live shots; replays of shots the app already has + are harmless. Invalid `last_event_id` values are treated as absent and never + fail `hello`. +- Every network v2 `shot` frame carries `id: `, so an `EventSource` + resends `Last-Event-ID` on its own when it reconnects. Other events carry no + `id`, which leaves the last shot id in place. Version-one streams and the v1 + characteristics have no catch-up: their `event_id` is not stable. +- Over BLE a notification reaches every subscribed phone, so another connected + phone receives the catch-up too and upserts it. +- A shot taken while a BLE catch-up is still being sent can push the oldest + queued catch-up shot out of the eight-message delivery queue. The phone then + lacks that one shot until its session is replayed in full (for example after + it reconnects without `last_event_id`). + +The Pi advertises support with the `shot_catch_up` feature in the `hello` +result. An older Pi ignores `last_event_id` and only replays the latest shot. + +### v2 shot + +Sent on the v2 shot characteristic. It carries every version-one field, plus: + +| Field | Type | Meaning | +|---|---|---| +| `type` | `"shot"` | | +| `final` | bool | `false`: OPS-only provisional shot, sent while optional hardware (IWR6843, camera) is still working. `true`: the final shot | +| `event_id` | UUID string | Stable per shot: the provisional and final versions of one shot share it. **Upsert by `event_id`** | +| `shot_number` | int or null | Per-monitor-run sequence; not reused after a delete | +| `profile_id`, `profile_name` | string or null | Profile the shot was attributed to at detection | +| `carry_range` | `[low, high]` or null | Carry range in yards | +| `spin_source` | string or null | Where `spin_rpm` came from | +| `launch_angle_confidence` | number or null | 0–1 | +| `enrichment` | object or null | `{"status":"pending"}` on a provisional shot; `{"status":"complete"}` or `{"status":"skipped","reason":"deadline"|"capacity"|"queue_full"|"worker_unavailable"}` on a final shot that had a provisional; `null` when the shot never waited for optional hardware | + +Every key is always present; unknown values are `null`. A shot with no optional +hardware configured is sent once, final. The provisional shot is not sent at all +to v1 phones, which only ever receive final shots. The contract fixture is +`tests/fixtures/shot_v2.json`, built from a real mock shot: + +```json +{"ball_speed_mph":106.1,"carry_range":[144,160],"club":"7-iron","club_path_deg":2.5,"club_speed_mph":83.5,"enrichment":{"status":"complete"},"estimated_carry_yards":152,"event_id":"05dd37ec-49ed-596b-b1a4-953d54e4f239","final":true,"launch_angle_confidence":0.6,"launch_angle_horizontal":-0.7,"launch_angle_vertical":21.2,"profile_id":"0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b","profile_name":"Zoë","schema_version":2,"shot_number":7,"smash_factor":1.27,"spin_axis_deg":-1.6,"spin_rpm":6482,"spin_source":null,"timestamp":"2026-09-25T14:03:07.412345","type":"shot"} +``` + +### v2 events + +Notified on the v2 control characteristic. Each has `schema_version: 2` and a +`type`, and never a `request_id`: + +| `type` | Fields | When | +|---|---|---| +| `club_changed` | `club` | Any club change (kiosk, phone, simulator) | +| `profiles` | `profiles: [{id, name}]`, `active_profile_id` | After every profile request or mutation from any client, including rejected ones | +| `session_cleared` | `profile_id` | After a profile's shots are cleared (kiosk or network clients) | +| `shot_deleted` | `timestamp` (the shot's delete key) | After a shot is deleted (kiosk or network clients) | +| `shot_processing` | `state`: `capturing`, `calculating` or `failed` | Rolling-buffer monitor progress; the next shot ends it | +| `power_status` | the Socket.IO `power_status` payload: `available, provider, state, battery_percent, battery_voltage_v, external_power, updated_at, error` | Every 5 s with `--battery geekworm` | + +```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 over BLE carry only `id` and `name`. `created_at` and the open-ended +`settings` stay on Socket.IO, because the phone only selects profiles here and +an unbounded `settings` object could not be guaranteed to fit in one message. + +### v2 commands + +Each v2 command calls the same server function as its Socket.IO counterpart, so +the kiosk and every other client see the same broadcasts. + +| Command | Payload | Result | Also broadcasts | +|---|---|---|---| +| `hello` | `{"client_schema_max":2}` | see [Negotiation](#negotiation) | | +| `get_club` | `{}` | `{"status":"current","club":…}` | | +| `set_club` | `{"club":"7-iron"}` | `{"status":"applied","club":…}` | `club_changed` | +| `iwr6843_orientation_calibration` | as version one | as version one | | +| `get_profiles` | `{}` | `{"status":"sent"}` | `profiles`: the roster arrives as the event, not in the result | +| `set_active_profile` | `{"profile_id":…}` | `{"status":"applied","active_profile_id":…}`, or `ok:false` `Unknown profile` | `profiles` (also when rejected) | +| `get_power_status` | `{}` | the `power_status` payload, or `ok:false` `Battery monitoring is not enabled` / `No battery reading yet` | | + +Over BLE, v2 is read-and-select only (see [Security](#security-and-scope)). +Adding, renaming and removing profiles, `clear_session` and `delete_shot` stay +on Socket.IO and the kiosk; sent over BLE they fail with +`Unsupported phone command: ` on either control characteristic. Phones +still learn about those changes from the `profiles`, `session_cleared` and +`shot_deleted` events. An event +triggered by a command is normally notified before the command's response, but +clients must accept either order. The Pi processes commands as they arrive and +enforces no busy state or timeout of its own; clients own their timeouts. +Version-one commands keep working on the v1 control characteristic, and v2-only +commands sent there fail with `Unsupported phone command`. + +### Network: `?schema=2` + +`GET /api/shots/stream?schema=2` opts a Server-Sent Events client into schema +v2. The default (no parameter, or `schema=1`) is the unchanged version-one +stream; any other value returns `400`. A v2 stream opens with `: ping`, then +the current state as `club_changed`, `profiles` and (with a battery monitor) +`power_status`, then the [catch-up](#catch-up-after-a-reconnect) shots +(the whole session unless `Last-Event-ID` names a shot). Each `shot` frame +carries `id: `. Event names match the `type` of the +payload: `shot`, `shot_processing`, `profiles`, `power_status`, +`session_cleared`, `shot_deleted` and `club_changed`. Commands stay on `/api/club`, the +calibration route and Socket.IO. + +```bash +curl -N 'http://raspberrypi.local:8080/api/shots/stream?schema=2' +``` + +### Size budget + +A BLE message is at most 255 fragments × 15 bytes = 3,825 bytes. Tests encode +a worst-case v2 shot (longest float representations everywhere, a 40-character +profile name of six-byte escapes) and a `profiles` event with twelve such names +(3,610 bytes) and require both to fit. The publisher refuses, and logs, any +message that would not fit instead of sending a truncated one. + +## Delivery behavior + +- Shot processing never waits for either transport, and a failure in one cannot + affect the other, the browser UI, or session logging. +- Each connected client gets a bounded queue of eight unsent events; the oldest + queued event is dropped if that client cannot keep up. One stalled phone + cannot slow down another. +- Disconnecting clears that client's queue. On the next connection a v2 client + is [caught up](#catch-up-after-a-reconnect) on the session shots it missed + (up to 20); a version-one client gets the latest completed shot replayed. +- The iOS app ignores a replayed event when its `event_id` is already visible. + v2 clients upsert by `event_id`, which also merges a provisional shot with + its final version. + +## Security and scope + +Version one intentionally has no application authentication or encryption layer +on either transport. Enable BLE only where nearby Bluetooth devices receiving +shots and issuing club or calibration commands is acceptable, and treat the +network API as accessible to anything on the same network — the same assumption +the browser UI already makes. Phone-assisted calibration can update and persist +TI mount tilt, so use either transport only in a trusted environment. + +BLE is unauthenticated: any nearby device can connect and write the control +characteristics. Schema v2 therefore exposes only reading state and selecting +(club, active profile) over Bluetooth, plus the calibration version one already +had. Actions that delete data, clearing a session or deleting a shot, and +profile add, rename and remove require the network (the kiosk or Socket.IO), where +they have the same exposure the browser UI already has. Revisit this only with +authenticated pairing. + +## Testing without hardware + +Everything above the radio is covered by the normal test suite, with no Pi, +Bluetooth adapter or `bless` install: + +- `tests/ble_harness.py` runs the real `BleShotPublisher` on its own thread and + event loop against a fake Bless server that behaves like Bless 0.3.0 on + BlueZ (subscription hooks called before `app.subscribed_characteristics` + changes, notifications delivered only to centrals subscribed to that + characteristic, writes through `write_request_func`). `VirtualCentral`s play + the phones: they subscribe, write framed commands and reassemble + notifications with the real reassembler. +- `tests/test_ble_loopback.py` uses it end to end against the real server + dispatch: latest-shot replay, `hello` on both control characteristics, a + provisional-then-final shot (v2 phone gets both with one `event_id`, a v1 + phone next to it gets only the v1 final shot), club and profile commands, + the calibration `409` path, unknown commands and pair-by-pair unsubscribe. +- `tests/fixtures/ble_goldens/*.json` hold framed hex for every message type. + `server_to_client` files are generated by + `uv run python scripts/ble/generate_goldens.py` and checked by + `tests/test_ble_goldens.py`; client test suites decode them. + `client_to_server` files are the reverse: a client commits the frames its + own encoder produces (`name`, `characteristic`, `sequence`, `message`, + `payload_hex`, `frames_hex`, and an `expect` block with `ok`, + `schema_version` and expected `result` fields), and the Python tests + reassemble them, dispatch them through the loopback server and check the + answer. + +```bash +uv run pytest tests/test_ble_protocol.py tests/test_ble_protocol_v2.py \ + tests/test_ble_publisher.py tests/test_ble_loopback.py tests/test_ble_goldens.py \ + tests/test_shot_stream.py tests/test_phone_transport_server.py \ + tests/test_phone_transport_v2.py tests/test_control_commands.py \ + tests/test_phone_catch_up.py tests/test_ble_catch_up.py \ + tests/test_shot_stream_catch_up.py -v +uv run python scripts/ble/generate_goldens.py --check +``` + +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 SSE and Socket.IO clients. + +### Simulating hardware on a Pi without it + +Mock mode can produce every phone event, so a Pi with no radar, UPS or camera +can still exercise the app end to end over the real radio: + +```bash +scripts/start-kiosk.sh --mock --ble --battery mock --mock-enrichment-ms 1500 +``` + +| What | How | The phone sees | +|---|---|---| +| Shots | Tap **Simulate shot** on the kiosk, or emit `simulate_shot` over Socket.IO | `shot_processing` `capturing` then `calculating`, then the shot | +| A failed capture | Emit `simulate_shot` with `{"fail": true}` | `shot_processing` `capturing`, `calculating`, `failed`, and no shot | +| Provisional then final | `--mock-enrichment-ms MS` | A provisional shot (`enrichment: pending`, no horizontal launch, club path or spin axis), then the final one with them, sharing one `event_id` | +| Skipped enrichment | `--mock-enrichment-ms` above the 20 s deadline, e.g. `25000` | The final shot with `enrichment: {"status":"skipped","reason":"deadline"}` | +| Battery | `--battery mock` | `power_status` cycling from 100% on battery through `low` and `critical`, then `plugged_in` back to 100%, about every 100 s; `get_power_status` answers with the latest reading | + +Without `--battery`, `get_power_status` fails with `Battery monitoring is not +enabled`. Without `--iwr6843`, the calibration command fails with `409` `TI +IWR6843 radar is not enabled`; both are the expected error paths for a phone to +show. + +To fire shots from another machine without the kiosk: + +```bash +ssh 'cd /tmp && ~/.local/bin/uv run -q --no-project --with "python-socketio[client]" python -c " +import socketio, time +sio = socketio.Client(); sio.connect(\"http://localhost:8080\") +sio.emit(\"simulate_shot\") # or: sio.emit(\"simulate_shot\", {\"fail\": True}) +time.sleep(1); sio.disconnect()"' +``` + +## Troubleshooting + +**The network transport will not connect.** + +- Confirm the address with `curl -N http://:8080/api/shots/stream` from a + computer on the same network. If curl works and the app does not, the problem + is on the phone, not the Pi. +- If `.local` does not resolve, try the Pi's IP address; some networks + block mDNS. +- Accept the iOS local network permission prompt. Deny it once and the app + cannot reach the Pi until you re-enable it in Settings, Privacy & Security, + Local Network. +- Keep the phone on the same network as the Pi; this transport does not traverse + routers or VPNs. + +**The app stays on “Looking for OpenFlight.”** + +- Confirm OpenFlight was started with `--ble`. +- Run `bluetoothctl show` on the Pi and confirm `Powered: yes`. +- Keep the app in the foreground for the initial connection. +- Restart OpenFlight after changing the Pi Bluetooth configuration. + +**The iPhone shows a pairing prompt every 30 seconds.** + +Symptom: the phone connects, then disconnects about every 33 seconds and iOS +asks to pair again. The OpenFlight log shows `[BLE] Client subscribed (schema +v2)` followed by `unsubscribed` exactly 30 seconds later, and `bluetoothctl info +` shows `Paired: no`. + +OpenFlight never asks for pairing. BlueZ does: by default `bluetoothd` also acts +as a GATT client and reads the phone's own GATT database. The iPhone answers +`Insufficient Authentication`, BlueZ sends an SMP Security Request (the iOS +prompt), and nothing on a headless or kiosk Pi confirms the pairing. After the +30-second SMP timeout BlueZ disconnects with `Authentication Failure (0x05)`, +the phone reconnects, and the loop repeats. + +Turn off BlueZ's GATT client role (OpenFlight only needs to be a peripheral): + +```bash +./scripts/setup/configure_bluetooth.sh # or --check to only report +``` + +The script backs up `/etc/bluetooth/main.conf`, sets `Client = false` under +`[GATT]`, and restarts bluetooth. Restart OpenFlight afterwards so its BLE +server re-registers, and on the iPhone tap Forget This Device if iOS remembered +a half-finished pairing. `setup.sh` offers this step on a Pi. + +Set the key under `[GATT]`. Stock `main.conf` on Raspberry Pi OS lists the +commented `#Client = true` under `[CSIS]`, where `bluetoothd` ignores it. + +To confirm the fix, capture with `sudo btmon` while the phone connects: there +should be no `SMP: Security Request` and no `Disconnect … Authentication +Failure`, and the subscription should stay up past 30 seconds. + +**The Pi logs `DBusError: Failed to register advertisement`.** + +BlueZ returns that one message for every advertising failure, so check what +bluetoothd actually rejected: + +```bash +journalctl -u bluetooth -n 20 --no-pager +``` + +`Failed to add advertisement: Invalid Parameters (0x0d)` means the kernel +refused the advertisement. Register the advertisement one property group at a +time to find out which part it objects to: + +```bash +uv run python scripts/hardware-test/test_ble_advertise.py +``` + +The probe prints which layer is implicated. For the parameter-level detail, +capture the management interface while it runs: + +```bash +sudo btmon -w /tmp/ble-adv.btsnoop +``` + +### Known bad: Raspberry Pi kernel 6.18.34+rpt-rpi-2712 + +On this kernel every advertisement is rejected, including one carrying no data +at all. `Add Extended Advertising Parameters (0x0054)` succeeds and reports 31 +bytes available for both advertising and scan response data, and then `Add +Extended Advertising Data (0x0055)` fails with `Invalid Parameters (0x0d)` for +a zero-byte payload: + +```text +@ MGMT Event: Command Complete Add Extended Advertising Parameters (0x0054) + Status: Success (0x00) + Available adv data len: 31 + Available scan rsp data len: 31 +@ MGMT Command: Add Extended Advertising Data (0x0055) + Advertising data length: 0 + Scan response length: 0 +@ MGMT Event: Command Status Add Extended Advertising Data (0x0055) + Status: Invalid Parameters (0x0d) +``` + +Nothing in userspace can shrink a zero-byte payload, so no OpenFlight or Bless +setting works around this. Boot a kernel without the regression (6.12.x is +reported to work) and rerun the probe. Until then, use the network transport +above: it needs no Bluetooth and delivers the identical payload. + +**The Pi logs that Bluetooth is unavailable.** + +- Run `uv sync --extra ble`. +- Confirm the BlueZ service is running with `systemctl status bluetooth`. +- Confirm the user running OpenFlight can access the system D-Bus and Bluetooth + adapter. + +**The app connects but no new shot appears.** + +- Confirm the browser UI received the shot; BLE publishes only completed shot + events. +- Look for `[BLE]` warnings in the OpenFlight terminal. +- Tap Retry in the app to disconnect, scan, and subscribe again. + +## Automated tests + +The Python side is covered in [Testing without hardware](#testing-without-hardware). +The SwiftUI app's tests run from a checkout of jake-fishtech's `feat/iOS-ble` +branch: + +```bash +xcodebuild test \ + -project ios/OpenFlight.xcodeproj \ + -scheme OpenFlight \ + -destination "platform=iOS Simulator,id=PASTE-SIMULATOR-UUID-HERE" \ + CODE_SIGNING_ALLOWED=NO +``` + +List valid simulator UUIDs first with `xcrun simctl list devices available`. diff --git a/docs/using/battery.md b/docs/using/battery.md index 8b7b88823..c1592a121 100644 --- a/docs/using/battery.md +++ b/docs/using/battery.md @@ -10,6 +10,7 @@ down Linux automatically. | Provider | CLI value | Hardware | Setup guide | |---|---|---|---| | Geekworm | `geekworm` | X1202 and X1206 | [Geekworm X1202/X1206](../build/battery.md) | +| Simulated | `mock` | None: cycles through every power state for testing the UI and phone apps | [Simulating hardware](../ios-ble.md#simulating-hardware-on-a-pi-without-it) | Start OpenFlight with an installed provider: 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..803423c0b --- /dev/null +++ b/scripts/ble/generate_goldens.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Write cross-language BLE golden files to ``tests/fixtures/ble_goldens``. + +Each server-to-client golden holds one message exactly as OpenFlight notifies +it: the decoded JSON, the UTF-8 payload and every 20-byte frame, all as hex. +Client implementations (the Swift app, the Kotlin Multiplatform app) decode +these frames in their own tests, and ``tests/test_ble_goldens.py`` fails when +the committed files drift from what the encoder produces now. + +Files whose ``direction`` is ``client_to_server`` are inputs, not outputs: +clients commit the frames their encoders produce, and the Python tests +reassemble and dispatch them. This script never rewrites them. + +Usage:: + + uv run python scripts/ble/generate_goldens.py # rewrite goldens + uv run python scripts/ble/generate_goldens.py --check # exit 1 on drift + uv run python scripts/ble/generate_goldens.py --refresh-shot-fixture +""" + +from __future__ import annotations + +import argparse +import json +import random +import sys +from datetime import datetime +from pathlib import Path + +from openflight.ble.protocol import ( + CONTROL_CHARACTERISTIC_UUID, + CONTROL_V2_CHARACTERISTIC_UUID, + SHOT_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, + build_club_event_v2, + build_control_response, + build_hello_result, + build_power_status_event, + build_profiles_event, + build_session_cleared_event, + build_shot_deleted_event, + build_shot_event_v2, + build_shot_processing_event, + encode_club_event, + encode_message, + encode_message_v2, + encode_shot_event, + fragment_payload, +) + +ROOT = Path(__file__).resolve().parents[2] +FIXTURES = ROOT / "tests" / "fixtures" +GOLDENS_DIR = FIXTURES / "ble_goldens" +SHOT_V1_FIXTURE = FIXTURES / "shot_v1.json" +SHOT_V2_FIXTURE = FIXTURES / "shot_v2.json" + +REQUEST_ID = "5E0F2C4A-8B1D-4C3E-9F6A-7D2B1C0E9A84" +PROFILE_A = "0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b" +PROFILE_B = "7c1d9e3f5a2b4c6d8e0f1a3b5c7d9e1f" + + +def build_shot_v2_fixture() -> dict: + """Build the v2 contract fixture from a real (seeded) mock shot.""" + from openflight import server # pylint: disable=import-outside-toplevel + from openflight.launch_monitor import ClubType # pylint: disable=import-outside-toplevel + + random.seed(2026) + monitor = server.MockLaunchMonitor() + monitor.set_club(ClubType.IRON_7) + shot = monitor.simulate_shot() + shot.timestamp = datetime(2026, 9, 25, 14, 3, 7, 412345) + shot.shot_number = 7 + shot.profile_id = PROFILE_A + shot.profile_name = "Zoë" + return build_shot_event_v2( + server.shot_to_dict(shot), + final=True, + enrichment={"status": "complete"}, + ) + + +def _golden( + name: str, + description: str, + characteristic: str, + message: dict, + payload: bytes, + sequence: int, +) -> dict: + return { + "name": name, + "description": description, + "direction": "server_to_client", + "characteristic": characteristic, + "schema_version": message["schema_version"], + "sequence": sequence, + "message": message, + "payload_hex": payload.hex(), + "frames_hex": [frame.hex() for frame in fragment_payload(payload, sequence=sequence)], + } + + +def _v1(name, description, characteristic, message, sequence): + return _golden(name, description, characteristic, message, encode_message(message), sequence) + + +def _v2(name, description, characteristic, message, sequence): + return _golden(name, description, characteristic, message, encode_message_v2(message), sequence) + + +def worst_case_profiles_snapshot() -> dict: + """Twelve profiles with the longest-escaping 40-character names allowed.""" + return { + "profiles": [ + { + "id": f"{index:032x}", + "name": "\x01" * 40, + "created_at": "2026-09-25T12:00:00Z", + "settings": {"ignored": "x" * 500}, + } + for index in range(12) + ], + "active_profile_id": f"{0:032x}", + } + + +def build_goldens() -> dict[str, dict]: + """Every server-to-client golden, keyed by file stem.""" + shot_v1 = json.loads(SHOT_V1_FIXTURE.read_text(encoding="utf-8")) + shot_v2 = json.loads(SHOT_V2_FIXTURE.read_text(encoding="utf-8")) + v1_shot_payload = encode_shot_event(shot_v1, event_id=shot_v1["event_id"]) + provisional = build_shot_event_v2( + shot_v2, + final=False, + enrichment={"status": "pending"}, + event_id=shot_v2["event_id"], + ) + profiles = { + "profiles": [ + {"id": PROFILE_A, "name": "Zoë ⛳", "created_at": "2026-09-01T08:00:00Z"}, + {"id": PROFILE_B, "name": "Sam", "created_at": "2026-09-02T08:00:00Z"}, + ], + "active_profile_id": PROFILE_A, + } + power = { + "available": True, + "provider": "geekworm", + "state": "on_battery", + "battery_percent": 76.5, + "battery_voltage_v": 3.98, + "external_power": False, + "updated_at": "2026-09-25T14:03:05.000000+00:00", + "error": None, + } + + goldens = [ + _golden( + "v1_shot", + "Version-one final shot on the v1 shot characteristic (tests/fixtures/shot_v1.json).", + SHOT_CHARACTERISTIC_UUID, + json.loads(v1_shot_payload), + v1_shot_payload, + 0, + ), + _golden( + "v1_club_changed", + "Version-one club_changed notify on the v1 control characteristic.", + CONTROL_CHARACTERISTIC_UUID, + json.loads(encode_club_event("7-iron")), + encode_club_event("7-iron"), + 1, + ), + _v1( + "v1_response_get_club", + "get_club answered on the v1 control characteristic.", + CONTROL_CHARACTERISTIC_UUID, + build_control_response(REQUEST_ID, result={"status": "current", "club": "7-iron"}), + 2, + ), + _v1( + "v1_response_hello", + "hello {client_schema_max:2} answered on the v1 control characteristic: " + "v1 envelope, v2 result.", + CONTROL_CHARACTERISTIC_UUID, + build_control_response(REQUEST_ID, result=build_hello_result(2)), + 3, + ), + _v1( + "v1_response_unknown_command", + "What a server without schema v2 (or any server, for an unknown type) answers.", + CONTROL_CHARACTERISTIC_UUID, + build_control_response(REQUEST_ID, error="Unsupported phone command: hello"), + 4, + ), + _v2( + "v2_shot_provisional", + "Provisional OPS-only v2 shot (final:false); same event_id as v2_shot_final.", + SHOT_V2_CHARACTERISTIC_UUID, + provisional, + 0, + ), + _v2( + "v2_shot_final", + "Final v2 shot (tests/fixtures/shot_v2.json).", + SHOT_V2_CHARACTERISTIC_UUID, + shot_v2, + 1, + ), + _v2( + "v2_response_hello", + "hello answered on the v2 control characteristic.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_control_response(REQUEST_ID, result=build_hello_result(2), schema_version=2), + 0, + ), + _v2( + "v2_response_set_active_profile", + "set_active_profile accepted.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_control_response( + REQUEST_ID, + result={"status": "applied", "active_profile_id": PROFILE_B}, + schema_version=2, + ), + 1, + ), + _v2( + "v2_response_error", + "A failed v2 command.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_control_response(REQUEST_ID, error="Unknown profile", schema_version=2), + 2, + ), + _v2( + "v2_event_shot_processing", + "shot_processing notify (capturing | calculating | failed).", + CONTROL_V2_CHARACTERISTIC_UUID, + build_shot_processing_event("calculating"), + 3, + ), + _v2( + "v2_event_profiles", + "profiles notify: ids and names only, UTF-8 (not \\u-escaped) names.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_profiles_event(profiles), + 4, + ), + _v2( + "v2_event_profiles_worst_case", + "Twelve 40-character names that each escape to six bytes per character: " + "the largest profiles event the server can send.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_profiles_event(worst_case_profiles_snapshot()), + 5, + ), + _v2( + "v2_event_power_status", + "power_status notify: the Socket.IO payload beside type.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_power_status_event(power), + 6, + ), + _v2( + "v2_event_session_cleared", + "session_cleared notify.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_session_cleared_event(PROFILE_A), + 7, + ), + _v2( + "v2_event_shot_deleted", + "shot_deleted notify: a shot was deleted (over Wi-Fi/Socket.IO); key is its timestamp.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_shot_deleted_event(shot_v2["timestamp"]), + 9, + ), + _v2( + "v2_event_club_changed", + "club_changed notify on the v2 control characteristic.", + CONTROL_V2_CHARACTERISTIC_UUID, + build_club_event_v2("7-iron"), + 8, + ), + ] + return {golden["name"]: golden for golden in goldens} + + +def render(golden: dict) -> str: + return json.dumps(golden, ensure_ascii=False, indent=2) + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + parser.add_argument("--check", action="store_true", help="exit 1 if files differ") + parser.add_argument( + "--refresh-shot-fixture", + action="store_true", + help="rebuild tests/fixtures/shot_v2.json from a seeded mock shot first", + ) + args = parser.parse_args(argv) + + if args.refresh_shot_fixture: + SHOT_V2_FIXTURE.write_text( + json.dumps(build_shot_v2_fixture(), ensure_ascii=False, indent=2, sort_keys=True) + + "\n", + encoding="utf-8", + ) + + GOLDENS_DIR.mkdir(parents=True, exist_ok=True) + drift = [] + for name, golden in build_goldens().items(): + path = GOLDENS_DIR / f"{name}.json" + text = render(golden) + if args.check: + if not path.exists() or path.read_text(encoding="utf-8") != text: + drift.append(path.name) + else: + path.write_text(text, encoding="utf-8") + if drift: + print("BLE goldens out of date: " + ", ".join(drift), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/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..c4f657b37 --- /dev/null +++ b/src/openflight/ble/__init__.py @@ -0,0 +1,33 @@ +"""Bluetooth Low Energy shot publishing for OpenFlight.""" + +from .protocol import ( + CONTROL_CHARACTERISTIC_UUID, + CONTROL_V2_CHARACTERISTIC_UUID, + FRAME_SIZE, + SERVICE_UUID, + SHOT_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, + FragmentReassembler, + build_shot_event, + build_shot_event_v2, + encode_shot_event, + encode_shot_event_v2, + fragment_payload, +) +from .publisher import BleShotPublisher + +__all__ = [ + "BleShotPublisher", + "CONTROL_CHARACTERISTIC_UUID", + "CONTROL_V2_CHARACTERISTIC_UUID", + "FRAME_SIZE", + "FragmentReassembler", + "SERVICE_UUID", + "SHOT_CHARACTERISTIC_UUID", + "SHOT_V2_CHARACTERISTIC_UUID", + "build_shot_event", + "build_shot_event_v2", + "encode_shot_event", + "encode_shot_event_v2", + "fragment_payload", +] diff --git a/src/openflight/ble/protocol.py b/src/openflight/ble/protocol.py new file mode 100644 index 000000000..2b441f954 --- /dev/null +++ b/src/openflight/ble/protocol.py @@ -0,0 +1,400 @@ +"""Versioned OpenFlight shot payload and BLE framing helpers.""" + +from __future__ import annotations + +import json +import math +import struct +import uuid +from typing import Iterable, Mapping + +SERVICE_UUID = "B6F633F2-E6E3-45AE-84B4-968ECCA2D9C7" +SHOT_CHARACTERISTIC_UUID = "2B28F67E-9011-41D2-98ED-562B47D7A5E4" +CONTROL_CHARACTERISTIC_UUID = "7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11" + +# Schema v2 lives on its own pair of characteristics in the same service. +# BlueZ notifies every subscribed central from one characteristic value, so a +# separate pair is the only way to keep version-one centrals on byte-identical +# version-one traffic (see "Schema v2 design decision" in docs/ios-ble.md). +SHOT_V2_CHARACTERISTIC_UUID = "ED365FE6-3ABF-4FC3-8E44-D9525A22DABD" +CONTROL_V2_CHARACTERISTIC_UUID = "7BA96E63-12C2-4CE0-BB84-3513C7FD1474" + +SCHEMA_VERSION = 1 +SCHEMA_VERSION_V2 = 2 +MAX_SCHEMA_VERSION = SCHEMA_VERSION_V2 +FRAME_VERSION = 1 +FRAME_SIZE = 20 +_HEADER = struct.Struct(">BHBB") +HEADER_SIZE = _HEADER.size +FRAGMENT_PAYLOAD_SIZE = FRAME_SIZE - HEADER_SIZE +MAX_FRAGMENT_COUNT = 255 +MAX_MESSAGE_SIZE = FRAGMENT_PAYLOAD_SIZE * MAX_FRAGMENT_COUNT + +_OPTIONAL_SHOT_FIELDS = ( + "club_speed_mph", + "smash_factor", + "launch_angle_vertical", + "launch_angle_horizontal", + "spin_rpm", + "club_path_deg", + "spin_axis_deg", +) + +# Added by schema v2. Present on every v2 shot, ``null`` when unknown. +_V2_OPTIONAL_SHOT_FIELDS = ( + "shot_number", + "profile_id", + "profile_name", + "carry_range", + "spin_source", + "launch_angle_confidence", +) + +# Stable namespace for per-shot v2 event ids. Changing it changes every id. +_SHOT_EVENT_NAMESPACE = uuid.UUID("D49C99A9-A305-49CA-A8C2-7D30B7645988") + +V2_FEATURES = ( + "provisional_shots", + "shot_processing", + "profiles", + "power_status", + "shot_deleted", + "club", + "shot_catch_up", +) + +V2_EVENT_TYPES = ( + "shot", + "shot_processing", + "profiles", + "power_status", + "session_cleared", + "shot_deleted", + "club_changed", +) + +ENRICHMENT_STATUSES = ("pending", "complete", "skipped") + + +def build_club_event(club: str) -> dict: + """Build the V1 event broadcast whenever the authoritative club changes.""" + if not isinstance(club, str) or not club: + raise ValueError("Club must be a non-empty string") + return { + "schema_version": SCHEMA_VERSION, + "type": "club_changed", + "club": club, + } + + +def encode_club_event(club: str) -> bytes: + """Encode a club-state event as deterministic, compact UTF-8 JSON.""" + return json.dumps( + build_club_event(club), + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def build_shot_event(shot_data: Mapping, *, event_id: str | None = None) -> dict: + """Build the stable, display-focused V1 payload from ``shot_to_dict`` output.""" + event = { + "schema_version": SCHEMA_VERSION, + "event_id": event_id or str(uuid.uuid4()), + "timestamp": shot_data["timestamp"], + "club": shot_data["club"], + "ball_speed_mph": shot_data["ball_speed_mph"], + "estimated_carry_yards": shot_data["estimated_carry_yards"], + } + event.update({field: shot_data.get(field) for field in _OPTIONAL_SHOT_FIELDS}) + return event + + +def encode_shot_event(shot_data: Mapping, *, event_id: str | None = None) -> bytes: + """Encode a shot event as deterministic, compact UTF-8 JSON.""" + event = build_shot_event(shot_data, event_id=event_id) + return json.dumps( + event, + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def encode_message(message: Mapping) -> bytes: + """Encode a version-one message: compact, sorted, ASCII-only JSON.""" + return json.dumps( + message, + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def encode_message_v2(message: Mapping) -> bytes: + """Encode a schema v2 message: compact, sorted UTF-8 JSON. + + Unlike version one, non-ASCII text is sent as UTF-8 rather than ``\\uXXXX`` + escapes, which keeps twelve 40-character profile names inside one BLE + message. Clients must decode UTF-8 only after reassembling every fragment. + """ + return json.dumps( + message, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8", "replace") + + +def stable_shot_event_id(shot_data: Mapping) -> str: + """Derive one event id per shot, shared by its provisional and final v2 events. + + ``timestamp`` is fixed at detection and ``shot_number`` at callback time, + so both publications of the same shot hash to the same UUID while shots + from different logging sessions (whose numbers restart at one) do not. + """ + timestamp = shot_data["timestamp"] + if not isinstance(timestamp, str) or not timestamp: + raise ValueError("Shot timestamp must be a non-empty string") + shot_number = shot_data.get("shot_number") + return str(uuid.uuid5(_SHOT_EVENT_NAMESPACE, f"{timestamp}#{shot_number}")) + + +def _blank_to_none(value): + return None if value == "" else value + + +def build_enrichment(status: str, reason: str | None = None) -> dict: + """Build the v2 ``enrichment`` object describing optional-hardware progress.""" + if status not in ENRICHMENT_STATUSES: + raise ValueError(f"Unknown enrichment status: {status!r}") + enrichment = {"status": status} + if reason: + enrichment["reason"] = str(reason) + return enrichment + + +def build_shot_event_v2( + shot_data: Mapping, + *, + final: bool, + enrichment: Mapping | None = None, + event_id: str | None = None, +) -> dict: + """Build a v2 shot: the v1 fields plus identity, profile and state fields.""" + event = build_shot_event(shot_data, event_id=event_id or stable_shot_event_id(shot_data)) + event["schema_version"] = SCHEMA_VERSION_V2 + event["type"] = "shot" + event["final"] = bool(final) + for field in _V2_OPTIONAL_SHOT_FIELDS: + event[field] = _blank_to_none(shot_data.get(field)) + if event["carry_range"] is not None: + event["carry_range"] = list(event["carry_range"]) + event["enrichment"] = ( + build_enrichment(enrichment["status"], enrichment.get("reason")) + if enrichment is not None + else None + ) + return event + + +def encode_shot_event_v2( + shot_data: Mapping, + *, + final: bool, + enrichment: Mapping | None = None, + event_id: str | None = None, +) -> bytes: + """Encode a v2 shot event.""" + return encode_message_v2( + build_shot_event_v2(shot_data, final=final, enrichment=enrichment, event_id=event_id) + ) + + +def build_event_v2(event_type: str, fields: Mapping | None = None) -> dict: + """Build one v2 notify event: ``{"schema_version":2,"type":...,**fields}``.""" + if event_type not in V2_EVENT_TYPES: + raise ValueError(f"Unknown v2 event type: {event_type!r}") + fields = dict(fields or {}) + if "schema_version" in fields or "type" in fields: + raise ValueError("v2 event fields must not override schema_version or type") + return {"schema_version": SCHEMA_VERSION_V2, "type": event_type, **fields} + + +def build_club_event_v2(club: str) -> dict: + """The v2 counterpart of ``build_club_event``.""" + if not isinstance(club, str) or not club: + raise ValueError("Club must be a non-empty string") + return build_event_v2("club_changed", {"club": club}) + + +def build_shot_processing_event(state: str) -> dict: + """``shot_processing``: ``capturing``, ``calculating`` or ``failed``.""" + if not isinstance(state, str) or not state: + raise ValueError("Processing state must be a non-empty string") + return build_event_v2("shot_processing", {"state": state}) + + +def build_profiles_event(snapshot: Mapping) -> dict: + """``profiles`` for phones: ids and names only. + + ``created_at`` and the opaque ``settings`` dict stay on Socket.IO. Phones + only select profiles, and an unbounded ``settings`` dict could not be + guaranteed to fit in one BLE message. + """ + profiles = [ + {"id": str(profile["id"]), "name": str(profile["name"])} + for profile in snapshot.get("profiles") or [] + ] + return build_event_v2( + "profiles", + {"profiles": profiles, "active_profile_id": snapshot.get("active_profile_id")}, + ) + + +def build_power_status_event(status: Mapping) -> dict: + """``power_status``: the Socket.IO payload, flattened beside ``type``.""" + return build_event_v2("power_status", status) + + +def build_session_cleared_event(profile_id: str) -> dict: + """``session_cleared``: the profile whose shots were removed.""" + return build_event_v2("session_cleared", {"profile_id": profile_id}) + + +def build_control_response( + request_id: str, + *, + result: Mapping | None = None, + error: str | None = None, + schema_version: int = SCHEMA_VERSION, +) -> dict: + """Build the response notified for one control command.""" + response = { + "schema_version": schema_version, + "request_id": request_id, + "ok": error is None, + } + if error is None: + response["result"] = dict(result or {}) + else: + response["error"] = error + return response + + +def build_shot_deleted_event(timestamp: str) -> dict: + """``shot_deleted``: the timestamp (the delete key) of a removed shot.""" + if not isinstance(timestamp, str) or not timestamp: + raise ValueError("Deleted shot timestamp must be a non-empty string") + return build_event_v2("shot_deleted", {"timestamp": timestamp}) + + +def build_hello_result(client_schema_max) -> dict: + """Answer a ``hello`` command with the negotiated schema and features.""" + if isinstance(client_schema_max, bool) or not isinstance(client_schema_max, int): + raise ValueError("hello requires an integer client_schema_max") + if client_schema_max < SCHEMA_VERSION: + raise ValueError("client_schema_max must be at least 1") + negotiated = min(client_schema_max, MAX_SCHEMA_VERSION) + if negotiated < SCHEMA_VERSION_V2: + return {"schema_version": negotiated, "features": []} + return { + "schema_version": negotiated, + "features": list(V2_FEATURES), + "characteristics": { + "shot": SHOT_V2_CHARACTERISTIC_UUID, + "control": CONTROL_V2_CHARACTERISTIC_UUID, + }, + } + + +def fragment_payload(payload: bytes, *, sequence: int) -> list[bytes]: + """Split a message into conservative 20-byte BLE notification frames.""" + if not payload: + 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..0cd657fcd --- /dev/null +++ b/src/openflight/ble/publisher.py @@ -0,0 +1,736 @@ +"""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, Sequence +from typing import Any, Mapping + +from .protocol import ( + CONTROL_CHARACTERISTIC_UUID, + CONTROL_V2_CHARACTERISTIC_UUID, + SCHEMA_VERSION, + SCHEMA_VERSION_V2, + SERVICE_UUID, + SHOT_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, + FragmentReassembler, + build_club_event_v2, + build_control_response, + build_hello_result, + encode_club_event, + encode_message, + encode_message_v2, + encode_shot_event, + encode_shot_event_v2, + fragment_payload, +) + +logger = logging.getLogger(__name__) + +CommandHandler = Callable[[str, Mapping], tuple[dict, int]] +# Given a client's ``last_event_id`` (or ``None``), the ``(event_id, v2 shot +# payload)`` pairs it missed, oldest first. See ``openflight.phone_catch_up``. +CatchUpProvider = Callable[[Any], Sequence[tuple[str, bytes]]] + +_V1_CHARACTERISTICS = frozenset( + {SHOT_CHARACTERISTIC_UUID.lower(), CONTROL_CHARACTERISTIC_UUID.lower()} +) +_V2_CHARACTERISTICS = frozenset( + {SHOT_V2_CHARACTERISTIC_UUID.lower(), CONTROL_V2_CHARACTERISTIC_UUID.lower()} +) +_ALL_CHARACTERISTICS = _V1_CHARACTERISTICS | _V2_CHARACTERISTICS + +# (response schema, accepted request envelope schemas) per control +# characteristic. The version-one characteristic is unchanged; the v2 one also +# accepts version-one envelopes so a client can reuse one encoder for ``hello``. +_CONTROL_SCHEMAS = { + CONTROL_CHARACTERISTIC_UUID.lower(): (SCHEMA_VERSION, (SCHEMA_VERSION,)), + CONTROL_V2_CHARACTERISTIC_UUID.lower(): ( + SCHEMA_VERSION_V2, + (SCHEMA_VERSION, SCHEMA_VERSION_V2), + ), +} + + +def _normalize_uuid(value) -> str | None: + try: + return str(uuid.UUID(str(value))).lower() + except (TypeError, ValueError): + return None + + +def _characteristic_uuid(characteristic) -> str | None: + """The normalized UUID of a Bless characteristic object, when it has one.""" + if characteristic is None: + return None + return _normalize_uuid(getattr(characteristic, "uuid", None)) + + +class BleShotPublisher: + """Publish completed shots without coupling the radar thread to Bluetooth. + + Version-one traffic uses the original shot and control characteristics and + is byte-for-byte what jake-fishtech's iOS app expects. Schema v2 traffic + (provisional and final shots, profile, power and processing events) uses a + second shot/control pair, so a v1 central never receives a v2 message. + """ + + def __init__( # pylint: disable=too-many-arguments + self, + *, + name: str = "OpenFlight", + queue_size: int = 8, + fragment_interval_s: float = 0.01, + command_handler: CommandHandler | None = None, + command_handler_v2: CommandHandler | None = None, + catch_up_provider: CatchUpProvider | None = None, + ): + if queue_size < 1: + raise ValueError("BLE queue size must be at least one") + self.name = name + self.queue_size = queue_size + self.fragment_interval_s = fragment_interval_s + self.command_handler = command_handler + self.command_handler_v2 = command_handler_v2 + self.catch_up_provider = catch_up_provider + + self._state_lock = threading.Lock() + self._thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._queue: asyncio.Queue[bytes] | None = None + self._v2_queue: asyncio.Queue[bytes] | None = None + self._stop_event: asyncio.Event | None = None + self._stop_requested = threading.Event() + self._server = None + self._latest_payload: bytes | None = None + self._latest_v2_payload: bytes | None = None + # ``_subscribed`` gates version-one traffic and ``_v2_subscribed`` gates + # schema v2 traffic. Both follow BlueZ's per-characteristic + # subscriptions when those are visible. + self._subscribed = False + self._v2_subscribed = False + self._subscriptions: set[str] = set() + self._sequence = 0 + self._v2_sequence = 0 + self._control_sequences = {key: 0 for key in _CONTROL_SCHEMAS} + self._control_reassemblers = {key: FragmentReassembler() for key in _CONTROL_SCHEMAS} + self._control_send_locks: dict[str, asyncio.Lock] = {} + # Missed shots computed at a v2 ``hello`` that arrived before the phone + # subscribed to the v2 shot characteristic; sent when it does. + self._pending_v2_catch_up: list[bytes] | None = None + + @property + def subscribed(self) -> bool: + """Whether a central is subscribed to version-one notifications.""" + with self._state_lock: + return self._subscribed + + @property + def v2_subscribed(self) -> bool: + """Whether a central is subscribed to schema v2 notifications.""" + with self._state_lock: + return self._v2_subscribed + + def start(self) -> None: + """Start advertising in a daemon thread; startup failures remain isolated.""" + with self._state_lock: + if self._thread and self._thread.is_alive(): + return + self._stop_requested.clear() + self._thread = threading.Thread( + target=self._run_thread, + name="openflight-ble", + daemon=True, + ) + self._thread.start() + + def stop(self) -> None: + """Stop advertising and join the BLE thread.""" + self._stop_requested.set() + with self._state_lock: + loop = self._loop + stop_event = self._stop_event + thread = self._thread + if loop and stop_event: + loop.call_soon_threadsafe(stop_event.set) + if thread and thread is not threading.current_thread(): + thread.join(timeout=5.0) + if thread.is_alive(): + logger.warning("[BLE] Publisher thread did not stop within 5 seconds") + + def publish(self, shot_data: Mapping) -> bool: + """Store the latest v1 shot and enqueue it when a central is subscribed.""" + try: + payload = encode_shot_event(shot_data) + except (KeyError, TypeError, ValueError): + logger.warning("[BLE] Failed to encode shot payload", exc_info=True) + return False + + with self._state_lock: + self._latest_payload = payload + loop = self._loop + subscribed = self._subscribed + if loop and subscribed: + loop.call_soon_threadsafe(self._enqueue_payload, payload) + return True + + def publish_v2_shot( + self, + shot_data: Mapping, + *, + final: bool, + enrichment: Mapping | None = None, + ) -> bool: + """Store the latest v2 shot (provisional or final) and notify v2 centrals.""" + try: + payload = encode_shot_event_v2(shot_data, final=final, enrichment=enrichment) + fragment_payload(payload, sequence=0) + except (KeyError, TypeError, ValueError): + logger.warning("[BLE] Failed to encode v2 shot payload", exc_info=True) + return False + + with self._state_lock: + self._latest_v2_payload = payload + loop = self._loop + subscribed = self._v2_subscribed + if loop and subscribed: + loop.call_soon_threadsafe(self._enqueue_v2_payload, payload) + return True + + def publish_club(self, club: str) -> bool: + """Notify connected centrals that the authoritative club changed.""" + try: + payload = encode_club_event(club) + v2_payload = encode_message_v2(build_club_event_v2(club)) + except (TypeError, ValueError): + logger.warning("[BLE] Failed to encode club payload", exc_info=True) + return False + + with self._state_lock: + loop = self._loop + subscribed = self._subscribed + v2_subscribed = self._v2_subscribed + if loop and subscribed: + asyncio.run_coroutine_threadsafe(self._send_control_response(payload), loop) + if loop and v2_subscribed: + asyncio.run_coroutine_threadsafe( + self._send_control_response(v2_payload, CONTROL_V2_CHARACTERISTIC_UUID), + loop, + ) + return True + + def publish_event_v2(self, event: Mapping) -> bool: + """Notify v2 centrals of one schema v2 event on the v2 control characteristic.""" + try: + payload = encode_message_v2(event) + fragment_payload(payload, sequence=0) + except (TypeError, ValueError): + logger.warning("[BLE] Failed to encode v2 event", exc_info=True) + return False + + with self._state_lock: + loop = self._loop + subscribed = self._v2_subscribed + if loop and subscribed: + asyncio.run_coroutine_threadsafe( + self._send_control_response(payload, CONTROL_V2_CHARACTERISTIC_UUID), + loop, + ) + return True + + def _run_thread(self) -> None: + 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._v2_queue = None + self._stop_event = None + self._server = None + self._subscribed = False + self._v2_subscribed = False + self._subscriptions = set() + self._control_send_locks = {} + self._pending_v2_catch_up = None + + async def _run(self) -> None: + # Bless is an optional dependency and must not affect non-BLE installs. + 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) + v2_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) + for shot_uuid in (SHOT_CHARACTERISTIC_UUID, SHOT_V2_CHARACTERISTIC_UUID): + await server.add_new_characteristic( + SERVICE_UUID, + shot_uuid, + GATTCharacteristicProperties.notify, + bytearray(), + GATTAttributePermissions.readable, + ) + for control_uuid in (CONTROL_CHARACTERISTIC_UUID, CONTROL_V2_CHARACTERISTIC_UUID): + await server.add_new_characteristic( + SERVICE_UUID, + control_uuid, + GATTCharacteristicProperties.write | GATTCharacteristicProperties.notify, + bytearray(), + GATTAttributePermissions.readable | GATTAttributePermissions.writeable, + ) + server.write_request_func = self._on_write_request + self._install_bluez_subscription_hooks(server) + + with self._state_lock: + self._loop = loop + self._queue = queue + self._v2_queue = v2_queue + self._stop_event = stop_event + self._server = server + + await server.start() + logger.info("[BLE] Advertising %s", self.name) + if self._stop_requested.is_set(): + stop_event.set() + + workers = [ + asyncio.create_task(self._delivery_worker()), + asyncio.create_task(self._v2_delivery_worker()), + ] + try: + await stop_event.wait() + finally: + for worker in workers: + worker.cancel() + await asyncio.gather(*workers, 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 all of them, + # as the version-one publisher did. + subscriptions |= _ALL_CHARACTERISTICS + else: + subscriptions.add(characteristic_uuid) + self._set_subscriptions(subscriptions) + + def _on_unsubscribe(self, characteristic, _session) -> None: + characteristic_uuid = _characteristic_uuid(characteristic) + with self._state_lock: + subscriptions = set(self._subscriptions) + if characteristic_uuid is None: + subscriptions.clear() + else: + subscriptions.discard(characteristic_uuid) + self._set_subscriptions(subscriptions) + + def _set_subscriptions(self, subscriptions: set[str]) -> None: + v1 = bool(subscriptions & _V1_CHARACTERISTICS) + v2 = bool(subscriptions & _V2_CHARACTERISTICS) + shot_v1 = SHOT_CHARACTERISTIC_UUID.lower() + shot_v2 = SHOT_V2_CHARACTERISTIC_UUID.lower() + with self._state_lock: + previous = self._subscriptions + was_v1 = self._subscribed + was_v2 = self._v2_subscribed + self._subscriptions = set(subscriptions) + self._subscribed = v1 + self._v2_subscribed = v2 + latest_payload = self._latest_payload + latest_v2_payload = self._latest_v2_payload + v1_queue = self._queue + v2_queue = self._v2_queue + v2_shot_started = shot_v2 in subscriptions and shot_v2 not in previous + pending_catch_up = None + if v2_shot_started or not v2: + # Consumed by the subscription it was waiting for, or discarded + # with the connection that asked for it. + pending_catch_up = self._pending_v2_catch_up if v2_shot_started else None + self._pending_v2_catch_up = None + + if v1 != was_v1: + logger.info("[BLE] Client %s (v1)", "subscribed" if v1 else "unsubscribed") + if v2 != was_v2: + logger.info("[BLE] Client %s (schema v2)", "subscribed" if v2 else "unsubscribed") + if was_v1 and not v1: + self._clear_queue(v1_queue) + if was_v2 and not v2: + self._clear_queue(v2_queue) + + # Replay the latest shot when its shot characteristic gains a + # subscriber, so a phone that subscribes to control first still gets it. + # A v2 phone that already sent ``hello`` gets its catch-up instead, + # which holds the latest shot unless the phone already had it. + if shot_v1 in subscriptions and shot_v1 not in previous and latest_payload is not None: + self._enqueue_payload(latest_payload) + if v2_shot_started: + if pending_catch_up is not None: + self._start_v2_catch_up(pending_catch_up) + elif latest_v2_payload is not None: + self._enqueue_v2_payload(latest_v2_payload) + + def _enqueue_payload(self, payload: bytes) -> None: + with self._state_lock: + queue = self._queue + subscribed = self._subscribed + if subscribed: + self._offer(queue, payload) + + def _enqueue_v2_payload(self, payload: bytes) -> None: + with self._state_lock: + queue = self._v2_queue + subscribed = self._v2_subscribed + if subscribed: + self._offer(queue, payload) + + async def _load_v2_catch_up(self, last_event_id) -> list[bytes] | None: + """The shots a v2 client missed, or ``None`` when catch-up is unavailable.""" + provider = self.catch_up_provider + if provider is None: + return None + try: + entries = await asyncio.to_thread(provider, last_event_id) + return [payload for _event_id, payload in entries] + except Exception: # pylint: disable=broad-exception-caught + # Never fail ``hello`` over catch-up: the phone keeps the + # latest-shot replay it had before catch-up existed. + logger.warning("[BLE] Could not load missed shots for catch-up", exc_info=True) + return None + + def _schedule_v2_catch_up(self, payloads: list[bytes] | None) -> None: + """Send catch-up now if the v2 shot characteristic is subscribed, else on subscribe. + + ``None`` (no catch-up for this command) does nothing. + """ + if payloads is None: + return + shot_v2 = SHOT_V2_CHARACTERISTIC_UUID.lower() + with self._state_lock: + ready = shot_v2 in self._subscriptions + if not ready: + self._pending_v2_catch_up = payloads + logger.info( + "[BLE] Catch-up: %d missed shot(s) for schema v2 client%s", + len(payloads), + "" if ready else " (sent when it subscribes to shots)", + ) + if ready: + self._start_v2_catch_up(payloads) + + def _start_v2_catch_up(self, payloads: list[bytes]) -> None: + with self._state_lock: + loop = self._loop + if loop is not None and payloads: + asyncio.run_coroutine_threadsafe(self._deliver_v2_catch_up(payloads), loop) + + async def _deliver_v2_catch_up(self, payloads: list[bytes]) -> None: + """Queue catch-up shots in order, waiting for room instead of dropping any.""" + for payload in payloads: + with self._state_lock: + queue = self._v2_queue + subscribed = self._v2_subscribed + if queue is None or not subscribed: + return + await queue.put(payload) + + @staticmethod + def _offer(queue: asyncio.Queue[bytes] | None, payload: bytes) -> None: + if queue is None: + 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 + await self._drain(queue, self._send_payload) + + async def _v2_delivery_worker(self) -> None: + with self._state_lock: + queue = self._v2_queue + await self._drain(queue, self._send_v2_payload) + + @staticmethod + async def _drain(queue: asyncio.Queue[bytes] | None, send) -> None: + if queue is None: + return + while True: + payload = await queue.get() + try: + await send(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, v2=False) + + async def _send_v2_payload(self, payload: bytes) -> None: + with self._state_lock: + sequence = self._v2_sequence + self._v2_sequence = (self._v2_sequence + 1) & 0xFFFF + await self._notify_frames(SHOT_V2_CHARACTERISTIC_UUID, payload, sequence, v2=True) + + def _gate_open(self, v2: bool) -> bool: + with self._state_lock: + return self._v2_subscribed if v2 else self._subscribed + + async def _notify_frames( + self, + characteristic_uuid: str, + payload: bytes, + sequence: int, + *, + v2: bool, + ) -> None: + with self._state_lock: + server = self._server + if server is None or not self._gate_open(v2): + return + + characteristic = server.get_characteristic(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._gate_open(v2): + 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.""" + characteristic_uuid = _characteristic_uuid(characteristic) + reassembler = self._control_reassemblers.get(characteristic_uuid) + if reassembler is None: + return + characteristic.value = bytearray(value) + try: + payload = reassembler.append(bytes(value)) + except ValueError: + logger.warning("[BLE] Rejected malformed control frame", exc_info=True) + 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, characteristic_uuid), + loop, + ) + + async def _process_control_payload( + self, + payload: bytes, + characteristic_uuid: str = CONTROL_CHARACTERISTIC_UUID, + ) -> None: + characteristic_uuid = characteristic_uuid.lower() + response_schema, accepted_schemas = _CONTROL_SCHEMAS[characteristic_uuid] + v2 = response_schema == SCHEMA_VERSION_V2 + handler = self.command_handler_v2 if v2 else self.command_handler + request_id = "unknown" + catch_up: list[bytes] | None = None + 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_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": + response, catch_up = await self._answer_hello( + request_id, command_payload, response_schema + ) + else: + if handler is None: + raise ValueError("Phone controls are not configured on this OpenFlight server") + result, status = await asyncio.to_thread(handler, command_type, command_payload) + if status < 200 or status >= 300: + error = result.get("error", f"Control command failed with status {status}") + response = self._control_response( + request_id, error=str(error), schema_version=response_schema + ) + else: + response = self._control_response( + request_id, result=result, schema_version=response_schema + ) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as error: + response = self._control_response( + str(request_id or "unknown"), error=str(error), schema_version=response_schema + ) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("[BLE] Phone control command failed") + response = self._control_response( + str(request_id or "unknown"), + error="OpenFlight could not apply the phone command", + schema_version=response_schema, + ) + + encoded = encode_message_v2(response) if v2 else encode_message(response) + await self._send_control_response(encoded, characteristic_uuid) + # After the answer, so a client sees ``hello`` succeed before shots arrive. + self._schedule_v2_catch_up(catch_up) + + async def _answer_hello( + self, + request_id: str, + command_payload: Mapping, + response_schema: int, + ) -> tuple[dict, list[bytes] | None]: + """Negotiate the transport and load a v2 client's catch-up shots. + + Transport negotiation is answered by the publisher itself: it is about + which characteristics exist, not server state. + """ + result = build_hello_result(command_payload.get("client_schema_max")) + response = self._control_response(request_id, result=result, schema_version=response_schema) + if result["schema_version"] < SCHEMA_VERSION_V2: + return response, None + return response, await self._load_v2_catch_up(command_payload.get("last_event_id")) + + @staticmethod + def _control_response( + request_id: str, + *, + result: Mapping[str, Any] | None = None, + error: str | None = None, + schema_version: int = SCHEMA_VERSION, + ) -> dict: + return build_control_response( + request_id, result=result, error=error, schema_version=schema_version + ) + + async def _send_control_response( + self, + payload: bytes, + characteristic_uuid: str = CONTROL_CHARACTERISTIC_UUID, + ) -> None: + key = characteristic_uuid.lower() + lock = self._control_send_locks.get(key) + if lock is None: + lock = self._control_send_locks[key] = asyncio.Lock() + async with lock: + await self._send_control_payload(payload, key) + + async def _send_control_payload( + self, + payload: bytes, + characteristic_uuid: str = CONTROL_CHARACTERISTIC_UUID, + ) -> None: + """Send one complete control message without interleaving fragments.""" + key = characteristic_uuid.lower() + v2 = key in _V2_CHARACTERISTICS + # Notify with the spelling the characteristic was registered with. + canonical = CONTROL_V2_CHARACTERISTIC_UUID if v2 else CONTROL_CHARACTERISTIC_UUID + with self._state_lock: + server = self._server + sequence = self._control_sequences[key] + self._control_sequences[key] = (sequence + 1) & 0xFFFF + if server is None or not self._gate_open(v2): + return + + characteristic = server.get_characteristic(canonical) + if characteristic is None: + raise RuntimeError("BLE control characteristic is unavailable") + for frame in fragment_payload(payload, sequence=sequence): + characteristic.value = bytearray(frame) + if not server.update_value(SERVICE_UUID, canonical): + raise RuntimeError("BLE control notification update failed") + await asyncio.sleep(self.fragment_interval_s) diff --git a/src/openflight/phone_catch_up.py b/src/openflight/phone_catch_up.py new file mode 100644 index 000000000..bd9d99d56 --- /dev/null +++ b/src/openflight/phone_catch_up.py @@ -0,0 +1,97 @@ +"""Catch a reconnecting phone up on the session shots it missed. + +Both phone transports share this rule. A schema v2 client names the last shot it +has (``last_event_id`` in the BLE ``hello`` payload, ``Last-Event-ID`` on the +network stream) and receives that shot again plus every current-session shot +after it, oldest first. The named shot is resent because the client may hold +only its provisional version if it disconnected before the final one arrived. +A client that names no shot, or one the session no longer holds (cleared, +deleted, or from before a Pi restart), receives the whole session. Either way +the most recent ``CATCH_UP_LIMIT`` shots are sent, and clients upsert them by +``event_id`` so a shot they already have is harmless. + +The session itself (``monitor.get_shots()``) decides which shots exist, so +clears, deletes and profile changes need no bookkeeping here. ``PhoneShotCache`` +only remembers the exact v2 bytes last published per ``event_id``, so a replayed +shot carries the same ``final`` and ``enrichment`` state the phone would have +received live. +""" + +from __future__ import annotations + +import threading +from collections import OrderedDict +from typing import Sequence + +CATCH_UP_LIMIT = 20 +# Far above any realistic session; bounds memory on a Pi left running for days. +DEFAULT_CACHE_ENTRIES = 512 +# Event ids are UUIDs (36 characters); anything much longer is not one of ours. +MAX_EVENT_ID_LENGTH = 64 + +CatchUpEntry = tuple[str, bytes] + + +def normalize_last_event_id(value) -> str | None: + """Return a usable ``last_event_id``, or ``None`` when the client sent none. + + Invalid values are treated as "no anchor" rather than rejected: a client + that sends garbage still gets the whole session instead of a failed + ``hello`` that would push it back to version one. + """ + if not isinstance(value, str): + return None + value = value.strip() + if not value or len(value) > MAX_EVENT_ID_LENGTH: + return None + return value + + +def select_catch_up( + entries: Sequence[CatchUpEntry], + last_event_id: str | None, + *, + limit: int = CATCH_UP_LIMIT, +) -> list[CatchUpEntry]: + """The ``last_event_id`` shot and those after it (all shots if it is unknown). + + ``entries`` are ``(event_id, payload)`` pairs in session order. The result + keeps that order and holds at most the most recent ``limit`` shots. + """ + if limit < 1: + raise ValueError("Catch-up limit must be at least one") + start = 0 + if last_event_id is not None: + for index, (event_id, _payload) in enumerate(entries): + if event_id == last_event_id: + start = index + break + return list(entries[start:])[-limit:] + + +class PhoneShotCache: + """The latest encoded v2 shot per ``event_id``, bounded and thread-safe.""" + + def __init__(self, max_entries: int = DEFAULT_CACHE_ENTRIES): + if max_entries < 1: + raise ValueError("Phone shot cache must hold at least one entry") + self.max_entries = max_entries + self._lock = threading.Lock() + self._payloads: OrderedDict[str, bytes] = OrderedDict() + + def __len__(self) -> int: + with self._lock: + return len(self._payloads) + + def remember(self, event_id: str, payload: bytes) -> None: + """Store the newest payload for a shot (a final replaces its provisional).""" + with self._lock: + self._payloads[event_id] = payload + self._payloads.move_to_end(event_id) + while len(self._payloads) > self.max_entries: + self._payloads.popitem(last=False) + + def get(self, event_id: str) -> bytes | None: + """The last published payload for ``event_id``, if still cached.""" + with self._lock: + return self._payloads.get(event_id) diff --git a/src/openflight/phone_orientation.py b/src/openflight/phone_orientation.py new file mode 100644 index 000000000..3768ef9d2 --- /dev/null +++ b/src/openflight/phone_orientation.py @@ -0,0 +1,178 @@ +"""Validation and persistence for phone-assisted radar orientation calibration.""" + +from __future__ import annotations + +import json +import math +import os +import tempfile +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + + +class PhoneOrientationValidationError(ValueError): + """A phone orientation payload is unsafe or internally inconsistent.""" + + +def _finite_number(payload: dict, key: str) -> float: + value = payload.get(key) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise PhoneOrientationValidationError(f"{key} must be a number") + number = float(value) + if not math.isfinite(number): + raise PhoneOrientationValidationError(f"{key} must be finite") + return number + + +@dataclass(frozen=True) +class PhoneOrientationMeasurement: + """One stable, gravity-referenced phone measurement.""" + + schema_version: int + mount_tilt_deg: float + roll_deg: float + gravity_x_g: float + gravity_y_g: float + gravity_z_g: float + tilt_stddev_deg: float + roll_stddev_deg: float + sample_count: int + measured_at: str + device_model: str + + @classmethod + def from_payload(cls, payload: Any) -> "PhoneOrientationMeasurement": + """Validate a client payload and recompute its angles from gravity.""" + if not isinstance(payload, dict): + raise PhoneOrientationValidationError("JSON body must be an object") + if payload.get("schema_version") != 1: + raise PhoneOrientationValidationError("schema_version must be 1") + + sample_count = payload.get("sample_count") + if isinstance(sample_count, bool) or not isinstance(sample_count, int): + raise PhoneOrientationValidationError("sample_count must be an integer") + if sample_count < 30: + raise PhoneOrientationValidationError("at least 30 samples are required") + + tilt_stddev = _finite_number(payload, "tilt_stddev_deg") + roll_stddev = _finite_number(payload, "roll_stddev_deg") + if not 0.0 <= tilt_stddev <= 0.5 or not 0.0 <= roll_stddev <= 0.5: + raise PhoneOrientationValidationError( + "phone must remain stable (angle standard deviation must be at most 0.5 degrees)" + ) + + gravity_x = _finite_number(payload, "gravity_x_g") + gravity_y = _finite_number(payload, "gravity_y_g") + gravity_z = _finite_number(payload, "gravity_z_g") + gravity_norm = math.sqrt( + gravity_x * gravity_x + gravity_y * gravity_y + gravity_z * gravity_z + ) + if not 0.9 <= gravity_norm <= 1.1: + raise PhoneOrientationValidationError("gravity vector must be between 0.9g and 1.1g") + + recomputed_tilt = math.degrees(math.asin(max(-1.0, min(1.0, -gravity_z / gravity_norm)))) + recomputed_roll = math.degrees(math.atan2(gravity_x, -gravity_y)) + submitted_tilt = _finite_number(payload, "mount_tilt_deg") + submitted_roll = _finite_number(payload, "roll_deg") + if abs(submitted_tilt - recomputed_tilt) > 0.25: + raise PhoneOrientationValidationError( + "mount_tilt_deg does not match the transmitted gravity vector" + ) + if abs(submitted_roll - recomputed_roll) > 0.25: + raise PhoneOrientationValidationError( + "roll_deg does not match the transmitted gravity vector" + ) + if not -30.0 <= recomputed_tilt <= 45.0: + raise PhoneOrientationValidationError("mount tilt must be between -30 and 45 degrees") + if abs(recomputed_roll) > 3.0: + raise PhoneOrientationValidationError( + "level the radar left-to-right within 3 degrees before calibrating" + ) + + measured_at = payload.get("measured_at") + if not isinstance(measured_at, str) or not measured_at.strip(): + raise PhoneOrientationValidationError("measured_at must be an ISO-8601 timestamp") + device_model = payload.get("device_model", "iPhone") + if not isinstance(device_model, str): + raise PhoneOrientationValidationError("device_model must be a string") + + return cls( + schema_version=1, + mount_tilt_deg=recomputed_tilt, + roll_deg=recomputed_roll, + gravity_x_g=gravity_x, + gravity_y_g=gravity_y, + gravity_z_g=gravity_z, + tilt_stddev_deg=tilt_stddev, + roll_stddev_deg=roll_stddev, + sample_count=sample_count, + measured_at=measured_at.strip()[:64], + device_model=device_model.strip()[:80] or "iPhone", + ) + + def to_dict(self) -> dict: + """Return a rounded JSON-safe representation.""" + data = asdict(self) + for key in ( + "mount_tilt_deg", + "roll_deg", + "tilt_stddev_deg", + "roll_stddev_deg", + ): + data[key] = round(data[key], 4) + for key in ("gravity_x_g", "gravity_y_g", "gravity_z_g"): + data[key] = round(data[key], 6) + return data + + +def save_phone_orientation_calibration(record: dict, path: Path) -> None: + """Atomically persist a validated applied-orientation record.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + json.dump(record, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + temporary_path = Path(handle.name) + os.replace(temporary_path, path) + finally: + if temporary_path is not None and temporary_path.exists(): + temporary_path.unlink() + + +def load_phone_orientation_calibration(path: Path) -> dict | None: + """Load and validate the persisted applied-orientation record.""" + path = Path(path) + if not path.exists(): + return None + with path.open(encoding="utf-8") as handle: + record = json.load(handle) + if not isinstance(record, dict) or record.get("schema_version") != 1: + raise PhoneOrientationValidationError("saved calibration schema_version must be 1") + configured_tilt = _finite_number(record, "configured_iwr_tilt_deg") + if not -45.0 <= configured_tilt <= 45.0: + raise PhoneOrientationValidationError("saved configured IWR tilt is out of range") + measurement = PhoneOrientationMeasurement.from_payload(record.get("measurement")) + normalized = dict(record) + normalized["configured_iwr_tilt_deg"] = configured_tilt + normalized["measurement"] = measurement.to_dict() + return normalized + + +__all__ = [ + "PhoneOrientationMeasurement", + "PhoneOrientationValidationError", + "load_phone_orientation_calibration", + "save_phone_orientation_calibration", +] diff --git a/src/openflight/power/factory.py b/src/openflight/power/factory.py index 3526eb514..e03ae344f 100644 --- a/src/openflight/power/factory.py +++ b/src/openflight/power/factory.py @@ -8,6 +8,7 @@ from .providers.geekworm import GeekwormPowerReader from .providers.linux import LinuxPowerReader +from .providers.mock import MockPowerReader from .reader import PowerReader @@ -15,6 +16,8 @@ class BatteryProvider(str, Enum): """Battery hardware integrations supported by OpenFlight.""" GEEKWORM = "geekworm" + # A simulated battery for testing the UI and phone apps without a UPS. + MOCK = "mock" SUPPORTED_BATTERY_PROVIDERS = tuple(provider.value for provider in BatteryProvider) @@ -39,6 +42,9 @@ def create_power_reader( ) -> PowerReader: """Prefer standard Linux telemetry and fall back to the selected provider.""" normalized = normalize_battery_provider(provider) + if normalized is BatteryProvider.MOCK: + # Never let a real battery (e.g. a laptop's) stand in for the simulation. + return provider_factory() if provider_factory is not None else MockPowerReader() try: return LinuxPowerReader(power_supply_path=power_supply_path) except OSError: diff --git a/src/openflight/power/providers/__init__.py b/src/openflight/power/providers/__init__.py index 592e582a6..044f6b19b 100644 --- a/src/openflight/power/providers/__init__.py +++ b/src/openflight/power/providers/__init__.py @@ -2,5 +2,6 @@ from .geekworm import GeekwormPowerReader from .linux import LinuxPowerReader +from .mock import MockPowerReader -__all__ = ["GeekwormPowerReader", "LinuxPowerReader"] +__all__ = ["GeekwormPowerReader", "LinuxPowerReader", "MockPowerReader"] diff --git a/src/openflight/power/providers/mock.py b/src/openflight/power/providers/mock.py new file mode 100644 index 000000000..7c8d20b32 --- /dev/null +++ b/src/openflight/power/providers/mock.py @@ -0,0 +1,74 @@ +"""A simulated battery for exercising power status without UPS hardware.""" + +from __future__ import annotations + +from ..models import PowerSample + +# Li-ion cell voltage mapped linearly from empty to full; only needs to look +# plausible to the UI and phones. +_EMPTY_VOLTAGE_V = 3.3 +_FULL_VOLTAGE_V = 4.2 + + +class MockPowerReader: + """Cycle through a deterministic discharge and charge, one step per read. + + Starting full on battery, each read drops ``discharge_step`` percent down + to ``floor_percent``, passing the low (<=20%) and critical (<=10%) states; + then external power comes on and each read adds ``charge_step`` percent back + to 100%. The cycle then repeats. At the default 5 s poll it takes about + 100 s, so every power state is seen within two minutes. + """ + + def __init__( + self, + *, + discharge_step: float = 8.0, + charge_step: float = 12.0, + floor_percent: float = 5.0, + ): + if discharge_step <= 0 or charge_step <= 0: + raise ValueError("Mock battery steps must be positive") + if not 0.0 < floor_percent < 100.0: + raise ValueError("Mock battery floor must be between 0 and 100 percent") + self._cycle = self._build_cycle(discharge_step, charge_step, floor_percent) + self._index = 0 + + @property + def cycle_length(self) -> int: + """Number of reads before the simulated cycle repeats.""" + return len(self._cycle) + + @staticmethod + def _build_cycle( + discharge_step: float, charge_step: float, floor_percent: float + ) -> list[PowerSample]: + levels: list[tuple[float, bool]] = [] + percent = 100.0 + while percent > floor_percent: + levels.append((percent, False)) + percent -= discharge_step + levels.append((floor_percent, False)) + percent = floor_percent + while percent < 100.0: + percent = min(100.0, percent + charge_step) + levels.append((percent, True)) + return [ + PowerSample( + battery_percent=round(level, 1), + battery_voltage_v=round( + _EMPTY_VOLTAGE_V + (_FULL_VOLTAGE_V - _EMPTY_VOLTAGE_V) * level / 100.0, 3 + ), + external_power=plugged_in, + ) + for level, plugged_in in levels + ] + + def read(self) -> PowerSample: + """Return the next simulated sample.""" + sample = self._cycle[self._index] + self._index = (self._index + 1) % len(self._cycle) + return sample + + def close(self) -> None: + """The simulation holds no resources.""" diff --git a/src/openflight/server.py b/src/openflight/server.py index 0b6aefd06..7af767b53 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -25,6 +25,16 @@ from flask_socketio import SocketIO from .ballistics import resolve_launch, simulate +from .ble.protocol import ( + build_club_event_v2, + build_power_status_event, + build_profiles_event, + build_session_cleared_event, + build_shot_deleted_event, + build_shot_processing_event, + encode_shot_event_v2, + stable_shot_event_id, +) from .clubs import ClubType from .clubs.physics import ( SHOT_SIMULATION_DEFAULTS, @@ -39,10 +49,18 @@ SpeedReading, set_show_raw_readings, ) +from .phone_catch_up import PhoneShotCache, normalize_last_event_id, select_catch_up +from .phone_orientation import ( + PhoneOrientationMeasurement, + PhoneOrientationValidationError, + load_phone_orientation_calibration, + save_phone_orientation_calibration, +) from .power import SUPPORTED_BATTERY_PROVIDERS, PowerMonitor, PowerStatus from .profiles import ProfileStore from .rolling_buffer.monitor import estimate_carry_with_spin, get_optimal_spin_for_ball_speed from .session_logger import get_session_logger, init_session_logger, log_session_error +from .shot_stream import SSE_MIMETYPE, ShotStreamBroker, ShotStreamFull from .sim import ( IncompleteShotError, PlayerState as SimPlayerState, @@ -135,6 +153,10 @@ def get_profile_store() -> ProfileStore: camera_replay_manager = None camera_reference_ball_tracker = None camera_ball_flight_reference_tracker = None +PHONE_ORIENTATION_CALIBRATION_PATH = ( + Path.home() / ".config" / "openflight" / "iwr6843_phone_orientation.json" +) +_iwr6843_calibration_lock = threading.Lock() # Optional LIS3DH enclosure orientation used to compensate TI mount tilt. inclinometer_service = None @@ -157,6 +179,24 @@ 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() + +# Network shot delivery for phone apps. Always available: it exposes the same +# shots the browser UI already broadcasts over WebSocket, so it adds no reach +# beyond the existing HTTP server. +shot_stream = ShotStreamBroker() + +# The exact v2 bytes last sent per shot, so a reconnecting phone's catch-up +# (BLE and network alike) replays what it would have received live. +phone_shot_cache = PhoneShotCache() + shutdown_lock = threading.Lock() shutdown_cleanup_started = False # One active hardware job plus two waiting shots is enough for normal golf @@ -191,6 +231,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 +376,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 +465,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 +1004,226 @@ def display(): return send_from_directory(_react_app_dir(), "index.html") +@app.route("/api/calibration/iwr6843/orientation", methods=["GET", "POST"]) +def api_iwr6843_orientation_calibration(): + """Read or apply a gravity-referenced phone measurement to TI mount tilt.""" + if iwr6843_runtime is None: + return {"error": "TI IWR6843 radar is not enabled"}, 409 + + if request.method == "GET": + return { + "status": "ready", + "configured_iwr_tilt_deg": round(math.degrees(iwr6843_runtime.calibration.tilt_rad), 4), + "azimuth_offset_deg": round(iwr6843_runtime.azimuth_offset_deg, 4), + "calibration": iwr6843_runtime_config.get("phone_orientation_calibration"), + } + + return apply_iwr6843_orientation_calibration(request.get_json(silent=True)) + + +def apply_iwr6843_orientation_calibration(payload): + """Validate, persist, and activate one phone orientation measurement.""" + if iwr6843_runtime is None: + return {"error": "TI IWR6843 radar is not enabled"}, 409 + + try: + measurement = PhoneOrientationMeasurement.from_payload(payload) + except PhoneOrientationValidationError as error: + return {"error": str(error)}, 400 + + enclosure_pitch_deg = None + if inclinometer_service is not None: + try: + selection = inclinometer_service.wait_for_stable(timeout_s=2.0) + except Exception as error: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Enclosure sensor failed during phone calibration: %s", error) + return {"error": "Could not read the enclosure sensor; try again"}, 409 + if selection.snapshot is None: + return { + "error": ( + "The enclosure sensor is not stable " + f"({selection.status}); keep the rig still and try again" + ) + }, 409 + enclosure_pitch_deg = float(selection.snapshot.calibrated_pitch_deg) + + configured_tilt_deg = measurement.mount_tilt_deg - (enclosure_pitch_deg or 0.0) + if not -45.0 <= configured_tilt_deg <= 45.0: + return {"error": "Derived TI-to-enclosure tilt is outside the supported range"}, 400 + + record = { + "schema_version": 1, + "source": "ios_companion", + "configured_iwr_tilt_deg": configured_tilt_deg, + "enclosure_pitch_deg": enclosure_pitch_deg, + "azimuth_offset_deg": iwr6843_runtime.azimuth_offset_deg, + "measurement": measurement.to_dict(), + "applied_at": datetime.now().astimezone().isoformat(), + } + + try: + with _iwr6843_calibration_lock: + save_phone_orientation_calibration(record, PHONE_ORIENTATION_CALIBRATION_PATH) + calibration_meta = dict(iwr6843_runtime.calibration.meta) + calibration_meta["phone_orientation_calibration"] = record + iwr6843_runtime.calibration = replace( + iwr6843_runtime.calibration, + tilt_rad=math.radians(configured_tilt_deg), + meta=calibration_meta, + ) + iwr6843_runtime_config.update( + { + "tilt_deg": configured_tilt_deg, + "tilt_source": "ios_companion", + "phone_orientation_calibration": record, + } + ) + except OSError as error: + logger.warning("[SERVER] Failed to persist phone orientation: %s", error, exc_info=True) + return {"error": "OpenFlight could not save the calibration"}, 500 + + session_logger = get_session_logger() + if session_logger: + session_logger.log_config_change( + {"iwr6843": dict(iwr6843_runtime_config)}, + source="ios_companion", + ) + response = { + "status": "applied", + "persistent": True, + "measured_mount_tilt_deg": measurement.mount_tilt_deg, + "enclosure_pitch_deg": enclosure_pitch_deg, + "configured_iwr_tilt_deg": configured_tilt_deg, + "roll_deg": measurement.roll_deg, + "azimuth_offset_deg": iwr6843_runtime.azimuth_offset_deg, + } + socketio.emit("iwr6843_orientation_calibrated", response) + logger.info( + "[SERVER] Applied iOS phone calibration: measured tilt %.3fdeg, " + "enclosure pitch %s, configured TI tilt %.3fdeg", + measurement.mount_tilt_deg, + f"{enclosure_pitch_deg:.3f}deg" if enclosure_pitch_deg is not None else "not enabled", + configured_tilt_deg, + ) + return response, 200 + + +def apply_club_selection(payload): + """Set the club used to tag and process future shots.""" + global active_club # pylint: disable=global-statement + if not isinstance(payload, dict): + return {"error": "Club selection must be a JSON object"}, 400 + club_name = payload.get("club") + try: + club = ClubType(club_name) + except (TypeError, ValueError): + valid = ", ".join(item.value for item in ClubType if item is not ClubType.UNKNOWN) + return {"error": f"Unknown club; choose one of: {valid}"}, 400 + if club is ClubType.UNKNOWN: + return {"error": "Unknown is not a selectable club"}, 400 + + with club_selection_lock: + # Without a monitor (startup, or tests) the selection is still recorded + # and broadcast, as the Socket.IO handler always did; the monitor picks + # up later changes once it exists. + if monitor is not None: + try: + monitor.set_club(club) + except Exception: # pylint: disable=broad-exception-caught + logger.exception("[SERVER] Failed to set club to %s", club.value) + return {"error": "OpenFlight could not change the club"}, 500 + active_club = club + response = {"status": "applied", "club": club.value} + _broadcast_club_selection(club) + logger.info("[SERVER] Club changed to %s", club.value) + return response, 200 + + +def current_club_selection(_payload=None): + """Return the Pi-owned club without changing monitor state.""" + with club_selection_lock: + club_value = active_club.value + return {"status": "current", "club": club_value}, 200 + + +def _broadcast_club_selection(club: ClubType) -> None: + """Fan one authoritative club update out over every active transport.""" + club_data = {"club": club.value} + try: + socketio.emit("club_changed", club_data) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to broadcast club over WebSocket", exc_info=True) + try: + shot_stream.publish_club(club.value) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to broadcast club over network stream", exc_info=True) + if ble_publisher is not None: + try: + ble_publisher.publish_club(club.value) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to broadcast club over BLE", exc_info=True) + + +def dispatch_phone_control_command(command_type, payload): + """Route a version-one BLE phone command to the shared server operation.""" + handlers = { + "iwr6843_orientation_calibration": apply_iwr6843_orientation_calibration, + "set_club": apply_club_selection, + "get_club": current_club_selection, + } + handler = handlers.get(command_type) + if handler is None: + return {"error": f"Unsupported phone command: {command_type}"}, 400 + return handler(payload) + + +def dispatch_phone_control_command_v2(command_type, payload): + """Route a schema v2 BLE phone command through the Socket.IO operations. + + Each command calls the same function its Socket.IO counterpart does, so the + kiosk and every other client see identical broadcasts. + + BLE has no authentication, so it is read-and-select only: profile add, + rename and remove, ``clear_session`` and ``delete_shot`` stay on + Socket.IO. Phones still hear about those changes through the + ``profiles``, ``session_cleared`` and ``shot_deleted`` events. + """ + handlers = { + "iwr6843_orientation_calibration": apply_iwr6843_orientation_calibration, + "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) + + +def _phone_state_events_v2() -> list[dict]: + """Current club, profiles and power, as seeded to a new schema v2 SSE client.""" + with club_selection_lock: + club_value = active_club.value + events = [build_club_event_v2(club_value)] + try: + events.append(build_profiles_event(get_profile_store().snapshot())) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Could not read profiles for the shot stream", exc_info=True) + if power_monitor is not None and power_monitor.status is not None: + events.append(build_power_status_event(power_monitor.status.to_dict())) + return events + + +@app.route("/api/club", methods=["GET", "POST"]) +def api_club_selection(): + """Read or set the active club over the network (HTTP).""" + if request.method == "GET": + return current_club_selection() + return apply_club_selection(request.get_json(silent=True)) + + @app.route("/") def static_files(path): """Serve static files.""" @@ -1078,6 +1345,24 @@ def init_camera_capture( return False +def _resolve_iwr_mount_tilt( + calibration_tilt_deg: float, + *, + explicit_tilt_deg: float | None, +) -> tuple[float, str]: + """Resolve TI tilt with explicit CLI values taking highest precedence.""" + if explicit_tilt_deg is not None: + return float(explicit_tilt_deg), "command_line" + try: + saved = load_phone_orientation_calibration(PHONE_ORIENTATION_CALIBRATION_PATH) + except (OSError, json.JSONDecodeError, PhoneOrientationValidationError) as error: + logger.warning("[SERVER] Ignoring invalid saved phone calibration: %s", error) + return float(calibration_tilt_deg), "calibration_file" + if saved is not None: + return float(saved["configured_iwr_tilt_deg"]), "ios_companion" + return float(calibration_tilt_deg), "calibration_file" + + def init_iwr6843( *, port: str | None, @@ -1114,8 +1399,11 @@ def init_iwr6843( calibration = Calibration.load(calibration_path) calibration.tee_range_m = tee_range_m calibration.tee_ball_height_m = ball_height_m - if tilt_deg is not None: - calibration.tilt_rad = math.radians(tilt_deg) + resolved_tilt_deg, tilt_source = _resolve_iwr_mount_tilt( + math.degrees(calibration.tilt_rad), + explicit_tilt_deg=tilt_deg, + ) + calibration.tilt_rad = math.radians(resolved_tilt_deg) if radar_height_m is not None: calibration.meta["radar_height_m"] = radar_height_m @@ -1159,6 +1447,7 @@ def init_iwr6843( "tx_order": resolved_order, "tdm_sign_policy": iwr6843_runtime.tdm_sign_policy, "tilt_deg": math.degrees(calibration.tilt_rad), + "tilt_source": tilt_source, "radar_height_m": calibration.radar_height_m, "ball_height_m": calibration.tee_ball_height_m, "azimuth_offset_deg": azimuth_offset_deg, @@ -1471,6 +1760,51 @@ def handle_get_camera_capture_settings(): socketio.emit("camera_capture_settings", _camera_capture_settings_payload()) +def _stream_catch_up_v2(last_event_id) -> list[tuple[str, bytes]] | None: + """Catch-up for a v2 stream client, or ``None`` (latest-shot replay) on failure.""" + try: + return phone_catch_up_v2(last_event_id) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Could not load missed shots for the stream", exc_info=True) + return None + + +@app.route("/api/shots/stream") +def shots_stream(): + """Stream completed shots to phones as Server-Sent Events. + + ``?schema=2`` opts into schema v2 events; the default stays version one. + A v2 client resumes with ``Last-Event-ID`` (or ``?last_event_id=``) and is + seeded with the session shots it missed, as BLE ``hello`` does. + """ + schema_arg = request.args.get("schema", "1") + if schema_arg not in ("1", "2"): + return {"error": "Unsupported schema; use 1 or 2"}, 400 + try: + if schema_arg == "2": + last_event_id = request.headers.get("Last-Event-ID") or request.args.get( + "last_event_id" + ) + subscriber = shot_stream.subscribe( + schema=2, + initial_events=_phone_state_events_v2(), + catch_up=_stream_catch_up_v2(last_event_id), + ) + else: + subscriber = shot_stream.subscribe() + except ShotStreamFull as exc: + logger.warning("[SERVER] Refused shot stream client: %s", exc) + return str(exc), 503 + + response = Response(shot_stream.frames(subscriber), mimetype=SSE_MIMETYPE) + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Accel-Buffering"] = "no" + # Covers the case where the response is discarded without ever being + # iterated; unsubscribing twice is a no-op. + response.call_on_close(lambda: shot_stream.unsubscribe(subscriber)) + return response + + @socketio.on("set_camera_capture_settings") def handle_set_camera_capture_settings(data): """Apply live-safe camera controls and alignment-guide position.""" @@ -1751,8 +2085,20 @@ def _emit_sim_snapshot() -> None: def _on_power_status(status: PowerStatus) -> None: - """Publish one battery reading to connected UI clients.""" - socketio.emit("power_status", status.to_dict()) + """Publish one battery reading to connected UI clients and v2 phones.""" + payload = status.to_dict() + socketio.emit("power_status", payload) + _publish_phone_event(build_power_status_event(payload)) + + +def current_power_status(_payload=None): + """Return the latest battery reading, as ``power_status`` carries it.""" + if power_monitor is None: + return {"error": "Battery monitoring is not enabled"}, 409 + status = power_monitor.status + if status is None: + return {"error": "No battery reading yet"}, 409 + return status.to_dict(), 200 def _log_power_status(status: PowerStatus) -> None: @@ -1782,6 +2128,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 +2149,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 +2163,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 +2289,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,23 +2321,35 @@ 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") -def handle_simulate_shot(): - """Simulate a shot (only works in mock mode).""" - if monitor and isinstance(monitor, (MockLaunchMonitor, MockSwingSpeedMonitor)): +def handle_simulate_shot(data=None): + """Simulate a shot (only works in mock mode). + + ``{"fail": true}`` simulates a capture the radar could not process. + """ + if isinstance(monitor, MockLaunchMonitor): + monitor.simulate_shot(fail=bool(_payload_dict(data).get("fail"))) + elif isinstance(monitor, MockSwingSpeedMonitor): monitor.simulate_shot() @@ -2111,6 +2491,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 +2607,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 +2621,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}) @@ -2860,6 +3250,8 @@ def _attach_camera_replay(shot: Shot, camera_capture) -> None: def _enrich_shot_from_optional_hardware(shot: Shot) -> _ShotEnrichmentResult: """Mutate a shot with available radar/camera measurements and timings.""" + if shot.mode == "mock" and _mock_enrichment_enabled(): + return _ShotEnrichmentResult(iwr6843_ms=monitor.enrich(shot)) # Snapshot orientation before IWR capture can block, and select only data # timestamped before impact so impact vibration cannot bias the geometry. @@ -3254,6 +3646,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 +3670,30 @@ def _finalize_shot_detected( context={"stage": f"emit_{emit_event}", "ball_speed_mph": shot.ball_speed_mph}, exc=e, ) - return + + # Bluetooth transport is deliberately independent of WebSocket delivery. + if shot_data is not None and ble_publisher is not None: + try: + ble_publisher.publish(shot_data) + except Exception as e: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to queue BLE shot: %s", e, exc_info=True) + + # The network transport is likewise independent; a stalled client cannot affect + # shot recording or the browser UI. + if shot_data is not None: + try: + shot_stream.publish(shot_data) + except Exception as e: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to queue streamed shot: %s", e, exc_info=True) + + # Schema v2 phones get the final shot too, marked final and carrying the + # event_id of any provisional shot published for it. + if shot_data is not None: + _publish_phone_shot_v2( + shot_data, + final=True, + enrichment=_final_phone_enrichment(emit_event, enrichment), + ) # Forward to simulator connectors (optional) _forward_shot_to_simulators(shot) @@ -3385,6 +3801,83 @@ def _queue_ordered_shot_finalization( _shot_finalization_condition.notify_all() +def _final_phone_enrichment( + emit_event: str, + enrichment: _ShotEnrichmentResult, +) -> dict | None: + """Describe optional-hardware progress on a final v2 shot. + + Only shots that were published provisionally (``emit_event`` is + ``shot_update``) carry an ``enrichment`` object; the rest never waited. + """ + if emit_event != "shot_update": + return None + if enrichment.skipped_reason: + return {"status": "skipped", "reason": enrichment.skipped_reason} + return {"status": "complete"} + + +def _publish_phone_shot_v2( + shot_data: dict, + *, + final: bool, + enrichment: dict | None, +) -> None: + """Hand one v2 shot to the BLE and SSE phone transports; never raises.""" + try: + phone_shot_cache.remember( + stable_shot_event_id(shot_data), + encode_shot_event_v2(shot_data, final=final, enrichment=enrichment), + ) + except (KeyError, TypeError, ValueError): + logger.warning("[SERVER] Could not cache v2 shot for phone catch-up", exc_info=True) + transports = [("network stream", shot_stream)] + if ble_publisher is not None: + transports.append(("BLE", ble_publisher)) + for name, transport in transports: + try: + transport.publish_v2_shot(shot_data, final=final, enrichment=enrichment) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to queue v2 shot over %s", name, exc_info=True) + + +def phone_catch_up_v2(last_event_id=None) -> list[tuple[str, bytes]]: + """The current-session v2 shots a reconnecting phone missed, oldest first. + + Shared by BLE ``hello`` and the network stream's ``Last-Event-ID``; see + ``openflight.phone_catch_up`` for the rule. The session decides which + shots exist, so cleared and deleted shots are never replayed. A shot is + replayed as the bytes last published for it, or rebuilt as a final shot + when that is no longer cached. + """ + session = monitor + if session is None or not hasattr(session, "get_shots"): + return [] + entries = [] + for shot in session.get_shots(): + try: + shot_data = shot_to_dict(shot) + event_id = stable_shot_event_id(shot_data) + payload = phone_shot_cache.get(event_id) or encode_shot_event_v2(shot_data, final=True) + except (AttributeError, KeyError, TypeError, ValueError): + logger.warning("[SERVER] Skipped a shot that could not be replayed", exc_info=True) + continue + entries.append((event_id, payload)) + return select_catch_up(entries, normalize_last_event_id(last_event_id)) + + +def _publish_phone_event(event: dict) -> None: + """Hand one schema v2 event to the BLE and SSE phone transports; never raises.""" + transports = [("network stream", shot_stream)] + if ble_publisher is not None: + transports.append(("BLE", ble_publisher)) + for name, transport in transports: + try: + transport.publish_event_v2(event) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[SERVER] Failed to queue v2 event over %s", name, exc_info=True) + + def _emit_initial_ops_shot(shot: Shot) -> bool: """Publish immediately available OPS metrics before slow enrichments.""" try: @@ -3403,6 +3896,9 @@ def _emit_initial_ops_shot(shot: Shot) -> bool: "pending": pending, }, ) + # Schema v2 phones get the same provisional shot. The final one follows + # from _finalize_shot_detected with the same event_id. + _publish_phone_shot_v2(shot_data, final=False, enrichment={"status": "pending"}) return True except Exception as error: # pylint: disable=broad-exception-caught logger.error("[SERVER] Failed to emit initial OPS shot: %s", error, exc_info=True) @@ -3444,11 +3940,16 @@ def _emit_ops_enrichment_skipped(shot: Shot, *, reason: str) -> None: ) +def _mock_enrichment_enabled() -> bool: + """Whether the mock monitor simulates optional hardware (``--mock-enrichment-ms``).""" + return getattr(monitor, "enrichment_ms", 0) > 0 and hasattr(monitor, "enrich") + + def _has_slow_shot_enrichment(shot: Shot) -> bool: """Whether optional hardware can add seconds to this shot callback.""" - return shot.mode != "mock" and ( - iwr6843_runtime is not None or camera_capture_runtime is not None - ) + if shot.mode == "mock": + return _mock_enrichment_enabled() + return iwr6843_runtime is not None or camera_capture_runtime is not None def _drain_shot_enrichment_queue() -> None: @@ -3569,6 +4070,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 +4084,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"), ) @@ -3685,6 +4188,7 @@ def start_monitor( swing_speed_mode: bool = False, swing_speed_kwargs: Optional[dict] = None, ops_baud: Optional[int] = None, + mock_enrichment_ms: float = 0.0, ): """ Start the monitor in launch monitor or swing speed mode. @@ -3695,6 +4199,8 @@ def start_monitor( trigger_type: Trigger strategy (sound or speed) debug: Enable verbose debug output ops_baud: Target UART baud when the OPS243 is on the GPIO header + mock_enrichment_ms: In mock mode, simulate optional hardware taking this + long, so shots go provisional then final (0 disables) """ global monitor, mock_mode, mock_swing_speed_mode, debug_mode, radar_config @@ -3711,7 +4217,7 @@ def start_monitor( print("[MODE] Mock swing speed training mode") elif mock: # Mock mode for testing without radar - monitor = MockLaunchMonitor() + monitor = MockLaunchMonitor(enrichment_ms=mock_enrichment_ms) elif swing_speed_mode: from .swing_speed import SwingSpeedMonitor @@ -3827,7 +4333,11 @@ def on_trigger_diagnostic(data: dict): if iwr6843_runtime is not None: iwr6843_runtime.capture_monitor.arm() else: - monitor.start(shot_callback=on_shot_detected, live_callback=on_live_reading) + monitor.start( + shot_callback=on_shot_detected, + live_callback=on_live_reading, + processing_callback=on_shot_processing, + ) def _cloud_raw_uploads_enabled() -> bool: @@ -3934,13 +4444,26 @@ def stop_monitor(): class MockLaunchMonitor: - """Mock launch monitor for UI development without radar hardware.""" + """Mock launch monitor for UI development without radar hardware. + + Like the rolling-buffer radar it reports ``capturing`` then ``calculating`` + to ``processing_callback`` (and ``failed`` for ``simulate_shot(fail=True)``). + With ``enrichment_ms`` it also stands in for IWR6843/camera hardware: shots + are recorded without horizontal launch, club path and spin axis, and + ``enrich`` supplies them after that delay, so mock shots go provisional then + final through the real enrichment pipeline. + """ - def __init__(self): + def __init__(self, *, enrichment_ms: float = 0.0, processing_step_s: float = 0.15): """Initialize mock monitor.""" + if enrichment_ms < 0: + raise ValueError("Mock enrichment delay must not be negative") + self.enrichment_ms = enrichment_ms + self.processing_step_s = processing_step_s self._shots: List[Shot] = [] self._running = False self._shot_callback = None + self._processing_callback = None self._current_club = ClubType.DRIVER def connect(self): @@ -3951,9 +4474,10 @@ def disconnect(self): """Disconnect from mock radar.""" self.stop() - def start(self, shot_callback=None, live_callback=None): # pylint: disable=unused-argument + def start(self, shot_callback=None, live_callback=None, processing_callback=None): # pylint: disable=unused-argument """Start mock monitoring.""" self._shot_callback = shot_callback + self._processing_callback = processing_callback self._running = True print("Mock monitor started - simulate shots via WebSocket") @@ -3961,8 +4485,64 @@ def stop(self): """Stop mock monitoring.""" self._running = False - def simulate_shot(self, ball_speed: float = None): - """Simulate a shot for testing using realistic TrackMan-based values.""" + def _notify_processing(self, state: str, *, pause: bool = True) -> None: + """Report a processing state; UI errors must not break the simulated shot.""" + if self._processing_callback is None: + return + try: + self._processing_callback(state) + except Exception: # pylint: disable=broad-exception-caught + logger.warning("[MOCK] Processing callback failed", exc_info=True) + if pause and self.processing_step_s > 0: + time.sleep(self.processing_step_s) + + @staticmethod + def _simulated_direction(confidence: float) -> dict: + """Horizontal launch, club path and spin axis: what IWR6843/camera measure.""" + defaults = SHOT_SIMULATION_DEFAULTS + launch_h = random.gauss(0, defaults.horizontal_launch_std_dev_deg) + return { + "launch_angle_horizontal": round(launch_h, 1), + "launch_angle_horizontal_confidence": confidence, + "launch_angle_horizontal_source": "mock", + "club_path_deg": round( + random.uniform(-defaults.club_path_max_abs_deg, defaults.club_path_max_abs_deg), + 1, + ), + "spin_axis_deg": round( + launch_h + - random.uniform( + -defaults.spin_axis_error_max_abs_deg, + defaults.spin_axis_error_max_abs_deg, + ), + 1, + ), + } + + def enrich(self, shot: Shot) -> float: + """Simulate optional hardware: wait ``enrichment_ms``, then add direction. + + Returns the elapsed milliseconds, as the hardware timings are reported. + """ + started = time.monotonic() + time.sleep(self.enrichment_ms / 1000.0) + confidence = shot.launch_angle_confidence or SHOT_SIMULATION_DEFAULTS.confidence_min + for field, value in self._simulated_direction(confidence).items(): + setattr(shot, field, value) + return (time.monotonic() - started) * 1000.0 + + def simulate_shot(self, ball_speed: float = None, *, fail: bool = False): + """Simulate a shot for testing using realistic TrackMan-based values. + + ``fail`` simulates a capture the radar could not process: ``failed`` is + reported and no shot is recorded (returns ``None``). + """ + self._notify_processing("capturing") + self._notify_processing("calculating", pause=not fail) + if fail: + self._notify_processing("failed", pause=False) + return None + physics = get_club_physics(self._current_club) profile = get_club_simulation_profile(self._current_club) defaults = SHOT_SIMULATION_DEFAULTS @@ -3993,10 +4573,15 @@ def simulate_shot(self, ball_speed: float = None): defaults.min_launch_deg, random.gauss(physics.optimal_launch_deg, profile.launch_std_dev_deg), ) - launch_h = random.gauss(0, defaults.horizontal_launch_std_dev_deg) launch_confidence = round( random.uniform(defaults.confidence_min, defaults.confidence_max), 2 ) + # With simulated enrichment these arrive later, as from IWR6843/camera. + direction = ( + {field: None for field in self._simulated_direction(launch_confidence)} + if self.enrichment_ms > 0 + else self._simulated_direction(launch_confidence) + ) club_aoa = round( random.gauss( @@ -4014,30 +4599,13 @@ def simulate_shot(self, ball_speed: float = None): spin_rpm=spin_rpm, spin_confidence=random.choice(defaults.spin_confidence_choices), launch_angle_vertical=round(launch_v, 1), - launch_angle_horizontal=round(launch_h, 1), launch_angle_confidence=launch_confidence, launch_angle_vertical_confidence=launch_confidence, - launch_angle_horizontal_confidence=launch_confidence, launch_angle_vertical_source="mock", - launch_angle_horizontal_source="mock", angle_source="mock", club_angle_deg=club_aoa, - club_path_deg=round( - random.uniform( - -defaults.club_path_max_abs_deg, - defaults.club_path_max_abs_deg, - ), - 1, - ), - spin_axis_deg=round( - launch_h - - random.uniform( - -defaults.spin_axis_error_max_abs_deg, - defaults.spin_axis_error_max_abs_deg, - ), - 1, - ), mode="mock", + **direction, ) self._shots.append(shot) @@ -4271,6 +4839,16 @@ def main(): action="store_true", help="Run swing speed training mode with simulated reps and no OPS radar", ) + parser.add_argument( + "--mock-enrichment-ms", + type=float, + default=0.0, + metavar="MS", + help=( + "With --mock, simulate IWR6843/camera enrichment taking MS milliseconds: " + "shots arrive provisional, then final with direction data (default: off)" + ), + ) parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)") parser.add_argument( "--web-port", type=int, default=8080, help="Web server port (default: 8080)" @@ -4388,6 +4966,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"], @@ -4673,6 +5256,10 @@ def main(): # launch angle), so require it whenever the K-LD7 radars are enabled. if args.kld7 and args.kld7_mount_tilt is None: parser.error("--kld7-mount-tilt is required when --kld7 is passed") + if args.mock_enrichment_ms < 0: + parser.error("--mock-enrichment-ms must not be negative") + if args.mock_enrichment_ms > 0 and (not args.mock or args.mock_swing_speed): + parser.error("--mock-enrichment-ms requires --mock (launch monitor mode)") if args.mock_swing_speed: args.mock = True args.swing_speed = True @@ -4969,6 +5556,7 @@ def main(): swing_speed_mode=args.swing_speed, swing_speed_kwargs=swing_speed_kwargs, ops_baud=args.ops_baud, + mock_enrichment_ms=args.mock_enrichment_ms, ) except Exception: monitor_recovery = ( @@ -4991,6 +5579,18 @@ 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, + command_handler_v2=dispatch_phone_control_command_v2, + catch_up_provider=phone_catch_up_v2, + ) + ble_publisher.start() + print("Bluetooth LE enabled (advertising as OpenFlight)") + # 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/src/openflight/shot_stream.py b/src/openflight/shot_stream.py new file mode 100644 index 000000000..8b494430f --- /dev/null +++ b/src/openflight/shot_stream.py @@ -0,0 +1,283 @@ +"""Fan out completed shots to HTTP clients as Server-Sent Events. + +This is the network sibling of the BLE publisher: same versioned payloads, same +bounded-queue delivery policy, same isolation from shot recording. Clients get +version one by default and schema v2 (provisional and final shots plus +profile, power, processing and club events) with ``?schema=2``. It exists so +a phone can receive shots over the network on hardware where BLE advertising is +unavailable, and so the payload contract can be exercised with nothing but +``curl``. +""" + +from __future__ import annotations + +import logging +import queue +import threading +from typing import Iterable, Iterator, Mapping, Sequence + +# The wire payload is the same versioned V1 shot event the BLE transport sends, +# so both transports are validated against one contract and one test fixture. +from .ble.protocol import ( + SCHEMA_VERSION, + SCHEMA_VERSION_V2, + build_club_event_v2, + build_shot_event_v2, + encode_club_event, + encode_message_v2, + encode_shot_event, +) + +logger = logging.getLogger(__name__) + +SSE_MIMETYPE = "text/event-stream" +DEFAULT_HEARTBEAT_INTERVAL_S = 15.0 +DEFAULT_MAX_SUBSCRIBERS = 8 + +# An SSE comment. Clients ignore it, but it keeps idle connections from being +# reaped and lets the server notice a vanished client between shots. +HEARTBEAT_FRAME = ": ping\n\n" + + +class ShotStreamFull(RuntimeError): + """Raised when the broker already serves the maximum number of clients.""" + + +class StreamEvent(bytes): + """Encoded JSON carrying its SSE name while remaining bytes-compatible. + + v2 shots also carry their ``event_id`` as the SSE ``id``, so a client's + ``Last-Event-ID`` names the last shot it received (see ``phone_catch_up``). + """ + + name: str + event_id: str | None + + def __new__(cls, name: str, payload: bytes, event_id: str | None = None): + event = super().__new__(cls, payload) + event.name = name + event.event_id = event_id + return event + + +def format_event(event: StreamEvent | bytes) -> str: + """Frame one encoded event for the SSE wire protocol. + + ``encode_shot_event`` emits compact single-line JSON, so the payload never + needs to be split across multiple ``data:`` lines. + """ + name = event.name if isinstance(event, StreamEvent) else "shot" + event_id = event.event_id if isinstance(event, StreamEvent) else None + id_line = f"id: {event_id}\n" if event_id else "" + return f"event: {name}\n{id_line}data: {event.decode('utf-8')}\n\n" + + +class ShotStreamBroker: + """Deliver encoded shot events to every connected SSE subscriber.""" + + def __init__( + self, + *, + queue_size: int = 8, + heartbeat_interval_s: float = DEFAULT_HEARTBEAT_INTERVAL_S, + max_subscribers: int = DEFAULT_MAX_SUBSCRIBERS, + ): + if queue_size < 1: + raise ValueError("Shot stream queue size must be at least one") + if heartbeat_interval_s <= 0: + raise ValueError("Shot stream heartbeat interval must be positive") + if max_subscribers < 1: + raise ValueError("Shot stream must allow at least one subscriber") + + self.queue_size = queue_size + self.heartbeat_interval_s = heartbeat_interval_s + self.max_subscribers = max_subscribers + + self._lock = threading.Lock() + self._subscribers: list[queue.Queue[StreamEvent]] = [] + # Subscribers that opted into schema v2 with ``?schema=2``. Everyone + # else keeps receiving the unchanged version-one stream. + self._v2_subscribers: set[int] = set() + self._latest_payload: bytes | None = None + self._latest_v2_event: StreamEvent | None = None + + @property + def subscriber_count(self) -> int: + """How many clients are currently streaming.""" + with self._lock: + return len(self._subscribers) + + def publish(self, shot_data: Mapping) -> bool: + """Encode a shot and hand it to every subscriber; never raises upward.""" + try: + payload = encode_shot_event(shot_data) + except (KeyError, TypeError, ValueError): + logger.warning("[STREAM] Failed to encode shot payload", exc_info=True) + return False + + with self._lock: + self._latest_payload = payload + subscribers = self._subscribers_for(SCHEMA_VERSION) + event = StreamEvent("shot", payload) + for subscriber in subscribers: + self._offer(subscriber, event) + return True + + def publish_v2_shot( + self, + shot_data: Mapping, + *, + final: bool, + enrichment: Mapping | None = None, + ) -> bool: + """Send a provisional or final v2 shot to schema v2 subscribers.""" + try: + shot_event = build_shot_event_v2(shot_data, final=final, enrichment=enrichment) + event = StreamEvent("shot", encode_message_v2(shot_event), shot_event["event_id"]) + except (KeyError, TypeError, ValueError): + logger.warning("[STREAM] Failed to encode v2 shot payload", exc_info=True) + return False + + with self._lock: + self._latest_v2_event = event + subscribers = self._subscribers_for(SCHEMA_VERSION_V2) + for subscriber in subscribers: + self._offer(subscriber, event) + return True + + def publish_event_v2(self, event: Mapping) -> bool: + """Send one schema v2 event, named by its ``type``, to v2 subscribers.""" + try: + stream_event = StreamEvent(str(event["type"]), encode_message_v2(event)) + except (KeyError, TypeError, ValueError): + logger.warning("[STREAM] Failed to encode v2 event", exc_info=True) + return False + + with self._lock: + subscribers = self._subscribers_for(SCHEMA_VERSION_V2) + for subscriber in subscribers: + self._offer(subscriber, stream_event) + return True + + def publish_club(self, club: str) -> bool: + """Broadcast an authoritative club change to every network subscriber.""" + try: + event = StreamEvent("club_changed", encode_club_event(club)) + v2_event = StreamEvent("club_changed", encode_message_v2(build_club_event_v2(club))) + except (TypeError, ValueError): + logger.warning("[STREAM] Failed to encode club payload", exc_info=True) + return False + + with self._lock: + subscribers = self._subscribers_for(SCHEMA_VERSION) + v2_subscribers = self._subscribers_for(SCHEMA_VERSION_V2) + for subscriber in subscribers: + self._offer(subscriber, event) + for subscriber in v2_subscribers: + self._offer(subscriber, v2_event) + return True + + def subscribe( + self, + *, + schema: int = SCHEMA_VERSION, + initial_events: Iterable[Mapping] = (), + catch_up: Sequence[tuple[str, bytes]] | None = None, + ) -> queue.Queue[StreamEvent]: + """Register a subscriber, seeded with the latest shot for replay. + + A schema v2 subscriber is first seeded with ``initial_events`` (current + state such as club and profiles), then its ``catch_up`` shots + (``(event_id, payload)`` pairs, oldest first) or, when catch-up is + unavailable (``None``), the latest v2 shot. Version one ignores + ``catch_up``: its shots carry no stable id to resume from. + """ + if schema not in (SCHEMA_VERSION, SCHEMA_VERSION_V2): + raise ValueError(f"Unsupported shot stream schema: {schema}") + seed = [] + if schema == SCHEMA_VERSION_V2: + for event in initial_events: + try: + seed.append(StreamEvent(str(event["type"]), encode_message_v2(event))) + except (KeyError, TypeError, ValueError): + logger.warning("[STREAM] Failed to encode initial v2 event", exc_info=True) + with self._lock: + if len(self._subscribers) >= self.max_subscribers: + raise ShotStreamFull(f"Shot stream already has {self.max_subscribers} clients") + if schema == SCHEMA_VERSION_V2 and catch_up is not None: + seed.extend( + StreamEvent("shot", payload, event_id) for event_id, payload in catch_up + ) + elif schema == SCHEMA_VERSION_V2: + if self._latest_v2_event is not None: + seed.append(self._latest_v2_event) + elif self._latest_payload is not None: + seed.append(StreamEvent("shot", self._latest_payload)) + subscriber: queue.Queue[StreamEvent] = queue.Queue( + maxsize=max(self.queue_size, len(seed)) + ) + for event in seed: + subscriber.put_nowait(event) + self._subscribers.append(subscriber) + if schema == SCHEMA_VERSION_V2: + self._v2_subscribers.add(id(subscriber)) + count = len(self._subscribers) + logger.info("[STREAM] Client subscribed (%d streaming, schema %d)", count, schema) + return subscriber + + def _subscribers_for(self, schema: int) -> list[queue.Queue[StreamEvent]]: + """Subscribers of one schema; the caller holds ``_lock``.""" + v2 = schema == SCHEMA_VERSION_V2 + return [item for item in self._subscribers if (id(item) in self._v2_subscribers) == v2] + + def unsubscribe(self, subscriber: queue.Queue[StreamEvent]) -> None: + """Drop a subscriber. Unsubscribing twice is not an error.""" + with self._lock: + if subscriber not in self._subscribers: + return + self._subscribers.remove(subscriber) + self._v2_subscribers.discard(id(subscriber)) + count = len(self._subscribers) + logger.info("[STREAM] Client unsubscribed (%d streaming)", count) + + def frames(self, subscriber: queue.Queue[StreamEvent]) -> Iterator[str]: + """Yield SSE frames for an already-registered subscriber. + + Registration is deliberately not folded in here: a generator body does + not run until first iteration, so a caller that needs to reject a client + before sending response headers has to call ``subscribe`` itself. + """ + try: + # Open with a heartbeat so the WSGI server flushes response headers + # at once. Without it a client learns nothing -- not even that it + # connected -- until the first shot or heartbeat, because headers + # are not written until the first chunk of the body. + yield HEARTBEAT_FRAME + while True: + try: + event = subscriber.get(timeout=self.heartbeat_interval_s) + except queue.Empty: + yield HEARTBEAT_FRAME + continue + yield format_event(event) + finally: + self.unsubscribe(subscriber) + + def _offer(self, subscriber: queue.Queue[StreamEvent], event: StreamEvent) -> None: + """Queue an event, dropping the oldest unsent event when full.""" + try: + subscriber.put_nowait(event) + return + except queue.Full: + pass + + try: + subscriber.get_nowait() + logger.warning("[STREAM] Delivery queue full; dropped oldest unsent shot") + except queue.Empty: + pass + + try: + subscriber.put_nowait(event) + except queue.Full: + logger.warning("[STREAM] Delivery queue still full; dropped shot") diff --git a/tests/ble_harness.py b/tests/ble_harness.py new file mode 100644 index 000000000..15f5eef70 --- /dev/null +++ b/tests/ble_harness.py @@ -0,0 +1,395 @@ +"""Loopback BLE harness: a fake Bless/BlueZ server plus virtual centrals. + +``BleLoopback`` runs the real ``BleShotPublisher`` on its own thread and event +loop, exactly as ``--ble`` does, but with a fake ``bless`` module. The fake +server behaves like Bless 0.3.0 on BlueZ where it matters to the publisher: + +* ``app.StartNotify``/``app.StopNotify`` are called *before* the + characteristic is added to (or removed from) ``app.subscribed_characteristics``. +* ``update_value`` notifies the characteristic's current value, and BlueZ + delivers it to every central subscribed to that characteristic (and only + those), which ``VirtualCentral`` models. +* Writes arrive through ``write_request_func(characteristic, value)``. + +A ``VirtualCentral`` subscribes, writes framed commands and reassembles the +frames it is notified with using the real protocol reassembler. No Bluetooth +stack, radio or ``bless`` install is needed. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import sys +import threading +import time +import types +import uuid +from enum import IntFlag + +from openflight.ble.protocol import ( + CONTROL_CHARACTERISTIC_UUID, + CONTROL_V2_CHARACTERISTIC_UUID, + SHOT_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, + FragmentReassembler, + fragment_payload, +) +from openflight.ble.publisher import BleShotPublisher + + +def normalize(value: str) -> str: + return str(uuid.UUID(str(value))).lower() + + +class _Properties(IntFlag): + notify = 1 + write = 2 + + +class _Permissions(IntFlag): + readable = 1 + writeable = 2 + + +class FakeCharacteristic: + def __init__(self, char_uuid: str, properties, permissions): + # Bless normalizes UUIDs to lowercase strings. + self.uuid = normalize(char_uuid) + self.properties = properties + self.permissions = permissions + self.value = bytearray() + + +class FakeBlueZApplication: + """The slice of Bless's ``BlueZGattApplication`` the publisher touches.""" + + def __init__(self): + self.subscribed_characteristics: list[str] = [] + # Bless 0.3.0 installs no-ops here; the publisher replaces them. + self.StartNotify = lambda _session: None # pylint: disable=invalid-name + self.StopNotify = lambda _session: None # pylint: disable=invalid-name + + +class FakeBlessServer: + """Fake ``bless.BlessServer`` that records notifications per characteristic.""" + + instances: list["FakeBlessServer"] = [] + + def __init__(self, *, name, loop, on_subscribe=None, on_unsubscribe=None, **_kwargs): + self.name = name + self.loop = loop + self.on_subscribe = on_subscribe + self.on_unsubscribe = on_unsubscribe + self.app = FakeBlueZApplication() + self.characteristics: dict[str, FakeCharacteristic] = {} + self.write_request_func = None + self.started = threading.Event() + self.stopped = threading.Event() + self.listeners: list = [] + FakeBlessServer.instances.append(self) + + async def add_new_service(self, _service_uuid): + return None + + async def add_new_characteristic( + self, _service_uuid, char_uuid, properties, _value, permissions + ): + characteristic = FakeCharacteristic(char_uuid, properties, permissions) + self.characteristics[characteristic.uuid] = characteristic + + async def start(self): + self.started.set() + + async def stop(self): + self.stopped.set() + + def get_characteristic(self, char_uuid): + return self.characteristics.get(normalize(char_uuid)) + + def update_value(self, _service_uuid, char_uuid) -> bool: + key = normalize(char_uuid) + frame = bytes(self.characteristics[key].value) + for listener in list(self.listeners): + listener(key, frame) + return True + + # -- driven from the central side, always on the publisher's loop ------ + + def start_notify(self, char_uuid: str) -> None: + """What BlueZ does when the first central enables a characteristic's CCCD.""" + self.app.StartNotify(None) + self.app.subscribed_characteristics.append(normalize(char_uuid)) + + def stop_notify(self, char_uuid: str) -> None: + """What BlueZ does when the last central disables a characteristic's CCCD.""" + self.app.StopNotify(None) + self.app.subscribed_characteristics.remove(normalize(char_uuid)) + + def write(self, char_uuid: str, value: bytes) -> None: + self.write_request_func(self.characteristics[normalize(char_uuid)], bytearray(value)) + + +def fake_bless_module() -> types.ModuleType: + module = types.ModuleType("bless") + module.BlessServer = FakeBlessServer + module.GATTCharacteristicProperties = _Properties + module.GATTAttributePermissions = _Permissions + return module + + +class BleLoopback: + """Run a real ``BleShotPublisher`` against ``FakeBlessServer`` in-process.""" + + def __init__(self, monkeypatch, **publisher_kwargs): + monkeypatch.setitem(sys.modules, "bless", fake_bless_module()) + FakeBlessServer.instances.clear() + publisher_kwargs.setdefault("fragment_interval_s", 0) + self.publisher = BleShotPublisher(**publisher_kwargs) + self.server: FakeBlessServer | None = None + self.centrals: list[VirtualCentral] = [] + self._subscribers: dict[str, int] = {} + self._lock = threading.Lock() + + def __enter__(self) -> "BleLoopback": + self.publisher.start() + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if FakeBlessServer.instances and FakeBlessServer.instances[0].started.is_set(): + break + time.sleep(0.005) + else: + raise AssertionError("fake BLE server never started") + self.server = FakeBlessServer.instances[0] + self.server.listeners.append(self._deliver) + # Let the publisher finish its own post-start bookkeeping. + self.run_on_loop(lambda: None) + return self + + def __exit__(self, *_exc): + self.publisher.stop() + + @property + def loop(self) -> asyncio.AbstractEventLoop: + return self.server.loop + + def run_on_loop(self, callback) -> None: + """Run ``callback`` on the publisher's loop and wait for queued follow-ups.""" + done = threading.Event() + + def call(): + callback() + # One more turn so ``call_soon`` work scheduled by the callback runs. + self.loop.call_soon(done.set) + + self.loop.call_soon_threadsafe(call) + assert done.wait(5), "publisher loop did not run the callback" + + def central(self, name: str = "central") -> "VirtualCentral": + central = VirtualCentral(self, name) + self.centrals.append(central) + return central + + def _deliver(self, char_uuid: str, frame: bytes) -> None: + for central in list(self.centrals): + central.receive(char_uuid, frame) + + def _subscribe(self, char_uuid: str) -> None: + key = normalize(char_uuid) + with self._lock: + count = self._subscribers.get(key, 0) + self._subscribers[key] = count + 1 + if count == 0: + self.run_on_loop(lambda: self.server.start_notify(key)) + + def _unsubscribe(self, char_uuid: str) -> None: + key = normalize(char_uuid) + with self._lock: + count = self._subscribers.get(key, 0) - 1 + self._subscribers[key] = max(count, 0) + if count == 0: + self.run_on_loop(lambda: self.server.stop_notify(key)) + + def write(self, char_uuid: str, frame: bytes) -> None: + self.run_on_loop(lambda: self.server.write(char_uuid, frame)) + + +class VirtualCentral: + """A phone: subscribes, writes framed commands, reassembles notifications.""" + + def __init__(self, loopback: BleLoopback, name: str): + self.loopback = loopback + self.name = name + self.subscriptions: set[str] = set() + self._reassemblers: dict[str, FragmentReassembler] = {} + self.frames: dict[str, list[bytes]] = {} + self.messages: dict[str, list[bytes]] = {} + self._condition = threading.Condition() + self._write_sequence = 0 + + # -- GATT operations ---------------------------------------------------- + + def subscribe(self, *char_uuids: str) -> None: + for char_uuid in char_uuids: + key = normalize(char_uuid) + if key not in self.subscriptions: + self.subscriptions.add(key) + self.loopback._subscribe(key) # pylint: disable=protected-access + + def unsubscribe(self, *char_uuids: str) -> None: + for char_uuid in char_uuids: + key = normalize(char_uuid) + if key in self.subscriptions: + self.subscriptions.discard(key) + self.loopback._unsubscribe(key) # pylint: disable=protected-access + + def disconnect(self) -> None: + self.unsubscribe(*list(self.subscriptions)) + + def write_payload(self, char_uuid: str, payload: bytes) -> None: + for frame in fragment_payload(payload, sequence=self._write_sequence): + self.loopback.write(char_uuid, frame) + self._write_sequence = (self._write_sequence + 1) & 0xFFFF + + def command( + self, + command_type: str, + payload: dict | None = None, + *, + control: str = CONTROL_CHARACTERISTIC_UUID, + schema_version: int = 1, + request_id: str | None = None, + ) -> str: + request_id = request_id or str(uuid.uuid4()) + message = { + "schema_version": schema_version, + "type": command_type, + "request_id": request_id, + "payload": payload if payload is not None else {}, + } + self.write_payload(control, json.dumps(message).encode("utf-8")) + return request_id + + def request(self, command_type: str, payload: dict | None = None, **kwargs) -> dict: + """Send a command and wait for the response carrying its request id.""" + control = kwargs.get("control", CONTROL_CHARACTERISTIC_UUID) + request_id = self.command(command_type, payload, **kwargs) + return self.wait_for( + control, + lambda message: message.get("request_id") == request_id, + ) + + # -- notifications -------------------------------------------------------- + + def receive(self, char_uuid: str, frame: bytes) -> None: + if char_uuid not in self.subscriptions: + return # BlueZ only notifies centrals that enabled this CCCD. + with self._condition: + self.frames.setdefault(char_uuid, []).append(frame) + reassembler = self._reassemblers.setdefault(char_uuid, FragmentReassembler()) + message = reassembler.append(frame) + if message is not None: + self.messages.setdefault(char_uuid, []).append(message) + self._condition.notify_all() + + def decoded(self, char_uuid: str) -> list[dict]: + with self._condition: + raw = list(self.messages.get(normalize(char_uuid), [])) + return [json.loads(item.decode("utf-8")) for item in raw] + + def raw(self, char_uuid: str) -> list[bytes]: + with self._condition: + return list(self.messages.get(normalize(char_uuid), [])) + + def wait_for(self, char_uuid: str, predicate=lambda _message: True, timeout=5.0) -> dict: + key = normalize(char_uuid) + deadline = time.monotonic() + timeout + with self._condition: + while True: + for raw in self.messages.get(key, []): + message = json.loads(raw.decode("utf-8")) + if predicate(message): + return message + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AssertionError( + f"{self.name} got no matching message on {key}; " + f"saw {self.messages.get(key, [])!r}" + ) + self._condition.wait(remaining) + + def wait_for_count(self, char_uuid: str, count: int, timeout=5.0) -> list[dict]: + key = normalize(char_uuid) + deadline = time.monotonic() + timeout + with self._condition: + while len(self.messages.get(key, [])) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AssertionError( + f"{self.name} expected {count} messages on {key}; " + f"saw {self.messages.get(key, [])!r}" + ) + self._condition.wait(remaining) + return self.decoded(key) + + +def settle(loopback: BleLoopback, rounds: int = 3) -> None: + """Let queued notifications drain before asserting on their absence.""" + for _ in range(rounds): + loopback.run_on_loop(lambda: None) + time.sleep(0.02) + + +@contextlib.contextmanager +def server_loopback(monkeypatch, tmp_path): + """A monitor-less OpenFlight server state with BLE on the loopback harness. + + The real server command dispatch answers commands; Socket.IO emits are + recorded on ``loopback.emitted`` instead of sent. + """ + from openflight import server as server_module # pylint: disable=import-outside-toplevel + from openflight.launch_monitor import ClubType # pylint: disable=import-outside-toplevel + from openflight.phone_catch_up import PhoneShotCache # pylint: disable=import-outside-toplevel + from openflight.profiles import ProfileStore # pylint: disable=import-outside-toplevel + from openflight.shot_stream import ShotStreamBroker # pylint: disable=import-outside-toplevel + + emitted = [] + lock = threading.Lock() + + def emit(event, payload=None, **_kwargs): + with lock: + emitted.append((event, payload)) + + monkeypatch.setattr(server_module.socketio, "emit", emit) + monkeypatch.setattr(server_module, "monitor", None) + monkeypatch.setattr(server_module, "profile_store", ProfileStore(tmp_path / "profiles.json")) + monkeypatch.setattr(server_module, "shot_stream", ShotStreamBroker()) + monkeypatch.setattr(server_module, "active_club", ClubType.DRIVER) + monkeypatch.setattr(server_module, "iwr6843_runtime", None) + monkeypatch.setattr(server_module, "power_monitor", None) + monkeypatch.setattr(server_module, "kld7_vertical", None) + monkeypatch.setattr(server_module, "kld7_horizontal", None) + monkeypatch.setattr(server_module, "camera_capture_runtime", None) + monkeypatch.setattr(server_module, "ball_speed_correction_enabled", False) + monkeypatch.setattr(server_module, "calculated_spin_enabled", False) + monkeypatch.setattr(server_module, "ballistics_enabled", False) + monkeypatch.setattr(server_module, "debug_mode", False) + monkeypatch.setattr(server_module, "sim_connectors", []) + monkeypatch.setattr(server_module, "get_session_logger", lambda: None) + monkeypatch.setattr(server_module, "phone_shot_cache", PhoneShotCache()) + + loopback = BleLoopback( + monkeypatch, + command_handler=server_module.dispatch_phone_control_command, + command_handler_v2=server_module.dispatch_phone_control_command_v2, + catch_up_provider=server_module.phone_catch_up_v2, + ) + with loopback: + monkeypatch.setattr(server_module, "ble_publisher", loopback.publisher) + loopback.emitted = emitted + yield loopback + + +V1_UUIDS = (SHOT_CHARACTERISTIC_UUID, CONTROL_CHARACTERISTIC_UUID) +V2_UUIDS = (SHOT_V2_CHARACTERISTIC_UUID, CONTROL_V2_CHARACTERISTIC_UUID) diff --git a/tests/fixtures/ble_goldens/client_v1_hello.json b/tests/fixtures/ble_goldens/client_v1_hello.json new file mode 100644 index 000000000..668b8a9bc --- /dev/null +++ b/tests/fixtures/ble_goldens/client_v1_hello.json @@ -0,0 +1,35 @@ +{ + "name": "client_v1_hello", + "description": "Hand-built: a v2-capable client probing with hello on the v1 control characteristic (v1 envelope).", + "direction": "client_to_server", + "characteristic": "7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11", + "schema_version": 1, + "sequence": 41, + "message": { + "schema_version": 1, + "type": "hello", + "request_id": "0b3c6f9e-2d41-4a8b-9c7e-5f1a2b3c4d5e", + "payload": { + "client_schema_max": 2 + } + }, + "payload_hex": "7b22736368656d615f76657273696f6e223a312c2274797065223a2268656c6c6f222c22726571756573745f6964223a2230623363366639652d326434312d346138622d396337652d356631613262336334643565222c227061796c6f6164223a7b22636c69656e745f736368656d615f6d6178223a327d7d", + "frames_hex": [ + "01002900097b22736368656d615f76657273696f", + "01002901096e223a312c2274797065223a226865", + "01002902096c6c6f222c22726571756573745f69", + "010029030964223a2230623363366639652d3264", + "010029040934312d346138622d396337652d3566", + "010029050931613262336334643565222c227061", + "0100290609796c6f6164223a7b22636c69656e74", + "01002907095f736368656d615f6d6178223a327d", + "01002908097d" + ], + "expect": { + "ok": true, + "schema_version": 1, + "result": { + "schema_version": 2 + } + } +} diff --git a/tests/fixtures/ble_goldens/client_v2_get_profiles.json b/tests/fixtures/ble_goldens/client_v2_get_profiles.json new file mode 100644 index 000000000..d1dfe0b24 --- /dev/null +++ b/tests/fixtures/ble_goldens/client_v2_get_profiles.json @@ -0,0 +1,32 @@ +{ + "name": "client_v2_get_profiles", + "description": "Hand-built: get_profiles on the v2 control characteristic; the roster follows as a profiles event.", + "direction": "client_to_server", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 43, + "message": { + "payload": {}, + "request_id": "c0ffee00-1234-4abc-8def-0123456789ab", + "schema_version": 2, + "type": "get_profiles" + }, + "payload_hex": "7b227061796c6f6164223a7b7d2c22726571756573745f6964223a2263306666656530302d313233342d346162632d386465662d303132333435363738396162222c22736368656d615f76657273696f6e223a322c2274797065223a226765745f70726f66696c6573227d", + "frames_hex": [ + "01002b00087b227061796c6f6164223a7b7d2c22", + "01002b0108726571756573745f6964223a226330", + "01002b02086666656530302d313233342d346162", + "01002b0308632d386465662d3031323334353637", + "01002b040838396162222c22736368656d615f76", + "01002b0508657273696f6e223a322c2274797065", + "01002b0608223a226765745f70726f66696c6573", + "01002b0708227d" + ], + "expect": { + "ok": true, + "schema_version": 2, + "result": { + "status": "sent" + } + } +} diff --git a/tests/fixtures/ble_goldens/client_v2_set_club.json b/tests/fixtures/ble_goldens/client_v2_set_club.json new file mode 100644 index 000000000..2b8975dd3 --- /dev/null +++ b/tests/fixtures/ble_goldens/client_v2_set_club.json @@ -0,0 +1,36 @@ +{ + "name": "client_v2_set_club", + "description": "Hand-built: set_club on the v2 control characteristic, with unsorted keys and spaces as a non-Python encoder may send.", + "direction": "client_to_server", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 42, + "message": { + "type": "set_club", + "payload": { + "club": "7-iron" + }, + "request_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", + "schema_version": 2 + }, + "payload_hex": "7b2274797065223a20227365745f636c7562222c20227061796c6f6164223a207b22636c7562223a2022372d69726f6e227d2c2022726571756573745f6964223a202239613862376336642d356534662d346133622d386332642d316530663961386237633664222c2022736368656d615f76657273696f6e223a20327d", + "frames_hex": [ + "01002a00097b2274797065223a20227365745f63", + "01002a01096c7562222c20227061796c6f616422", + "01002a02093a207b22636c7562223a2022372d69", + "01002a0309726f6e227d2c202272657175657374", + "01002a04095f6964223a20223961386237633664", + "01002a05092d356534662d346133622d38633264", + "01002a06092d316530663961386237633664222c", + "01002a07092022736368656d615f76657273696f", + "01002a08096e223a20327d" + ], + "expect": { + "ok": true, + "schema_version": 2, + "result": { + "status": "applied", + "club": "7-iron" + } + } +} diff --git a/tests/fixtures/ble_goldens/v1_club_changed.json b/tests/fixtures/ble_goldens/v1_club_changed.json new file mode 100644 index 000000000..cd38a567c --- /dev/null +++ b/tests/fixtures/ble_goldens/v1_club_changed.json @@ -0,0 +1,20 @@ +{ + "name": "v1_club_changed", + "description": "Version-one club_changed notify on the v1 control characteristic.", + "direction": "server_to_client", + "characteristic": "7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11", + "schema_version": 1, + "sequence": 1, + "message": { + "club": "7-iron", + "schema_version": 1, + "type": "club_changed" + }, + "payload_hex": "7b22636c7562223a22372d69726f6e222c22736368656d615f76657273696f6e223a312c2274797065223a22636c75625f6368616e676564227d", + "frames_hex": [ + "01000100047b22636c7562223a22372d69726f6e", + "0100010104222c22736368656d615f7665727369", + "01000102046f6e223a312c2274797065223a2263", + "01000103046c75625f6368616e676564227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v1_response_get_club.json b/tests/fixtures/ble_goldens/v1_response_get_club.json new file mode 100644 index 000000000..6e98ddaf8 --- /dev/null +++ b/tests/fixtures/ble_goldens/v1_response_get_club.json @@ -0,0 +1,29 @@ +{ + "name": "v1_response_get_club", + "description": "get_club answered on the v1 control characteristic.", + "direction": "server_to_client", + "characteristic": "7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11", + "schema_version": 1, + "sequence": 2, + "message": { + "schema_version": 1, + "request_id": "5E0F2C4A-8B1D-4C3E-9F6A-7D2B1C0E9A84", + "ok": true, + "result": { + "status": "current", + "club": "7-iron" + } + }, + "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636c7562223a22372d69726f6e222c22737461747573223a2263757272656e74227d2c22736368656d615f76657273696f6e223a317d", + "frames_hex": [ + "01000200097b226f6b223a747275652c22726571", + "0100020109756573745f6964223a223545304632", + "01000202094334412d384231442d344333452d39", + "01000203094636412d3744324231433045394138", + "010002040934222c22726573756c74223a7b2263", + "01000205096c7562223a22372d69726f6e222c22", + "0100020609737461747573223a2263757272656e", + "010002070974227d2c22736368656d615f766572", + "010002080973696f6e223a317d" + ] +} diff --git a/tests/fixtures/ble_goldens/v1_response_hello.json b/tests/fixtures/ble_goldens/v1_response_hello.json new file mode 100644 index 000000000..0f8bb8e31 --- /dev/null +++ b/tests/fixtures/ble_goldens/v1_response_hello.json @@ -0,0 +1,55 @@ +{ + "name": "v1_response_hello", + "description": "hello {client_schema_max:2} answered on the v1 control characteristic: v1 envelope, v2 result.", + "direction": "server_to_client", + "characteristic": "7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11", + "schema_version": 1, + "sequence": 3, + "message": { + "schema_version": 1, + "request_id": "5E0F2C4A-8B1D-4C3E-9F6A-7D2B1C0E9A84", + "ok": true, + "result": { + "schema_version": 2, + "features": [ + "provisional_shots", + "shot_processing", + "profiles", + "power_status", + "shot_deleted", + "club", + "shot_catch_up" + ], + "characteristics": { + "shot": "ED365FE6-3ABF-4FC3-8E44-D9525A22DABD", + "control": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474" + } + } + }, + "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636861726163746572697374696373223a7b22636f6e74726f6c223a2237424139364536332d313243322d344345302d424238342d333531334337464431343734222c2273686f74223a2245443336354645362d334142462d344643332d384534342d443935323541323244414244227d2c226665617475726573223a5b2270726f766973696f6e616c5f73686f7473222c2273686f745f70726f63657373696e67222c2270726f66696c6573222c22706f7765725f737461747573222c2273686f745f64656c65746564222c22636c7562222c2273686f745f63617463685f7570225d2c22736368656d615f76657273696f6e223a327d2c22736368656d615f76657273696f6e223a317d", + "frames_hex": [ + "01000300177b226f6b223a747275652c22726571", + "0100030117756573745f6964223a223545304632", + "01000302174334412d384231442d344333452d39", + "01000303174636412d3744324231433045394138", + "010003041734222c22726573756c74223a7b2263", + "0100030517686172616374657269737469637322", + "01000306173a7b22636f6e74726f6c223a223742", + "01000307174139364536332d313243322d344345", + "0100030817302d424238342d3335313343374644", + "010003091731343734222c2273686f74223a2245", + "0100030a17443336354645362d334142462d3446", + "0100030b1743332d384534342d44393532354132", + "0100030c173244414244227d2c22666561747572", + "0100030d176573223a5b2270726f766973696f6e", + "0100030e17616c5f73686f7473222c2273686f74", + "0100030f175f70726f63657373696e67222c2270", + "0100031017726f66696c6573222c22706f776572", + "01000311175f737461747573222c2273686f745f", + "010003121764656c65746564222c22636c756222", + "01000313172c2273686f745f63617463685f7570", + "0100031417225d2c22736368656d615f76657273", + "0100031517696f6e223a327d2c22736368656d61", + "01000316175f76657273696f6e223a317d" + ] +} diff --git a/tests/fixtures/ble_goldens/v1_response_unknown_command.json b/tests/fixtures/ble_goldens/v1_response_unknown_command.json new file mode 100644 index 000000000..0138cbb5c --- /dev/null +++ b/tests/fixtures/ble_goldens/v1_response_unknown_command.json @@ -0,0 +1,26 @@ +{ + "name": "v1_response_unknown_command", + "description": "What a server without schema v2 (or any server, for an unknown type) answers.", + "direction": "server_to_client", + "characteristic": "7E3B5D6C-7F10-4D4A-9C39-25E2B77F4A11", + "schema_version": 1, + "sequence": 4, + "message": { + "schema_version": 1, + "request_id": "5E0F2C4A-8B1D-4C3E-9F6A-7D2B1C0E9A84", + "ok": false, + "error": "Unsupported phone command: hello" + }, + "payload_hex": "7b226572726f72223a22556e737570706f727465642070686f6e6520636f6d6d616e643a2068656c6c6f222c226f6b223a66616c73652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22736368656d615f76657273696f6e223a317d", + "frames_hex": [ + "01000400097b226572726f72223a22556e737570", + "0100040109706f727465642070686f6e6520636f", + "01000402096d6d616e643a2068656c6c6f222c22", + "01000403096f6b223a66616c73652c2272657175", + "01000404096573745f6964223a22354530463243", + "010004050934412d384231442d344333452d3946", + "010004060936412d374432423143304539413834", + "0100040709222c22736368656d615f7665727369", + "01000408096f6e223a317d" + ] +} diff --git a/tests/fixtures/ble_goldens/v1_shot.json b/tests/fixtures/ble_goldens/v1_shot.json new file mode 100644 index 000000000..9cd599a9b --- /dev/null +++ b/tests/fixtures/ble_goldens/v1_shot.json @@ -0,0 +1,49 @@ +{ + "name": "v1_shot", + "description": "Version-one final shot on the v1 shot characteristic (tests/fixtures/shot_v1.json).", + "direction": "server_to_client", + "characteristic": "2B28F67E-9011-41D2-98ED-562B47D7A5E4", + "schema_version": 1, + "sequence": 0, + "message": { + "ball_speed_mph": 151.4, + "club": "driver", + "club_path_deg": 2.1, + "club_speed_mph": 103.2, + "estimated_carry_yards": 264, + "event_id": "B0D91F0A-7950-4D7E-9DD5-AF9777C190E1", + "launch_angle_horizontal": -1.3, + "launch_angle_vertical": 12.6, + "schema_version": 1, + "smash_factor": 1.47, + "spin_axis_deg": -3.4, + "spin_rpm": 2380, + "timestamp": "2026-07-29T19:42:10.123456" + }, + "payload_hex": "7b2262616c6c5f73706565645f6d7068223a3135312e342c22636c7562223a22647269766572222c22636c75625f706174685f646567223a322e312c22636c75625f73706565645f6d7068223a3130332e322c22657374696d617465645f63617272795f7961726473223a3236342c226576656e745f6964223a2242304439314630412d373935302d344437452d394444352d414639373737433139304531222c226c61756e63685f616e676c655f686f72697a6f6e74616c223a2d312e332c226c61756e63685f616e676c655f766572746963616c223a31322e362c22736368656d615f76657273696f6e223a312c22736d6173685f666163746f72223a312e34372c227370696e5f617869735f646567223a2d332e342c227370696e5f72706d223a323338302c2274696d657374616d70223a22323032362d30372d32395431393a34323a31302e313233343536227d", + "frames_hex": [ + "01000000177b2262616c6c5f73706565645f6d70", + "010000011768223a3135312e342c22636c756222", + "01000002173a22647269766572222c22636c7562", + "01000003175f706174685f646567223a322e312c", + "010000041722636c75625f73706565645f6d7068", + "0100000517223a3130332e322c22657374696d61", + "01000006177465645f63617272795f7961726473", + "0100000717223a3236342c226576656e745f6964", + "0100000817223a2242304439314630412d373935", + "0100000917302d344437452d394444352d414639", + "0100000a17373737433139304531222c226c6175", + "0100000b176e63685f616e676c655f686f72697a", + "0100000c176f6e74616c223a2d312e332c226c61", + "0100000d17756e63685f616e676c655f76657274", + "0100000e176963616c223a31322e362c22736368", + "0100000f17656d615f76657273696f6e223a312c", + "010000101722736d6173685f666163746f72223a", + "0100001117312e34372c227370696e5f61786973", + "01000012175f646567223a2d332e342c22737069", + "01000013176e5f72706d223a323338302c227469", + "01000014176d657374616d70223a22323032362d", + "010000151730372d32395431393a34323a31302e", + "0100001617313233343536227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_event_club_changed.json b/tests/fixtures/ble_goldens/v2_event_club_changed.json new file mode 100644 index 000000000..b7a7fa7e3 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_event_club_changed.json @@ -0,0 +1,20 @@ +{ + "name": "v2_event_club_changed", + "description": "club_changed notify on the v2 control characteristic.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 8, + "message": { + "schema_version": 2, + "type": "club_changed", + "club": "7-iron" + }, + "payload_hex": "7b22636c7562223a22372d69726f6e222c22736368656d615f76657273696f6e223a322c2274797065223a22636c75625f6368616e676564227d", + "frames_hex": [ + "01000800047b22636c7562223a22372d69726f6e", + "0100080104222c22736368656d615f7665727369", + "01000802046f6e223a322c2274797065223a2263", + "01000803046c75625f6368616e676564227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_event_power_status.json b/tests/fixtures/ble_goldens/v2_event_power_status.json new file mode 100644 index 000000000..ad759b572 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_event_power_status.json @@ -0,0 +1,39 @@ +{ + "name": "v2_event_power_status", + "description": "power_status notify: the Socket.IO payload beside type.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 6, + "message": { + "schema_version": 2, + "type": "power_status", + "available": true, + "provider": "geekworm", + "state": "on_battery", + "battery_percent": 76.5, + "battery_voltage_v": 3.98, + "external_power": false, + "updated_at": "2026-09-25T14:03:05.000000+00:00", + "error": null + }, + "payload_hex": "7b22617661696c61626c65223a747275652c22626174746572795f70657263656e74223a37362e352c22626174746572795f766f6c746167655f76223a332e39382c226572726f72223a6e756c6c2c2265787465726e616c5f706f776572223a66616c73652c2270726f7669646572223a226765656b776f726d222c22736368656d615f76657273696f6e223a322c227374617465223a226f6e5f62617474657279222c2274797065223a22706f7765725f737461747573222c22757064617465645f6174223a22323032362d30392d32355431343a30333a30352e3030303030302b30303a3030227d", + "frames_hex": [ + "01000600107b22617661696c61626c65223a7472", + "010006011075652c22626174746572795f706572", + "010006021063656e74223a37362e352c22626174", + "0100060310746572795f766f6c746167655f7622", + "01000604103a332e39382c226572726f72223a6e", + "0100060510756c6c2c2265787465726e616c5f70", + "01000606106f776572223a66616c73652c227072", + "01000607106f7669646572223a226765656b776f", + "0100060810726d222c22736368656d615f766572", + "010006091073696f6e223a322c22737461746522", + "0100060a103a226f6e5f62617474657279222c22", + "0100060b1074797065223a22706f7765725f7374", + "0100060c1061747573222c22757064617465645f", + "0100060d106174223a22323032362d30392d3235", + "0100060e105431343a30333a30352e3030303030", + "0100060f10302b30303a3030227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_event_profiles.json b/tests/fixtures/ble_goldens/v2_event_profiles.json new file mode 100644 index 000000000..b851f3fe8 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_event_profiles.json @@ -0,0 +1,41 @@ +{ + "name": "v2_event_profiles", + "description": "profiles notify: ids and names only, UTF-8 (not \\u-escaped) names.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 4, + "message": { + "schema_version": 2, + "type": "profiles", + "profiles": [ + { + "id": "0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b", + "name": "Zoë ⛳" + }, + { + "id": "7c1d9e3f5a2b4c6d8e0f1a3b5c7d9e1f", + "name": "Sam" + } + ], + "active_profile_id": "0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b" + }, + "payload_hex": "7b226163746976655f70726f66696c655f6964223a223066386534623261396337643465316638613662336335643765396631613262222c2270726f66696c6573223a5b7b226964223a223066386534623261396337643465316638613662336335643765396631613262222c226e616d65223a225a6fc3ab20e29bb3227d2c7b226964223a223763316439653366356132623463366438653066316133623563376439653166222c226e616d65223a2253616d227d5d2c22736368656d615f76657273696f6e223a322c2274797065223a2270726f66696c6573227d", + "frames_hex": [ + "010004000f7b226163746976655f70726f66696c", + "010004010f655f6964223a223066386534623261", + "010004020f396337643465316638613662336335", + "010004030f643765396631613262222c2270726f", + "010004040f66696c6573223a5b7b226964223a22", + "010004050f306638653462326139633764346531", + "010004060f663861366233633564376539663161", + "010004070f3262222c226e616d65223a225a6fc3", + "010004080fab20e29bb3227d2c7b226964223a22", + "010004090f376331643965336635613262346336", + "0100040a0f643865306631613362356337643965", + "0100040b0f3166222c226e616d65223a2253616d", + "0100040c0f227d5d2c22736368656d615f766572", + "0100040d0f73696f6e223a322c2274797065223a", + "0100040e0f2270726f66696c6573227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_event_profiles_worst_case.json b/tests/fixtures/ble_goldens/v2_event_profiles_worst_case.json new file mode 100644 index 000000000..b3626a8b4 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_event_profiles_worst_case.json @@ -0,0 +1,307 @@ +{ + "name": "v2_event_profiles_worst_case", + "description": "Twelve 40-character names that each escape to six bytes per character: the largest profiles event the server can send.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 5, + "message": { + "schema_version": 2, + "type": "profiles", + "profiles": [ + { + "id": "00000000000000000000000000000000", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000001", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000002", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000003", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000004", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000005", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000006", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000007", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000008", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "00000000000000000000000000000009", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "0000000000000000000000000000000a", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + }, + { + "id": "0000000000000000000000000000000b", + "name": "\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001\u0001" + } + ], + "active_profile_id": "00000000000000000000000000000000" + }, + "payload_hex": "7b226163746976655f70726f66696c655f6964223a223030303030303030303030303030303030303030303030303030303030303030222c2270726f66696c6573223a5b7b226964223a223030303030303030303030303030303030303030303030303030303030303030222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303031222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303032222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303033222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303034222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303035222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303036222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303037222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303038222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303039222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303061222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d2c7b226964223a223030303030303030303030303030303030303030303030303030303030303062222c226e616d65223a225c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c75303030315c7530303031227d5d2c22736368656d615f76657273696f6e223a322c2274797065223a2270726f66696c6573227d", + "frames_hex": [ + "01000500f17b226163746976655f70726f66696c", + "01000501f1655f6964223a223030303030303030", + "01000502f1303030303030303030303030303030", + "01000503f1303030303030303030222c2270726f", + "01000504f166696c6573223a5b7b226964223a22", + "01000505f1303030303030303030303030303030", + "01000506f1303030303030303030303030303030", + "01000507f13030222c226e616d65223a225c7530", + "01000508f13030315c75303030315c7530303031", + "01000509f15c75303030315c75303030315c7530", + "0100050af13030315c75303030315c7530303031", + "0100050bf15c75303030315c75303030315c7530", + "0100050cf13030315c75303030315c7530303031", + "0100050df15c75303030315c75303030315c7530", + "0100050ef13030315c75303030315c7530303031", + "0100050ff15c75303030315c75303030315c7530", + "01000510f13030315c75303030315c7530303031", + "01000511f15c75303030315c75303030315c7530", + "01000512f13030315c75303030315c7530303031", + "01000513f15c75303030315c75303030315c7530", + "01000514f13030315c75303030315c7530303031", + "01000515f15c75303030315c75303030315c7530", + "01000516f13030315c75303030315c7530303031", + "01000517f15c75303030315c7530303031227d2c", + "01000518f17b226964223a223030303030303030", + "01000519f1303030303030303030303030303030", + "0100051af1303030303030303031222c226e616d", + "0100051bf165223a225c75303030315c75303030", + "0100051cf1315c75303030315c75303030315c75", + "0100051df1303030315c75303030315c75303030", + "0100051ef1315c75303030315c75303030315c75", + "0100051ff1303030315c75303030315c75303030", + "01000520f1315c75303030315c75303030315c75", + "01000521f1303030315c75303030315c75303030", + "01000522f1315c75303030315c75303030315c75", + "01000523f1303030315c75303030315c75303030", + "01000524f1315c75303030315c75303030315c75", + "01000525f1303030315c75303030315c75303030", + "01000526f1315c75303030315c75303030315c75", + "01000527f1303030315c75303030315c75303030", + "01000528f1315c75303030315c75303030315c75", + "01000529f1303030315c75303030315c75303030", + "0100052af1315c75303030315c75303030315c75", + "0100052bf130303031227d2c7b226964223a2230", + "0100052cf1303030303030303030303030303030", + "0100052df1303030303030303030303030303030", + "0100052ef132222c226e616d65223a225c753030", + "0100052ff130315c75303030315c75303030315c", + "01000530f175303030315c75303030315c753030", + "01000531f130315c75303030315c75303030315c", + "01000532f175303030315c75303030315c753030", + "01000533f130315c75303030315c75303030315c", + "01000534f175303030315c75303030315c753030", + "01000535f130315c75303030315c75303030315c", + "01000536f175303030315c75303030315c753030", + "01000537f130315c75303030315c75303030315c", + "01000538f175303030315c75303030315c753030", + "01000539f130315c75303030315c75303030315c", + "0100053af175303030315c75303030315c753030", + "0100053bf130315c75303030315c75303030315c", + "0100053cf175303030315c75303030315c753030", + "0100053df130315c75303030315c75303030315c", + "0100053ef175303030315c7530303031227d2c7b", + "0100053ff1226964223a22303030303030303030", + "01000540f1303030303030303030303030303030", + "01000541f13030303030303033222c226e616d65", + "01000542f1223a225c75303030315c7530303031", + "01000543f15c75303030315c75303030315c7530", + "01000544f13030315c75303030315c7530303031", + "01000545f15c75303030315c75303030315c7530", + "01000546f13030315c75303030315c7530303031", + "01000547f15c75303030315c75303030315c7530", + "01000548f13030315c75303030315c7530303031", + "01000549f15c75303030315c75303030315c7530", + "0100054af13030315c75303030315c7530303031", + "0100054bf15c75303030315c75303030315c7530", + "0100054cf13030315c75303030315c7530303031", + "0100054df15c75303030315c75303030315c7530", + "0100054ef13030315c75303030315c7530303031", + "0100054ff15c75303030315c75303030315c7530", + "01000550f13030315c75303030315c7530303031", + "01000551f15c75303030315c75303030315c7530", + "01000552f1303031227d2c7b226964223a223030", + "01000553f1303030303030303030303030303030", + "01000554f1303030303030303030303030303034", + "01000555f1222c226e616d65223a225c75303030", + "01000556f1315c75303030315c75303030315c75", + "01000557f1303030315c75303030315c75303030", + "01000558f1315c75303030315c75303030315c75", + "01000559f1303030315c75303030315c75303030", + "0100055af1315c75303030315c75303030315c75", + "0100055bf1303030315c75303030315c75303030", + "0100055cf1315c75303030315c75303030315c75", + "0100055df1303030315c75303030315c75303030", + "0100055ef1315c75303030315c75303030315c75", + "0100055ff1303030315c75303030315c75303030", + "01000560f1315c75303030315c75303030315c75", + "01000561f1303030315c75303030315c75303030", + "01000562f1315c75303030315c75303030315c75", + "01000563f1303030315c75303030315c75303030", + "01000564f1315c75303030315c75303030315c75", + "01000565f1303030315c7530303031227d2c7b22", + "01000566f16964223a2230303030303030303030", + "01000567f1303030303030303030303030303030", + "01000568f130303030303035222c226e616d6522", + "01000569f13a225c75303030315c75303030315c", + "0100056af175303030315c75303030315c753030", + "0100056bf130315c75303030315c75303030315c", + "0100056cf175303030315c75303030315c753030", + "0100056df130315c75303030315c75303030315c", + "0100056ef175303030315c75303030315c753030", + "0100056ff130315c75303030315c75303030315c", + "01000570f175303030315c75303030315c753030", + "01000571f130315c75303030315c75303030315c", + "01000572f175303030315c75303030315c753030", + "01000573f130315c75303030315c75303030315c", + "01000574f175303030315c75303030315c753030", + "01000575f130315c75303030315c75303030315c", + "01000576f175303030315c75303030315c753030", + "01000577f130315c75303030315c75303030315c", + "01000578f175303030315c75303030315c753030", + "01000579f13031227d2c7b226964223a22303030", + "0100057af1303030303030303030303030303030", + "0100057bf1303030303030303030303030303622", + "0100057cf12c226e616d65223a225c7530303031", + "0100057df15c75303030315c75303030315c7530", + "0100057ef13030315c75303030315c7530303031", + "0100057ff15c75303030315c75303030315c7530", + "01000580f13030315c75303030315c7530303031", + "01000581f15c75303030315c75303030315c7530", + "01000582f13030315c75303030315c7530303031", + "01000583f15c75303030315c75303030315c7530", + "01000584f13030315c75303030315c7530303031", + "01000585f15c75303030315c75303030315c7530", + "01000586f13030315c75303030315c7530303031", + "01000587f15c75303030315c75303030315c7530", + "01000588f13030315c75303030315c7530303031", + "01000589f15c75303030315c75303030315c7530", + "0100058af13030315c75303030315c7530303031", + "0100058bf15c75303030315c75303030315c7530", + "0100058cf13030315c7530303031227d2c7b2269", + "0100058df164223a223030303030303030303030", + "0100058ef1303030303030303030303030303030", + "0100058ff1303030303037222c226e616d65223a", + "01000590f1225c75303030315c75303030315c75", + "01000591f1303030315c75303030315c75303030", + "01000592f1315c75303030315c75303030315c75", + "01000593f1303030315c75303030315c75303030", + "01000594f1315c75303030315c75303030315c75", + "01000595f1303030315c75303030315c75303030", + "01000596f1315c75303030315c75303030315c75", + "01000597f1303030315c75303030315c75303030", + "01000598f1315c75303030315c75303030315c75", + "01000599f1303030315c75303030315c75303030", + "0100059af1315c75303030315c75303030315c75", + "0100059bf1303030315c75303030315c75303030", + "0100059cf1315c75303030315c75303030315c75", + "0100059df1303030315c75303030315c75303030", + "0100059ef1315c75303030315c75303030315c75", + "0100059ff1303030315c75303030315c75303030", + "010005a0f131227d2c7b226964223a2230303030", + "010005a1f1303030303030303030303030303030", + "010005a2f130303030303030303030303038222c", + "010005a3f1226e616d65223a225c75303030315c", + "010005a4f175303030315c75303030315c753030", + "010005a5f130315c75303030315c75303030315c", + "010005a6f175303030315c75303030315c753030", + "010005a7f130315c75303030315c75303030315c", + "010005a8f175303030315c75303030315c753030", + "010005a9f130315c75303030315c75303030315c", + "010005aaf175303030315c75303030315c753030", + "010005abf130315c75303030315c75303030315c", + "010005acf175303030315c75303030315c753030", + "010005adf130315c75303030315c75303030315c", + "010005aef175303030315c75303030315c753030", + "010005aff130315c75303030315c75303030315c", + "010005b0f175303030315c75303030315c753030", + "010005b1f130315c75303030315c75303030315c", + "010005b2f175303030315c75303030315c753030", + "010005b3f130315c7530303031227d2c7b226964", + "010005b4f1223a22303030303030303030303030", + "010005b5f1303030303030303030303030303030", + "010005b6f13030303039222c226e616d65223a22", + "010005b7f15c75303030315c75303030315c7530", + "010005b8f13030315c75303030315c7530303031", + "010005b9f15c75303030315c75303030315c7530", + "010005baf13030315c75303030315c7530303031", + "010005bbf15c75303030315c75303030315c7530", + "010005bcf13030315c75303030315c7530303031", + "010005bdf15c75303030315c75303030315c7530", + "010005bef13030315c75303030315c7530303031", + "010005bff15c75303030315c75303030315c7530", + "010005c0f13030315c75303030315c7530303031", + "010005c1f15c75303030315c75303030315c7530", + "010005c2f13030315c75303030315c7530303031", + "010005c3f15c75303030315c75303030315c7530", + "010005c4f13030315c75303030315c7530303031", + "010005c5f15c75303030315c75303030315c7530", + "010005c6f13030315c75303030315c7530303031", + "010005c7f1227d2c7b226964223a223030303030", + "010005c8f1303030303030303030303030303030", + "010005c9f1303030303030303030303061222c22", + "010005caf16e616d65223a225c75303030315c75", + "010005cbf1303030315c75303030315c75303030", + "010005ccf1315c75303030315c75303030315c75", + "010005cdf1303030315c75303030315c75303030", + "010005cef1315c75303030315c75303030315c75", + "010005cff1303030315c75303030315c75303030", + "010005d0f1315c75303030315c75303030315c75", + "010005d1f1303030315c75303030315c75303030", + "010005d2f1315c75303030315c75303030315c75", + "010005d3f1303030315c75303030315c75303030", + "010005d4f1315c75303030315c75303030315c75", + "010005d5f1303030315c75303030315c75303030", + "010005d6f1315c75303030315c75303030315c75", + "010005d7f1303030315c75303030315c75303030", + "010005d8f1315c75303030315c75303030315c75", + "010005d9f1303030315c75303030315c75303030", + "010005daf1315c7530303031227d2c7b22696422", + "010005dbf13a2230303030303030303030303030", + "010005dcf1303030303030303030303030303030", + "010005ddf130303062222c226e616d65223a225c", + "010005def175303030315c75303030315c753030", + "010005dff130315c75303030315c75303030315c", + "010005e0f175303030315c75303030315c753030", + "010005e1f130315c75303030315c75303030315c", + "010005e2f175303030315c75303030315c753030", + "010005e3f130315c75303030315c75303030315c", + "010005e4f175303030315c75303030315c753030", + "010005e5f130315c75303030315c75303030315c", + "010005e6f175303030315c75303030315c753030", + "010005e7f130315c75303030315c75303030315c", + "010005e8f175303030315c75303030315c753030", + "010005e9f130315c75303030315c75303030315c", + "010005eaf175303030315c75303030315c753030", + "010005ebf130315c75303030315c75303030315c", + "010005ecf175303030315c75303030315c753030", + "010005edf130315c75303030315c753030303122", + "010005eef17d5d2c22736368656d615f76657273", + "010005eff1696f6e223a322c2274797065223a22", + "010005f0f170726f66696c6573227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_event_session_cleared.json b/tests/fixtures/ble_goldens/v2_event_session_cleared.json new file mode 100644 index 000000000..8d178ca52 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_event_session_cleared.json @@ -0,0 +1,23 @@ +{ + "name": "v2_event_session_cleared", + "description": "session_cleared notify.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 7, + "message": { + "schema_version": 2, + "type": "session_cleared", + "profile_id": "0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b" + }, + "payload_hex": "7b2270726f66696c655f6964223a223066386534623261396337643465316638613662336335643765396631613262222c22736368656d615f76657273696f6e223a322c2274797065223a2273657373696f6e5f636c6561726564227d", + "frames_hex": [ + "01000700077b2270726f66696c655f6964223a22", + "0100070107306638653462326139633764346531", + "0100070207663861366233633564376539663161", + "01000703073262222c22736368656d615f766572", + "010007040773696f6e223a322c2274797065223a", + "01000705072273657373696f6e5f636c65617265", + "010007060764227d" + ] +} diff --git a/tests/fixtures/ble_goldens/v2_event_shot_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..a018b6264 --- /dev/null +++ b/tests/fixtures/ble_goldens/v2_response_hello.json @@ -0,0 +1,55 @@ +{ + "name": "v2_response_hello", + "description": "hello answered on the v2 control characteristic.", + "direction": "server_to_client", + "characteristic": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474", + "schema_version": 2, + "sequence": 0, + "message": { + "schema_version": 2, + "request_id": "5E0F2C4A-8B1D-4C3E-9F6A-7D2B1C0E9A84", + "ok": true, + "result": { + "schema_version": 2, + "features": [ + "provisional_shots", + "shot_processing", + "profiles", + "power_status", + "shot_deleted", + "club", + "shot_catch_up" + ], + "characteristics": { + "shot": "ED365FE6-3ABF-4FC3-8E44-D9525A22DABD", + "control": "7BA96E63-12C2-4CE0-BB84-3513C7FD1474" + } + } + }, + "payload_hex": "7b226f6b223a747275652c22726571756573745f6964223a2235453046324334412d384231442d344333452d394636412d374432423143304539413834222c22726573756c74223a7b22636861726163746572697374696373223a7b22636f6e74726f6c223a2237424139364536332d313243322d344345302d424238342d333531334337464431343734222c2273686f74223a2245443336354645362d334142462d344643332d384534342d443935323541323244414244227d2c226665617475726573223a5b2270726f766973696f6e616c5f73686f7473222c2273686f745f70726f63657373696e67222c2270726f66696c6573222c22706f7765725f737461747573222c2273686f745f64656c65746564222c22636c7562222c2273686f745f63617463685f7570225d2c22736368656d615f76657273696f6e223a327d2c22736368656d615f76657273696f6e223a327d", + "frames_hex": [ + "01000000177b226f6b223a747275652c22726571", + "0100000117756573745f6964223a223545304632", + "01000002174334412d384231442d344333452d39", + "01000003174636412d3744324231433045394138", + "010000041734222c22726573756c74223a7b2263", + "0100000517686172616374657269737469637322", + "01000006173a7b22636f6e74726f6c223a223742", + "01000007174139364536332d313243322d344345", + "0100000817302d424238342d3335313343374644", + "010000091731343734222c2273686f74223a2245", + "0100000a17443336354645362d334142462d3446", + "0100000b1743332d384534342d44393532354132", + "0100000c173244414244227d2c22666561747572", + "0100000d176573223a5b2270726f766973696f6e", + "0100000e17616c5f73686f7473222c2273686f74", + "0100000f175f70726f63657373696e67222c2270", + "0100001017726f66696c6573222c22706f776572", + "01000011175f737461747573222c2273686f745f", + "010000121764656c65746564222c22636c756222", + "01000013172c2273686f745f63617463685f7570", + "0100001417225d2c22736368656d615f76657273", + "0100001517696f6e223a327d2c22736368656d61", + "01000016175f76657273696f6e223a327d" + ] +} diff --git a/tests/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_v1.json b/tests/fixtures/shot_v1.json new file mode 100644 index 000000000..14493358c --- /dev/null +++ b/tests/fixtures/shot_v1.json @@ -0,0 +1,15 @@ +{ + "ball_speed_mph": 151.4, + "club": "driver", + "club_path_deg": 2.1, + "club_speed_mph": 103.2, + "estimated_carry_yards": 264, + "event_id": "B0D91F0A-7950-4D7E-9DD5-AF9777C190E1", + "launch_angle_horizontal": -1.3, + "launch_angle_vertical": 12.6, + "schema_version": 1, + "smash_factor": 1.47, + "spin_axis_deg": -3.4, + "spin_rpm": 2380, + "timestamp": "2026-07-29T19:42:10.123456" +} diff --git a/tests/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_catch_up.py b/tests/test_ble_catch_up.py new file mode 100644 index 000000000..457163d57 --- /dev/null +++ b/tests/test_ble_catch_up.py @@ -0,0 +1,277 @@ +"""BLE schema v2 catch-up: a reconnecting phone receives the session shots it missed. + +Driven over the loopback harness with the real publisher and server dispatch. +The phone follows the documented negotiation: subscribe to v2 control, write +``hello`` (optionally naming ``last_event_id``), then subscribe to v2 shot. +""" + +import json +from datetime import datetime + +import pytest +from ble_harness import V2_UUIDS, server_loopback, settle + +from openflight import server as server_module +from openflight.ble.protocol import ( + CONTROL_V2_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, + stable_shot_event_id, +) +from openflight.launch_monitor import ClubType, Shot +from openflight.phone_catch_up import CATCH_UP_LIMIT + + +@pytest.fixture +def pi(monkeypatch, tmp_path): + with server_loopback(monkeypatch, tmp_path) as loopback: + yield loopback + + +def _session(monkeypatch, count, *, profile_id=""): + """A mock monitor holding ``count`` shots; returns their event ids in order.""" + monitor = server_module.MockLaunchMonitor() + for index in range(count): + monitor._shots.append( + Shot( + ball_speed_mph=100.0 + index, + timestamp=datetime(2026, 9, 28, 12, index // 60, index % 60, 1000), + club=ClubType.DRIVER, + shot_number=index + 1, + profile_id=profile_id, + ) + ) + monkeypatch.setattr(server_module, "monitor", monitor) + return [stable_shot_event_id(server_module.shot_to_dict(shot)) for shot in monitor.get_shots()] + + +def _publish_latest_live(): + """Publish the session's last shot as the live path would (sets the latest replay).""" + shot_data = server_module.shot_to_dict(server_module.monitor.get_shots()[-1]) + server_module._publish_phone_shot_v2(shot_data, final=True, enrichment=None) + + +def _hello(phone, **payload): + return phone.request( + "hello", + {"client_schema_max": 2, **payload}, + control=CONTROL_V2_CHARACTERISTIC_UUID, + schema_version=2, + ) + + +def _connect(pi, name="v2-app", **hello_payload): + """Negotiate as the documented flow does: control, hello, then shot.""" + phone = pi.central(name) + phone.subscribe(CONTROL_V2_CHARACTERISTIC_UUID) + answer = _hello(phone, **hello_payload) + assert answer["ok"] is True, answer + phone.subscribe(SHOT_V2_CHARACTERISTIC_UUID) + return phone + + +def _received_ids(phone): + return [shot["event_id"] for shot in phone.decoded(SHOT_V2_CHARACTERISTIC_UUID)] + + +def test_hello_advertises_catch_up(pi): + phone = pi.central() + phone.subscribe(CONTROL_V2_CHARACTERISTIC_UUID) + + assert "shot_catch_up" in _hello(phone)["result"]["features"] + + +def test_hello_without_anchor_replays_whole_session_in_order(pi, monkeypatch): + ids = _session(monkeypatch, 3) + + phone = _connect(pi) + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 3) + settle(pi) + assert _received_ids(phone) == ids + assert all(shot["final"] for shot in phone.decoded(SHOT_V2_CHARACTERISTIC_UUID)) + + +def test_hello_with_known_anchor_resends_it_and_sends_missed_shots(pi, monkeypatch): + ids = _session(monkeypatch, 5) + + phone = _connect(pi, last_event_id=ids[1]) + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 4) + settle(pi) + assert _received_ids(phone) == ids[1:] + + +def test_hello_with_latest_anchor_resends_only_that_shot_once(pi, monkeypatch): + ids = _session(monkeypatch, 3) + _publish_latest_live() + + phone = _connect(pi, last_event_id=ids[-1]) + phone.wait_for(SHOT_V2_CHARACTERISTIC_UUID) + settle(pi, rounds=10) + + # The catch-up replaces the latest-shot replay rather than adding to it. + assert _received_ids(phone) == [ids[-1]] + + +def test_anchor_held_as_provisional_is_brought_up_to_final(pi, monkeypatch): + """The phone saw the provisional, disconnected, and missed the final.""" + ids = _session(monkeypatch, 2) + shot_data = server_module.shot_to_dict(server_module.monitor.get_shots()[-1]) + server_module._publish_phone_shot_v2(shot_data, final=False, enrichment={"status": "pending"}) + server_module._publish_phone_shot_v2(shot_data, final=True, enrichment={"status": "complete"}) + + phone = _connect(pi, last_event_id=ids[-1]) + + shot = phone.wait_for(SHOT_V2_CHARACTERISTIC_UUID) + assert shot["event_id"] == ids[-1] + assert shot["final"] is True + assert shot["enrichment"] == {"status": "complete"} + + +def test_unknown_anchor_replays_whole_session(pi, monkeypatch): + ids = _session(monkeypatch, 2) + + phone = _connect(pi, last_event_id="11111111-2222-3333-4444-555555555555") + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 2) + settle(pi) + assert _received_ids(phone) == ids + + +@pytest.mark.parametrize("anchor", [7, "", None, {"id": "x"}]) +def test_invalid_anchor_does_not_fail_hello(pi, monkeypatch, anchor): + ids = _session(monkeypatch, 2) + + phone = _connect(pi, last_event_id=anchor) + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 2) + settle(pi) + assert _received_ids(phone) == ids + + +def test_more_missed_shots_than_the_queue_holds_are_all_delivered(pi, monkeypatch): + count = pi.publisher.queue_size + 4 + ids = _session(monkeypatch, count) + + phone = _connect(pi) + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, count, timeout=15) + settle(pi) + assert _received_ids(phone) == ids + + +def test_catch_up_is_capped_to_most_recent_shots(pi, monkeypatch): + ids = _session(monkeypatch, CATCH_UP_LIMIT + 3) + + phone = _connect(pi) + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, CATCH_UP_LIMIT, timeout=20) + settle(pi, rounds=10) + assert _received_ids(phone) == ids[-CATCH_UP_LIMIT:] + + +def test_hello_after_shot_subscription_sends_catch_up_immediately(pi, monkeypatch): + ids = _session(monkeypatch, 3) + phone = pi.central() + phone.subscribe(*V2_UUIDS) + settle(pi) + + assert _hello(phone, last_event_id=ids[1])["ok"] is True + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 2) + settle(pi) + assert _received_ids(phone) == ids[1:] + + +def test_replayed_shot_is_byte_identical_to_last_published_version(pi, monkeypatch): + """A provisional-then-final shot replays as the final, enrichment included.""" + _session(monkeypatch, 1) + shot_data = server_module.shot_to_dict(server_module.monitor.get_shots()[0]) + server_module._publish_phone_shot_v2(shot_data, final=False, enrichment={"status": "pending"}) + server_module._publish_phone_shot_v2(shot_data, final=True, enrichment={"status": "complete"}) + published = server_module.phone_shot_cache.get(stable_shot_event_id(shot_data)) + + phone = _connect(pi) + + shot = phone.wait_for(SHOT_V2_CHARACTERISTIC_UUID) + settle(pi) + assert published is not None + assert shot == json.loads(published) + assert shot["final"] is True + assert shot["enrichment"] == {"status": "complete"} + assert phone.decoded(SHOT_V2_CHARACTERISTIC_UUID) == [shot] + + +def test_deleted_shots_are_not_replayed(pi, monkeypatch): + ids = _session(monkeypatch, 3) + first = server_module.monitor.get_shots()[0] + server_module.apply_delete_shot({"timestamp": first.timestamp.isoformat()}) + + phone = _connect(pi) + + phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 2) + settle(pi) + assert _received_ids(phone) == ids[1:] + + +def test_cleared_profile_shots_are_not_replayed(pi, monkeypatch): + active_id = server_module.get_profile_store().get_active().id + _session(monkeypatch, 2, profile_id=active_id) + server_module.apply_clear_session({}) + + phone = _connect(pi) + settle(pi, rounds=10) + + assert _received_ids(phone) == [] + + +def test_disconnect_before_shot_subscription_discards_pending_catch_up(pi, monkeypatch): + ids = _session(monkeypatch, 3) + _publish_latest_live() + phone = pi.central() + phone.subscribe(CONTROL_V2_CHARACTERISTIC_UUID) + assert _hello(phone)["ok"] is True + phone.disconnect() + settle(pi) + + # Reconnects without a new hello: only the latest-shot replay, as before. + phone.subscribe(SHOT_V2_CHARACTERISTIC_UUID) + phone.wait_for(SHOT_V2_CHARACTERISTIC_UUID) + settle(pi, rounds=10) + assert _received_ids(phone) == [ids[-1]] + + +def test_v1_hello_does_not_trigger_catch_up(pi, monkeypatch): + _session(monkeypatch, 3) + phone = pi.central() + phone.subscribe(CONTROL_V2_CHARACTERISTIC_UUID) + + answer = _hello(phone, client_schema_max=1) + phone.subscribe(SHOT_V2_CHARACTERISTIC_UUID) + settle(pi, rounds=10) + + assert answer["result"] == {"schema_version": 1, "features": []} + assert _received_ids(phone) == [] + + +def test_catch_up_failure_keeps_hello_and_falls_back_to_latest_replay(pi, monkeypatch): + ids = _session(monkeypatch, 2) + _publish_latest_live() + + def broken(_last_event_id): + raise RuntimeError("session unavailable") + + monkeypatch.setattr(pi.publisher, "catch_up_provider", broken) + + phone = _connect(pi) + + phone.wait_for(SHOT_V2_CHARACTERISTIC_UUID) + settle(pi, rounds=10) + assert _received_ids(phone) == [ids[-1]] + + +def test_no_monitor_means_no_catch_up(pi): + phone = _connect(pi) + settle(pi, rounds=10) + + assert _received_ids(phone) == [] diff --git a/tests/test_ble_goldens.py b/tests/test_ble_goldens.py new file mode 100644 index 000000000..315555bdc --- /dev/null +++ b/tests/test_ble_goldens.py @@ -0,0 +1,112 @@ +"""Cross-language BLE goldens in tests/fixtures/ble_goldens. + +Server-to-client files must match what the encoder produces today (regenerate +with ``uv run python scripts/ble/generate_goldens.py``). Client-to-server files +are committed by client implementations; they must reassemble to their JSON +and be answered as their ``expect`` block says. +""" + +import importlib.util +import json +from pathlib import Path + +import pytest +from ble_harness import V1_UUIDS, V2_UUIDS, server_loopback + +from openflight.ble.protocol import ( + FRAME_SIZE, + MAX_MESSAGE_SIZE, + parse_fragment, + reassemble_fragments, +) + +ROOT = Path(__file__).resolve().parents[1] +GOLDENS_DIR = ROOT / "tests" / "fixtures" / "ble_goldens" + + +def _load_generator(): + path = ROOT / "scripts" / "ble" / "generate_goldens.py" + spec = importlib.util.spec_from_file_location("generate_ble_goldens", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +GENERATOR = _load_generator() + + +def _files(direction): + for path in sorted(GOLDENS_DIR.glob("*.json")): + golden = json.loads(path.read_text(encoding="utf-8")) + if golden["direction"] == direction: + yield pytest.param(golden, id=path.stem) + + +def test_committed_server_goldens_match_the_encoder(): + expected = GENERATOR.build_goldens() + committed = { + path.stem: path.read_text(encoding="utf-8") + for path in GOLDENS_DIR.glob("*.json") + if json.loads(path.read_text(encoding="utf-8"))["direction"] == "server_to_client" + } + + assert set(committed) == set(expected) + for name, golden in expected.items(): + assert committed[name] == GENERATOR.render(golden), ( + f"{name}.json is stale; run scripts/ble/generate_goldens.py" + ) + + +@pytest.mark.parametrize("golden", list(_files("server_to_client"))) +def test_server_golden_frames_decode_to_their_message(golden): + frames = [bytes.fromhex(item) for item in golden["frames_hex"]] + payload = bytes.fromhex(golden["payload_hex"]) + + assert all(len(frame) <= FRAME_SIZE for frame in frames) + assert {parse_fragment(frame)[0] for frame in frames} == {golden["sequence"]} + assert reassemble_fragments(reversed(frames)) == payload + assert len(payload) <= MAX_MESSAGE_SIZE + assert json.loads(payload.decode("utf-8")) == golden["message"] + assert golden["message"]["schema_version"] == golden["schema_version"] + + +def test_v1_goldens_are_ascii_and_v2_goldens_are_utf8(): + for golden in GENERATOR.build_goldens().values(): + payload = bytes.fromhex(golden["payload_hex"]) + if golden["schema_version"] == 1: + assert payload.isascii() + profiles = GENERATOR.build_goldens()["v2_event_profiles"] + assert "Zoë ⛳".encode() in bytes.fromhex(profiles["payload_hex"]) + + +def test_shot_goldens_share_one_event_id(): + goldens = GENERATOR.build_goldens() + provisional = goldens["v2_shot_provisional"]["message"] + final = goldens["v2_shot_final"]["message"] + + assert provisional["event_id"] == final["event_id"] + assert (provisional["final"], final["final"]) == (False, True) + + +@pytest.mark.parametrize("golden", list(_files("client_to_server"))) +def test_client_golden_reassembles_and_is_dispatched(golden, monkeypatch, tmp_path): + frames = [bytes.fromhex(item) for item in golden["frames_hex"]] + payload = reassemble_fragments(frames) + assert payload == bytes.fromhex(golden["payload_hex"]) + assert json.loads(payload) == golden["message"] + + with server_loopback(monkeypatch, tmp_path) as loopback: + phone = loopback.central("golden-client") + phone.subscribe(*V1_UUIDS, *V2_UUIDS) + for frame in frames: + loopback.write(golden["characteristic"], frame) + response = phone.wait_for( + golden["characteristic"], + lambda message: message.get("request_id") == golden["message"]["request_id"], + ) + + expect = golden["expect"] + assert response["ok"] is expect["ok"] + assert response["schema_version"] == expect["schema_version"] + for key, value in expect.get("result", {}).items(): + assert response["result"][key] == value diff --git a/tests/test_ble_loopback.py b/tests/test_ble_loopback.py new file mode 100644 index 000000000..90bd3b18a --- /dev/null +++ b/tests/test_ble_loopback.py @@ -0,0 +1,399 @@ +"""End-to-end BLE tests over the loopback harness: no Pi, radio or bless needed. + +Each test drives the real ``BleShotPublisher`` (own thread and event loop) and +the real server command dispatch, with virtual centrals standing in for +phones. A version-one central models jake-fishtech's iOS app and must only +ever see version-one bytes. +""" + +import json +import threading +from datetime import datetime + +import pytest +from ble_harness import V1_UUIDS, V2_UUIDS, server_loopback, settle + +from openflight import server as server_module +from openflight.ble.protocol import ( + CONTROL_CHARACTERISTIC_UUID, + CONTROL_V2_CHARACTERISTIC_UUID, + SHOT_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, + encode_club_event, +) +from openflight.launch_monitor import ClubType, Shot + + +@pytest.fixture +def pi(monkeypatch, tmp_path): + """A monitor-less OpenFlight server with BLE on the loopback harness.""" + with server_loopback(monkeypatch, tmp_path) as loopback: + yield loopback + + +def _slow_enrichment(monkeypatch): + """Take the provisional-then-final path that optional hardware triggers.""" + monkeypatch.setattr(server_module, "camera_capture_runtime", object()) + monkeypatch.setattr(server_module, "shot_enrichment_task", None) + monkeypatch.setattr( + server_module, + "shot_enrichment_queue", + server_module.queue.Queue(maxsize=server_module._SHOT_ENRICHMENT_QUEUE_CAPACITY), + ) + monkeypatch.setattr( + server_module, + "_enrich_shot_from_optional_hardware", + lambda _shot: server_module._ShotEnrichmentResult(camera_capture_ms=5.0), + ) + + def start_background_task(target, *args, **kwargs): + thread = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True) + thread.start() + return thread + + monkeypatch.setattr(server_module.socketio, "start_background_task", start_background_task) + + +def _hardware_shot(second=0): + return Shot( + ball_speed_mph=151.4, + club_speed_mph=103.2, + timestamp=datetime(2026, 9, 25, 12, 0, second, 123456), + impact_timestamp=100.0 + second, + club=ClubType.DRIVER, + ) + + +def _wait_idle(): + with server_module._shot_finalization_condition: + assert server_module._shot_finalization_condition.wait_for( + lambda: ( + not server_module._shot_finalization_order + and not server_module._shot_finalization_running + ), + timeout=5, + ) + + +def test_v1_central_replays_latest_shot_on_subscribe(pi): + pi.publisher.publish( + { + "timestamp": "2026-09-25T12:00:00", + "club": "driver", + "ball_speed_mph": 150.0, + "estimated_carry_yards": 250, + } + ) + phone = pi.central("v1-app") + phone.subscribe(*V1_UUIDS) + + shot = phone.wait_for(SHOT_CHARACTERISTIC_UUID) + assert shot["schema_version"] == 1 + assert shot["ball_speed_mph"] == 150.0 + assert phone.raw(SHOT_CHARACTERISTIC_UUID)[0] == pi.publisher._latest_payload + + +def test_hello_negotiates_on_both_control_characteristics(pi): + phone = pi.central("v2-app") + phone.subscribe(*V1_UUIDS, *V2_UUIDS) + + v1_answer = phone.request("hello", {"client_schema_max": 2}) + v2_answer = phone.request( + "hello", + {"client_schema_max": 2}, + control=CONTROL_V2_CHARACTERISTIC_UUID, + schema_version=2, + ) + + assert v1_answer["schema_version"] == 1 # v1 envelope on the v1 characteristic + assert v1_answer["ok"] is True + assert v1_answer["result"]["schema_version"] == 2 + assert v1_answer["result"]["characteristics"] == { + "shot": SHOT_V2_CHARACTERISTIC_UUID, + "control": CONTROL_V2_CHARACTERISTIC_UUID, + } + assert "profiles" in v1_answer["result"]["features"] + assert v2_answer["schema_version"] == 2 + assert v2_answer["result"] == v1_answer["result"] + + v1_client = phone.request("hello", {"client_schema_max": 1}) + assert v1_client["result"] == {"schema_version": 1, "features": []} + + invalid = phone.request("hello", {"client_schema_max": "two"}) + assert invalid["ok"] is False + assert "client_schema_max" in invalid["error"] + + +def test_provisional_and_final_shot_share_event_id_and_v1_sees_only_final(pi, monkeypatch): + _slow_enrichment(monkeypatch) + v1_phone = pi.central("v1-app") + v2_phone = pi.central("v2-app") + v1_phone.subscribe(*V1_UUIDS) + v2_phone.subscribe(*V2_UUIDS) + + server_module.on_shot_detected(_hardware_shot()) + _wait_idle() + + provisional, final = v2_phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 2) + assert provisional["final"] is False + assert provisional["enrichment"] == {"status": "pending"} + assert final["final"] is True + assert final["enrichment"] == {"status": "complete"} + assert provisional["event_id"] == final["event_id"] + assert final["schema_version"] == 2 and final["type"] == "shot" + assert final["shot_number"] == provisional["shot_number"] is not None + + v1_shot = v1_phone.wait_for(SHOT_CHARACTERISTIC_UUID) + settle(pi) + assert len(v1_phone.decoded(SHOT_CHARACTERISTIC_UUID)) == 1 + assert v1_shot["schema_version"] == 1 + assert "final" not in v1_shot and "type" not in v1_shot + assert v1_shot["event_id"] != final["event_id"] # v1 keeps its per-publish uuid4 + # The v1 central never subscribed to the v2 pair, so it saw nothing there. + assert v1_phone.decoded(SHOT_V2_CHARACTERISTIC_UUID) == [] + assert v1_phone.decoded(CONTROL_V2_CHARACTERISTIC_UUID) == [] + + +def test_club_commands_and_club_changed_reach_each_schema(pi): + v1_phone = pi.central("v1-app") + v2_phone = pi.central("v2-app") + v1_phone.subscribe(*V1_UUIDS) + v2_phone.subscribe(*V2_UUIDS) + + answer = v2_phone.request( + "set_club", + {"club": "7-iron"}, + control=CONTROL_V2_CHARACTERISTIC_UUID, + schema_version=2, + ) + assert answer == { + "schema_version": 2, + "request_id": answer["request_id"], + "ok": True, + "result": {"status": "applied", "club": "7-iron"}, + } + + v1_event = v1_phone.wait_for( + CONTROL_CHARACTERISTIC_UUID, lambda message: message.get("type") == "club_changed" + ) + v2_event = v2_phone.wait_for( + CONTROL_V2_CHARACTERISTIC_UUID, lambda message: message.get("type") == "club_changed" + ) + assert v1_event == {"schema_version": 1, "type": "club_changed", "club": "7-iron"} + assert v2_event == {"schema_version": 2, "type": "club_changed", "club": "7-iron"} + raw_v1_events = [ + raw for raw in v1_phone.raw(CONTROL_CHARACTERISTIC_UUID) if b"club_changed" in raw + ] + assert raw_v1_events == [encode_club_event("7-iron")] + assert ("club_changed", {"club": "7-iron"}) in pi.emitted + + current = v1_phone.request("get_club") + assert current["result"] == {"status": "current", "club": "7-iron"} + + +def test_profiles_commands_route_through_socketio_operations(pi): + store = server_module.get_profile_store() + second = store.add("Sam") + store.set_active(store.list()[0].id) + phone = pi.central("v2-app") + phone.subscribe(*V2_UUIDS) + + ack = phone.request("get_profiles", control=CONTROL_V2_CHARACTERISTIC_UUID, schema_version=2) + assert ack["result"] == {"status": "sent"} + profiles = phone.wait_for( + CONTROL_V2_CHARACTERISTIC_UUID, lambda message: message.get("type") == "profiles" + ) + assert [item["name"] for item in profiles["profiles"]] == ["Profile 1", "Sam"] + assert set(profiles["profiles"][0]) == {"id", "name"} + + answer = phone.request( + "set_active_profile", + {"profile_id": second.id}, + control=CONTROL_V2_CHARACTERISTIC_UUID, + schema_version=2, + ) + assert answer["result"] == {"status": "applied", "active_profile_id": second.id} + assert store.get_active().id == second.id + socket_profiles = [payload for event, payload in pi.emitted if event == "profiles"] + assert socket_profiles[-1]["active_profile_id"] == second.id + + rejected = phone.request( + "set_active_profile", + {"profile_id": "nope"}, + control=CONTROL_V2_CHARACTERISTIC_UUID, + schema_version=2, + ) + assert rejected["ok"] is False + assert rejected["error"] == "Unknown profile" + + +def test_calibration_without_iwr6843_returns_the_409_error(pi): + phone = pi.central("v1-app") + phone.subscribe(*V1_UUIDS) + + answer = phone.request("iwr6843_orientation_calibration", {"mount_tilt_deg": 12.0}) + + assert answer["ok"] is False + assert answer["error"] == "TI IWR6843 radar is not enabled" + + +def test_unknown_and_v2_only_commands_error_on_the_v1_characteristic(pi): + phone = pi.central("v1-app") + phone.subscribe(*V1_UUIDS, *V2_UUIDS) + + unknown = phone.request("launch_rocket") + v2_only_on_v1 = phone.request("get_profiles") + v2_envelope_on_v1 = phone.request("get_club", schema_version=2) + unknown_v2 = phone.request( + "launch_rocket", control=CONTROL_V2_CHARACTERISTIC_UUID, schema_version=2 + ) + + assert unknown == { + "schema_version": 1, + "request_id": unknown["request_id"], + "ok": False, + "error": "Unsupported phone command: launch_rocket", + } + assert v2_only_on_v1["error"] == "Unsupported phone command: get_profiles" + assert v2_envelope_on_v1["error"] == "Unsupported control schema version" + assert unknown_v2["schema_version"] == 2 + assert unknown_v2["error"] == "Unsupported phone command: launch_rocket" + + +def test_v2_session_and_power_commands(pi, monkeypatch): + class _Power: + status = None + + phone = pi.central("v2-app") + phone.subscribe(*V2_UUIDS) + + disabled = phone.request( + "get_power_status", control=CONTROL_V2_CHARACTERISTIC_UUID, schema_version=2 + ) + assert disabled["error"] == "Battery monitoring is not enabled" + monkeypatch.setattr(server_module, "power_monitor", _Power()) + waiting = phone.request( + "get_power_status", control=CONTROL_V2_CHARACTERISTIC_UUID, schema_version=2 + ) + assert waiting["error"] == "No battery reading yet" + + +def test_destructive_commands_are_not_available_over_ble(pi): + """BLE is unauthenticated, so it is read-and-select only.""" + phone = pi.central("v2-app") + phone.subscribe(*V1_UUIDS, *V2_UUIDS) + + for command, payload in ( + ("clear_session", {}), + ("delete_shot", {"timestamp": "2026-09-25T12:00:00"}), + ): + for control, schema in ( + (CONTROL_CHARACTERISTIC_UUID, 1), + (CONTROL_V2_CHARACTERISTIC_UUID, 2), + ): + answer = phone.request(command, payload, control=control, schema_version=schema) + assert answer["ok"] is False + assert answer["error"] == f"Unsupported phone command: {command}" + assert pi.emitted == [] + + +def test_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("v2-app") + phone.subscribe(*V2_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_V2_CHARACTERISTIC_UUID, lambda message: message.get("type") == "shot_deleted" + ) + cleared = phone.wait_for( + CONTROL_V2_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_unsubscribing_one_pair_keeps_the_other_flowing(pi): + v1_phone = pi.central("v1-app") + v2_phone = pi.central("v2-app") + v1_phone.subscribe(*V1_UUIDS) + v2_phone.subscribe(*V2_UUIDS) + assert pi.publisher.subscribed and pi.publisher.v2_subscribed + + v2_phone.disconnect() + assert pi.publisher.subscribed + assert not pi.publisher.v2_subscribed + + server_module.apply_club_selection({"club": "pw"}) + event = v1_phone.wait_for( + CONTROL_CHARACTERISTIC_UUID, lambda message: message.get("type") == "club_changed" + ) + assert event["club"] == "pw" + + v1_phone.disconnect() + assert not pi.publisher.subscribed + + +def test_v2_central_gets_latest_v2_shot_replayed(pi): + pi.publisher.publish_v2_shot( + { + "timestamp": "2026-09-25T12:00:00", + "club": "driver", + "ball_speed_mph": 150.0, + "estimated_carry_yards": 250, + "shot_number": 3, + }, + final=True, + ) + phone = pi.central("v2-app") + # Control first, as the negotiation flow does, then the shot characteristic. + phone.subscribe(CONTROL_V2_CHARACTERISTIC_UUID) + settle(pi) + phone.subscribe(SHOT_V2_CHARACTERISTIC_UUID) + + shot = phone.wait_for(SHOT_V2_CHARACTERISTIC_UUID) + + assert shot["shot_number"] == 3 + assert shot["final"] is True + settle(pi) + assert len(phone.decoded(SHOT_V2_CHARACTERISTIC_UUID)) == 1 + + +def test_client_frames_for_two_commands_do_not_interleave_responses(pi): + phone = pi.central("v2-app") + phone.subscribe(*V2_UUIDS) + first = phone.command("get_club", control=CONTROL_V2_CHARACTERISTIC_UUID, schema_version=2) + second = phone.command( + "hello", + {"client_schema_max": 2}, + control=CONTROL_V2_CHARACTERISTIC_UUID, + schema_version=2, + ) + + answers = { + message["request_id"]: message + for message in ( + phone.wait_for(CONTROL_V2_CHARACTERISTIC_UUID, lambda m: m.get("request_id") == first), + phone.wait_for(CONTROL_V2_CHARACTERISTIC_UUID, lambda m: m.get("request_id") == second), + ) + } + assert answers[first]["result"]["club"] == "driver" + assert answers[second]["result"]["schema_version"] == 2 + # Every notification reassembled into valid JSON: no interleaved fragments. + for raw in phone.raw(CONTROL_V2_CHARACTERISTIC_UUID): + json.loads(raw) diff --git a/tests/test_ble_protocol.py b/tests/test_ble_protocol.py new file mode 100644 index 000000000..4670b6db0 --- /dev/null +++ b/tests/test_ble_protocol.py @@ -0,0 +1,171 @@ +"""Tests for the public OpenFlight BLE shot contract.""" + +import json +from pathlib import Path + +import pytest + +from openflight.ble.protocol import ( + CONTROL_CHARACTERISTIC_UUID, + FRAGMENT_PAYLOAD_SIZE, + FRAME_SIZE, + MAX_MESSAGE_SIZE, + FragmentReassembler, + build_shot_event, + encode_shot_event, + fragment_payload, + parse_fragment, + reassemble_fragments, +) + +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "shot_v1.json" + + +def _shot_data(): + return { + "timestamp": "2026-07-29T19:42:10.123456", + "club": "driver", + "ball_speed_mph": 151.4, + "club_speed_mph": 103.2, + "smash_factor": 1.47, + "estimated_carry_yards": 264, + "launch_angle_vertical": 12.6, + "launch_angle_horizontal": -1.3, + "spin_rpm": 2380, + "club_path_deg": 2.1, + "spin_axis_deg": -3.4, + "spin_candidates": [{"rpm": 2380}], + } + + +def test_build_shot_event_is_stable_and_display_focused(): + expected = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + event = build_shot_event(_shot_data(), event_id=expected["event_id"]) + + assert event == expected + assert "spin_candidates" not in event + + +def test_missing_measurements_are_explicit_nulls(): + shot = _shot_data() + for key in ( + "club_speed_mph", + "smash_factor", + "launch_angle_vertical", + "launch_angle_horizontal", + "spin_rpm", + "club_path_deg", + "spin_axis_deg", + ): + shot.pop(key) + + event = json.loads(encode_shot_event(shot, event_id="shot-null")) + + assert event["club_speed_mph"] is None + assert event["launch_angle_vertical"] is None + assert event["spin_rpm"] is None + assert event["spin_axis_deg"] is None + + +def test_encoding_is_compact_and_deterministic(): + encoded = encode_shot_event(_shot_data(), event_id="shot-1") + + assert b" " not in encoded + assert encoded == encode_shot_event(_shot_data(), event_id="shot-1") + + +def test_non_finite_numbers_are_rejected(): + shot = _shot_data() + shot["ball_speed_mph"] = float("nan") + + with pytest.raises(ValueError, match="Out of range float values"): + encode_shot_event(shot) + + +@pytest.mark.parametrize( + ("payload_size", "expected_frames"), + [ + (1, 1), + (FRAGMENT_PAYLOAD_SIZE, 1), + (FRAGMENT_PAYLOAD_SIZE + 1, 2), + (FRAGMENT_PAYLOAD_SIZE * 3, 3), + ], +) +def test_fragment_boundaries(payload_size, expected_frames): + frames = fragment_payload(b"x" * payload_size, sequence=42) + + assert len(frames) == expected_frames + assert all(len(frame) <= FRAME_SIZE for frame in frames) + assert reassemble_fragments(reversed(frames)) == b"x" * payload_size + + +def test_duplicate_fragment_is_harmless(): + frames = fragment_payload(b"a complete payload", sequence=7) + + assert reassemble_fragments([frames[0], frames[0], *frames[1:]]) == b"a complete payload" + + +def test_incomplete_message_is_rejected(): + frames = fragment_payload(b"x" * (FRAGMENT_PAYLOAD_SIZE + 1), sequence=8) + + with pytest.raises(ValueError, match="incomplete"): + reassemble_fragments(frames[:-1]) + + +def test_mixed_sequences_are_rejected(): + first = fragment_payload(b"first message that has chunks", sequence=1) + second = fragment_payload(b"second message", sequence=2) + + with pytest.raises(ValueError, match="different messages"): + reassemble_fragments([first[0], second[0]]) + + +def test_invalid_frame_metadata_is_rejected(): + frame = bytearray(fragment_payload(b"payload", sequence=3)[0]) + frame[4] = 0 + + with pytest.raises(ValueError, match="metadata"): + parse_fragment(bytes(frame)) + + +def test_oversized_payload_is_rejected(): + with pytest.raises(ValueError, match="maximum"): + fragment_payload(b"x" * (MAX_MESSAGE_SIZE + 1), sequence=0) + + +def test_control_characteristic_uuid_is_distinct_from_shot_notifications(): + from openflight.ble.protocol import SHOT_CHARACTERISTIC_UUID + + assert CONTROL_CHARACTERISTIC_UUID != SHOT_CHARACTERISTIC_UUID + + +def test_incremental_reassembler_completes_control_payload(): + payload = json.dumps( + { + "schema_version": 1, + "type": "iwr6843_orientation_calibration", + "request_id": "request-1", + "payload": {"mount_tilt_deg": 12.25}, + } + ).encode() + reassembler = FragmentReassembler() + + result = None + for frame in fragment_payload(payload, sequence=42): + result = reassembler.append(frame) or result + + assert result == payload + + +def test_incremental_reassembler_recovers_when_a_new_sequence_arrives(): + old_frames = fragment_payload(b"old incomplete payload", sequence=1) + new_payload = b"new complete payload" + new_frames = fragment_payload(new_payload, sequence=2) + reassembler = FragmentReassembler() + + assert reassembler.append(old_frames[0]) is None + result = None + for frame in new_frames: + result = reassembler.append(frame) or result + + assert result == new_payload diff --git a/tests/test_ble_protocol_v2.py b/tests/test_ble_protocol_v2.py new file mode 100644 index 000000000..bc5204dba --- /dev/null +++ b/tests/test_ble_protocol_v2.py @@ -0,0 +1,328 @@ +"""Tests for the BLE/SSE schema v2 payloads, negotiation and size budget.""" + +import json +from pathlib import Path + +import pytest + +from openflight.ble.protocol import ( + CONTROL_CHARACTERISTIC_UUID, + CONTROL_V2_CHARACTERISTIC_UUID, + MAX_MESSAGE_SIZE, + SERVICE_UUID, + SHOT_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, + V2_FEATURES, + build_event_v2, + build_hello_result, + build_power_status_event, + build_profiles_event, + build_shot_deleted_event, + build_shot_event, + build_shot_event_v2, + encode_message_v2, + encode_shot_event, + encode_shot_event_v2, + fragment_payload, + stable_shot_event_id, +) +from openflight.profiles import MAX_NAME_LENGTH, MAX_PROFILES + +FIXTURES = Path(__file__).parent / "fixtures" +SHOT_V2_FIXTURE = FIXTURES / "shot_v2.json" + +V1_KEYS = { + "schema_version", + "event_id", + "timestamp", + "club", + "ball_speed_mph", + "estimated_carry_yards", + "club_speed_mph", + "smash_factor", + "launch_angle_vertical", + "launch_angle_horizontal", + "spin_rpm", + "club_path_deg", + "spin_axis_deg", +} +V2_EXTRA_KEYS = { + "type", + "final", + "shot_number", + "profile_id", + "profile_name", + "carry_range", + "spin_source", + "launch_angle_confidence", + "enrichment", +} + + +def _shot_data(**overrides): + data = { + "timestamp": "2026-09-25T14:03:07.412345", + "club": "7-iron", + "ball_speed_mph": 106.1, + "club_speed_mph": 83.5, + "smash_factor": 1.27, + "estimated_carry_yards": 152, + "launch_angle_vertical": 21.2, + "launch_angle_horizontal": -0.7, + "spin_rpm": 6482, + "club_path_deg": 2.5, + "spin_axis_deg": -1.6, + "shot_number": 7, + "profile_id": "0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b", + "profile_name": "Zoë", + "carry_range": [144, 160], + "spin_source": "measured", + "launch_angle_confidence": 0.6, + "readings": [1, 2, 3], + } + data.update(overrides) + return data + + +def test_v2_characteristics_are_new_and_distinct(): + uuids = { + SERVICE_UUID, + SHOT_CHARACTERISTIC_UUID, + CONTROL_CHARACTERISTIC_UUID, + SHOT_V2_CHARACTERISTIC_UUID, + CONTROL_V2_CHARACTERISTIC_UUID, + } + assert len(uuids) == 5 + + +def test_v2_shot_is_v1_fields_plus_v2_fields(): + event = build_shot_event_v2(_shot_data(), final=True) + + assert set(event) == V1_KEYS | V2_EXTRA_KEYS + assert event["schema_version"] == 2 + assert event["type"] == "shot" + assert event["final"] is True + assert event["enrichment"] is None + assert event["shot_number"] == 7 + assert event["carry_range"] == [144, 160] + assert "readings" not in event + + +def test_v1_shot_encoding_is_unchanged_by_v2(): + """The version-one builder must not grow v2 fields.""" + event = build_shot_event(_shot_data(), event_id="B0D91F0A-7950-4D7E-9DD5-AF9777C190E1") + + assert set(event) == V1_KEYS + assert event["schema_version"] == 1 + assert encode_shot_event(_shot_data(), event_id="x").isascii() + + +def test_missing_v2_fields_are_explicit_nulls_and_blanks_become_null(): + data = _shot_data(profile_id="", profile_name="", spin_source="") + for field in ("shot_number", "carry_range", "launch_angle_confidence"): + data.pop(field) + + event = build_shot_event_v2(data, final=False, enrichment={"status": "pending"}) + + for field in ( + "shot_number", + "profile_id", + "profile_name", + "carry_range", + "spin_source", + "launch_angle_confidence", + ): + assert event[field] is None + assert event["enrichment"] == {"status": "pending"} + + +def test_provisional_and_final_share_a_stable_event_id(): + provisional = build_shot_event_v2(_shot_data(), final=False, enrichment={"status": "pending"}) + final = build_shot_event_v2( + _shot_data(ball_speed_mph=107.0), final=True, enrichment={"status": "complete"} + ) + other_shot = build_shot_event_v2(_shot_data(shot_number=8), final=True) + other_session = build_shot_event_v2( + _shot_data(timestamp="2026-09-26T09:00:00.000001"), final=True + ) + + assert provisional["event_id"] == final["event_id"] == stable_shot_event_id(_shot_data()) + assert other_shot["event_id"] != final["event_id"] + assert other_session["event_id"] != final["event_id"] + + +def test_enrichment_status_is_validated(): + with pytest.raises(ValueError): + build_shot_event_v2(_shot_data(), final=True, enrichment={"status": "done"}) + + skipped = build_shot_event_v2( + _shot_data(), final=True, enrichment={"status": "skipped", "reason": "deadline"} + ) + assert skipped["enrichment"] == {"status": "skipped", "reason": "deadline"} + + +def test_v2_encoding_is_compact_sorted_utf8(): + payload = encode_shot_event_v2(_shot_data(), final=True) + + assert b" " not in payload.replace(b"Zo\xc3\xab", b"") + assert "Zoë".encode() in payload + decoded = json.loads(payload.decode("utf-8")) + assert list(decoded) == sorted(decoded) + + +def test_shot_deleted_event_carries_the_delete_key(): + assert build_shot_deleted_event("2026-09-25T12:00:00.000001") == { + "schema_version": 2, + "type": "shot_deleted", + "timestamp": "2026-09-25T12:00:00.000001", + } + with pytest.raises(ValueError): + build_shot_deleted_event("") + + +def test_hello_features_are_read_and_select_only(): + features = build_hello_result(2)["features"] + + assert "shot_deleted" in features + assert "delete_shot" not in features + assert "session_clear" not in features + + +def test_v2_events_cannot_override_envelope_fields(): + with pytest.raises(ValueError): + build_event_v2("profiles", {"type": "shot"}) + with pytest.raises(ValueError): + build_event_v2("not_an_event", {}) + + +def test_power_status_event_flattens_the_socketio_payload(): + status = {"available": False, "provider": "geekworm", "state": "unavailable"} + + assert build_power_status_event(status) == { + "schema_version": 2, + "type": "power_status", + **status, + } + + +def test_profiles_event_keeps_ids_and_names_only(): + event = build_profiles_event( + { + "profiles": [ + {"id": "a", "name": "A", "created_at": "2026", "settings": {"x": 1}}, + ], + "active_profile_id": "a", + } + ) + + assert event == { + "schema_version": 2, + "type": "profiles", + "profiles": [{"id": "a", "name": "A"}], + "active_profile_id": "a", + } + + +@pytest.mark.parametrize( + ("client_max", "expected_schema"), + [(1, 1), (2, 2), (9, 2)], +) +def test_hello_negotiates_the_highest_common_schema(client_max, expected_schema): + result = build_hello_result(client_max) + + assert result["schema_version"] == expected_schema + if expected_schema == 2: + assert result["features"] == list(V2_FEATURES) + assert result["characteristics"] == { + "shot": SHOT_V2_CHARACTERISTIC_UUID, + "control": CONTROL_V2_CHARACTERISTIC_UUID, + } + else: + assert result == {"schema_version": 1, "features": []} + + +@pytest.mark.parametrize("bad", [None, "2", 2.0, True, 0, -1]) +def test_hello_rejects_invalid_client_schema(bad): + with pytest.raises(ValueError): + build_hello_result(bad) + + +# -- size budget: every v2 message must fit in 255 fragments of 15 bytes ---------- + + +def _worst_float(): + # The longest repr a finite double produces. + return -1.2345678901234567e-300 + + +def test_worst_case_v2_shot_fits_in_one_ble_message(): + worst_name = "\x01" * MAX_NAME_LENGTH # six bytes per character once escaped + data = { + "timestamp": "2026-09-25T14:03:07.412345", + "club": "3-hybrid", + "ball_speed_mph": _worst_float(), + "club_speed_mph": _worst_float(), + "smash_factor": _worst_float(), + "estimated_carry_yards": _worst_float(), + "launch_angle_vertical": _worst_float(), + "launch_angle_horizontal": _worst_float(), + "spin_rpm": _worst_float(), + "club_path_deg": _worst_float(), + "spin_axis_deg": _worst_float(), + "shot_number": 2**63, + "profile_id": "f" * 64, + "profile_name": worst_name, + "carry_range": [_worst_float(), _worst_float()], + "spin_source": "calculated_from_launch_angle", + "launch_angle_confidence": _worst_float(), + } + payload = encode_shot_event_v2( + data, + final=False, + enrichment={"status": "skipped", "reason": "worker_unavailable"}, + ) + + assert len(payload) <= MAX_MESSAGE_SIZE + assert len(fragment_payload(payload, sequence=0)) <= 255 + + +@pytest.mark.parametrize("worst_char", ["\x01", "\U0001f3cc", '"', "\\"]) +def test_twelve_worst_case_profiles_fit_in_one_ble_message(worst_char): + snapshot = { + "profiles": [ + { + "id": f"{index:032x}", # ProfileStore ids are uuid4().hex + "name": worst_char * MAX_NAME_LENGTH, + "created_at": "2026-09-25T12:00:00Z", + "settings": {"not_sent": "x" * 10_000}, + } + for index in range(MAX_PROFILES) + ], + "active_profile_id": f"{0:032x}", + } + + payload = encode_message_v2(build_profiles_event(snapshot)) + + assert len(payload) <= MAX_MESSAGE_SIZE + fragment_payload(payload, sequence=0) + + +# -- contract fixture --------------------------------------------------------------- + + +def test_shot_v2_fixture_is_a_final_v2_shot(): + fixture = json.loads(SHOT_V2_FIXTURE.read_text(encoding="utf-8")) + + assert set(fixture) == V1_KEYS | V2_EXTRA_KEYS + assert fixture["schema_version"] == 2 + assert fixture["type"] == "shot" + assert fixture["final"] is True + assert fixture["event_id"] == stable_shot_event_id(fixture) + + +def test_shot_v2_fixture_round_trips_through_the_encoder(): + fixture = json.loads(SHOT_V2_FIXTURE.read_text(encoding="utf-8")) + + rebuilt = build_shot_event_v2(fixture, final=fixture["final"], enrichment=fixture["enrichment"]) + + assert rebuilt == fixture diff --git a/tests/test_ble_publisher.py b/tests/test_ble_publisher.py new file mode 100644 index 000000000..a03085be2 --- /dev/null +++ b/tests/test_ble_publisher.py @@ -0,0 +1,362 @@ +"""Tests for BLE delivery isolation, buffering, and fragmentation.""" + +import asyncio +import json +import logging +import sys +import types +from enum import IntFlag + +from openflight.ble.protocol import ( + CONTROL_CHARACTERISTIC_UUID, + SHOT_CHARACTERISTIC_UUID, + fragment_payload, + reassemble_fragments, +) +from openflight.ble.publisher import BleShotPublisher + + +def _shot_data(ball_speed=150.0): + return { + "timestamp": "2026-07-29T19:42:10", + "club": "driver", + "ball_speed_mph": ball_speed, + "club_speed_mph": None, + "smash_factor": None, + "estimated_carry_yards": 250, + "launch_angle_vertical": None, + "launch_angle_horizontal": None, + "spin_rpm": None, + "club_path_deg": None, + "spin_axis_deg": None, + } + + +class _Characteristic: + value = bytearray() + + +class _Server: + def __init__(self): + self.characteristic = _Characteristic() + self.notifications = [] + + def get_characteristic(self, uuid): + assert uuid == SHOT_CHARACTERISTIC_UUID + return self.characteristic + + def update_value(self, _service_uuid, _characteristic_uuid): + self.notifications.append(bytes(self.characteristic.value)) + return True + + +class _BlueZApplication: + """Match the subscription hooks exposed by Bless's Linux backend.""" + + def __init__(self): + self.StartNotify = lambda _session: None + self.StopNotify = lambda _session: None + + +class _BlueZServer: + def __init__(self): + self.app = _BlueZApplication() + + +class _ControlCharacteristic: + uuid = CONTROL_CHARACTERISTIC_UUID + value = bytearray() + + +class _ControlServer: + def __init__(self): + self.characteristic = _ControlCharacteristic() + self.notifications = [] + + def get_characteristic(self, uuid): + assert uuid == CONTROL_CHARACTERISTIC_UUID + return self.characteristic + + def update_value(self, service_uuid, characteristic_uuid): + self.notifications.append( + (service_uuid, characteristic_uuid, bytes(self.characteristic.value)) + ) + return True + + +def test_linux_runtime_uses_bless_writeable_permission(monkeypatch): + """Bless 0.3.0 spells the attribute permission ``writeable``.""" + + class Properties(IntFlag): + notify = 1 + write = 2 + + class Permissions(IntFlag): + readable = 1 + writeable = 2 + + class FakeBlessServer: + instance = None + + def __init__(self, **_kwargs): + self.characteristics = [] + self.app = None + FakeBlessServer.instance = self + + async def add_new_service(self, _uuid): + return None + + async def add_new_characteristic( + self, service_uuid, characteristic_uuid, properties, value, permissions + ): + self.characteristics.append( + (service_uuid, characteristic_uuid, properties, value, permissions) + ) + + async def start(self): + return None + + async def stop(self): + return None + + fake_bless = types.ModuleType("bless") + fake_bless.BlessServer = FakeBlessServer + fake_bless.GATTCharacteristicProperties = Properties + fake_bless.GATTAttributePermissions = Permissions + monkeypatch.setitem(sys.modules, "bless", fake_bless) + + async def run(): + publisher = BleShotPublisher() + publisher._stop_requested.set() # pylint: disable=protected-access + await publisher._run() # pylint: disable=protected-access + + asyncio.run(run()) + + control = next( + item + for item in FakeBlessServer.instance.characteristics + if item[1] == CONTROL_CHARACTERISTIC_UUID + ) + assert control[4] == Permissions.readable | Permissions.writeable + + +def test_disconnected_publish_retains_only_latest_payload(): + publisher = BleShotPublisher() + + assert publisher.publish(_shot_data(140.0)) + assert publisher.publish(_shot_data(151.0)) + + latest = json.loads(publisher._latest_payload) # pylint: disable=protected-access + assert latest["ball_speed_mph"] == 151.0 + + +def test_invalid_shot_does_not_raise_into_caller(): + publisher = BleShotPublisher() + + assert publisher.publish({}) is False + + +def test_delivery_uses_bounded_frames_and_round_trips(): + async def run(): + publisher = BleShotPublisher(fragment_interval_s=0) + server = _Server() + publisher._server = server # pylint: disable=protected-access + publisher._subscribed = True # pylint: disable=protected-access + + payload = b'{"schema_version":1,"event_id":"shot-1","ball_speed_mph":150.0}' + await publisher._send_payload(payload) # pylint: disable=protected-access + + assert reassemble_fragments(server.notifications) == payload + assert max(map(len, server.notifications)) <= 20 + + asyncio.run(run()) + + +def test_queue_overflow_drops_oldest_unsent_payload(): + async def run(): + publisher = BleShotPublisher(queue_size=2) + publisher._queue = asyncio.Queue(maxsize=2) # pylint: disable=protected-access + publisher._subscribed = True # pylint: disable=protected-access + + publisher._enqueue_payload(b"first") # pylint: disable=protected-access + publisher._enqueue_payload(b"second") # pylint: disable=protected-access + publisher._enqueue_payload(b"third") # pylint: disable=protected-access + + assert publisher._queue.qsize() == 2 # pylint: disable=protected-access + assert publisher._queue.get_nowait() == b"second" # pylint: disable=protected-access + assert publisher._queue.get_nowait() == b"third" # pylint: disable=protected-access + + asyncio.run(run()) + + +def test_unsubscribe_clears_backlog_but_keeps_latest_payload(): + async def run(): + publisher = BleShotPublisher(queue_size=2) + publisher._queue = asyncio.Queue(maxsize=2) # pylint: disable=protected-access + publisher._subscribed = True # pylint: disable=protected-access + publisher.publish(_shot_data()) + publisher._enqueue_payload(b"queued") # pylint: disable=protected-access + + publisher._on_unsubscribe(None, None) # pylint: disable=protected-access + + assert not publisher.subscribed + assert publisher._queue.empty() # pylint: disable=protected-access + assert publisher._latest_payload is not None # pylint: disable=protected-access + + asyncio.run(run()) + + +def test_bluez_subscription_hooks_update_publisher_state_and_replay_latest(): + """Bless 0.3.0 ignores constructor subscription callbacks on Linux.""" + + async def run(): + publisher = BleShotPublisher() + publisher._queue = asyncio.Queue(maxsize=2) # pylint: disable=protected-access + publisher.publish(_shot_data(151.0)) + server = _BlueZServer() + + publisher._install_bluez_subscription_hooks(server) # pylint: disable=protected-access + server.app.StartNotify(None) + + assert publisher.subscribed + assert publisher._queue.get_nowait() == publisher._latest_payload # pylint: disable=protected-access + + server.app.StopNotify(None) + + assert not publisher.subscribed + + asyncio.run(run()) + + +def test_background_startup_failure_is_isolated(caplog): + class FailingPublisher(BleShotPublisher): + async def _run(self): + raise RuntimeError("no BlueZ adapter") + + publisher = FailingPublisher() + with caplog.at_level(logging.WARNING): + publisher.start() + publisher._thread.join(timeout=2) # pylint: disable=protected-access + + assert not publisher._thread.is_alive() # pylint: disable=protected-access + assert "shot recording will continue without BLE" in caplog.text + + +def test_control_write_reassembles_command_and_notifies_response(): + async def run(): + received = [] + + def handler(command_type, payload): + received.append((command_type, payload)) + return { + "status": "applied", + "persistent": True, + "configured_iwr_tilt_deg": 12.25, + }, 200 + + publisher = BleShotPublisher(command_handler=handler, fragment_interval_s=0) + publisher._loop = asyncio.get_running_loop() # pylint: disable=protected-access + publisher._server = _ControlServer() # pylint: disable=protected-access + publisher._subscribed = True # pylint: disable=protected-access + command = { + "schema_version": 1, + "type": "iwr6843_orientation_calibration", + "request_id": "request-1", + "payload": {"mount_tilt_deg": 12.25}, + } + + for frame in fragment_payload(json.dumps(command).encode(), sequence=7): + publisher._on_write_request( # pylint: disable=protected-access + publisher._server.characteristic, # pylint: disable=protected-access + bytearray(frame), + ) + + for _ in range(100): + if publisher._server.notifications: # pylint: disable=protected-access + break + await asyncio.sleep(0.01) + + assert received == [("iwr6843_orientation_calibration", {"mount_tilt_deg": 12.25})] + updates = publisher._server.notifications # pylint: disable=protected-access + assert {item[1] for item in updates} == {CONTROL_CHARACTERISTIC_UUID} + response = json.loads(reassemble_fragments(item[2] for item in updates)) + assert response == { + "schema_version": 1, + "request_id": "request-1", + "ok": True, + "result": { + "status": "applied", + "persistent": True, + "configured_iwr_tilt_deg": 12.25, + }, + } + + asyncio.run(run()) + + +def test_control_write_dispatches_club_selection_command(): + async def run(): + received = [] + + def handler(command_type, payload): + received.append((command_type, payload)) + return {"status": "applied", "club": payload["club"]}, 200 + + publisher = BleShotPublisher(command_handler=handler, fragment_interval_s=0) + publisher._loop = asyncio.get_running_loop() # pylint: disable=protected-access + publisher._server = _ControlServer() # pylint: disable=protected-access + publisher._subscribed = True # pylint: disable=protected-access + command = { + "schema_version": 1, + "type": "set_club", + "request_id": "club-request-1", + "payload": {"club": "7-iron"}, + } + + for frame in fragment_payload(json.dumps(command).encode(), sequence=8): + publisher._on_write_request( # pylint: disable=protected-access + publisher._server.characteristic, # pylint: disable=protected-access + bytearray(frame), + ) + + for _ in range(100): + if publisher._server.notifications: # pylint: disable=protected-access + break + await asyncio.sleep(0.01) + + assert received == [("set_club", {"club": "7-iron"})] + updates = publisher._server.notifications # pylint: disable=protected-access + response = json.loads(reassemble_fragments(item[2] for item in updates)) + assert response == { + "schema_version": 1, + "request_id": "club-request-1", + "ok": True, + "result": {"status": "applied", "club": "7-iron"}, + } + + asyncio.run(run()) + + +def test_publish_club_notifies_connected_clients_as_unsolicited_state(): + async def run(): + publisher = BleShotPublisher(fragment_interval_s=0) + publisher._loop = asyncio.get_running_loop() # pylint: disable=protected-access + publisher._server = _ControlServer() # pylint: disable=protected-access + publisher._subscribed = True # pylint: disable=protected-access + + assert publisher.publish_club("3-wood") + + for _ in range(100): + if publisher._server.notifications: # pylint: disable=protected-access + break + await asyncio.sleep(0.01) + + updates = publisher._server.notifications # pylint: disable=protected-access + payload = json.loads(reassemble_fragments(item[2] for item in updates)) + assert payload == { + "schema_version": 1, + "type": "club_changed", + "club": "3-wood", + } + + asyncio.run(run()) diff --git a/tests/test_camera_ball_flight.py b/tests/test_camera_ball_flight.py index 339be5e64..9a90e98d5 100644 --- a/tests/test_camera_ball_flight.py +++ b/tests/test_camera_ball_flight.py @@ -1,5 +1,6 @@ """Tests for experimental camera-assisted horizontal ball flight.""" +import importlib.util import math from types import SimpleNamespace @@ -22,7 +23,10 @@ from openflight.camera.club_motion import ReferenceBall from openflight.iwr6843.lcmf import BallRangeEvidence, LCMFResult +CV2_AVAILABLE = importlib.util.find_spec("cv2") is not None + +@pytest.mark.skipif(not CV2_AVAILABLE, reason="OpenCV (camera extra) not installed") def test_ball_flight_uses_established_anchor_when_detection_is_missing(monkeypatch): tracker = ReferenceBallTracker() for x in (159.0, 160.0, 161.0): diff --git a/tests/test_camera_club_delivery.py b/tests/test_camera_club_delivery.py index 45acbbfec..d59043956 100644 --- a/tests/test_camera_club_delivery.py +++ b/tests/test_camera_club_delivery.py @@ -1,5 +1,6 @@ """Tests for the live camera club-delivery estimator and fusion.""" +import importlib.util import math from dataclasses import replace @@ -28,6 +29,8 @@ from openflight.camera.club_motion import ReferenceBall, detect_reference_ball from openflight.clubs import ClubType +CV2_AVAILABLE = importlib.util.find_spec("cv2") is not None + class _Ball: x = 320.0 @@ -775,6 +778,7 @@ def _synthetic_capture( return frames, ts +@pytest.mark.skipif(not CV2_AVAILABLE, reason="OpenCV (camera extra) not installed") class TestTraceEstimation: def test_bright_scene_produces_trace(self): frames, ts = _synthetic_capture() 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..dca8900f4 --- /dev/null +++ b/tests/test_control_commands.py @@ -0,0 +1,114 @@ +"""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, the SSE broker and BLE, so every test gets its own. + """ + monkeypatch.setattr(server_module, "active_club", ClubType.DRIVER) + monkeypatch.setattr(server_module, "shot_stream", _ClubPublisher()) + monkeypatch.setattr(server_module, "ble_publisher", None) + monkeypatch.setattr(server_module.socketio, "emit", lambda *_args, **_kwargs: None) + + +def test_apply_club_selection_updates_monitor_and_broadcasts(monkeypatch): + monitor = _Monitor() + stream = _ClubPublisher() + ble = _ClubPublisher() + emitted = [] + monkeypatch.setattr(server_module, "monitor", monitor) + monkeypatch.setattr(server_module, "shot_stream", stream) + monkeypatch.setattr(server_module, "ble_publisher", ble) + monkeypatch.setattr(server_module, "active_club", ClubType.DRIVER) + monkeypatch.setattr( + server_module.socketio, "emit", lambda event, data: emitted.append((event, data)) + ) + + response, status = server_module.apply_club_selection({"club": "7-iron"}) + + assert status == 200 + assert response == {"status": "applied", "club": "7-iron"} + assert monitor.clubs == [ClubType.IRON_7] + assert emitted == [("club_changed", {"club": "7-iron"})] + assert server_module.active_club is ClubType.IRON_7 + assert stream.clubs == ["7-iron"] + assert ble.clubs == ["7-iron"] + + +def test_apply_club_selection_rejects_unknown_club(monkeypatch): + monitor = _Monitor() + monkeypatch.setattr(server_module, "monitor", monitor) + + response, status = server_module.apply_club_selection({"club": "putter"}) + + assert status == 400 + assert "Unknown club" in response["error"] + assert monitor.clubs == [] + + +def test_network_club_endpoint_uses_shared_selection_logic(monkeypatch): + monitor = _Monitor() + monkeypatch.setattr(server_module, "monitor", monitor) + + response = server_module.app.test_client().post( + "/api/club", + json={"club": "pw"}, + ) + + assert response.status_code == 200 + assert response.get_json() == {"status": "applied", "club": "pw"} + assert monitor.clubs == [ClubType.PW] + + +def test_network_club_endpoint_returns_authoritative_selection(monkeypatch): + monkeypatch.setattr(server_module, "active_club", ClubType.WOOD_3) + + response = server_module.app.test_client().get("/api/club") + + assert response.status_code == 200 + assert response.get_json() == {"status": "current", "club": "3-wood"} + + +def test_control_dispatch_routes_club_command(monkeypatch): + monitor = _Monitor() + monkeypatch.setattr(server_module, "monitor", monitor) + + response, status = server_module.dispatch_phone_control_command("set_club", {"club": "3-wood"}) + + assert status == 200 + assert response["club"] == "3-wood" + assert monitor.clubs == [ClubType.WOOD_3] + + +def test_control_dispatch_returns_authoritative_club(monkeypatch): + monkeypatch.setattr(server_module, "active_club", ClubType.WOOD_5) + + response, status = server_module.dispatch_phone_control_command("get_club", {}) + + assert status == 200 + assert response == {"status": "current", "club": "5-wood"} diff --git a/tests/test_mock_hardware.py b/tests/test_mock_hardware.py new file mode 100644 index 000000000..69942206f --- /dev/null +++ b/tests/test_mock_hardware.py @@ -0,0 +1,267 @@ +"""Mock mode simulating the optional-hardware shot path. + +``MockLaunchMonitor`` reports ``capturing`` then ``calculating`` like the +rolling-buffer radar, can fail a shot on request, and with ``enrichment_ms`` +holds back the direction fields (horizontal launch, club path, spin axis) that +IWR6843/camera hardware measures, so mock shots go provisional then final +through the real enrichment pipeline. +""" + +import threading +from datetime import datetime + +import pytest +from ble_harness import V2_UUIDS, server_loopback, settle + +from openflight import server as server_module +from openflight.ble.protocol import CONTROL_V2_CHARACTERISTIC_UUID, SHOT_V2_CHARACTERISTIC_UUID +from openflight.launch_monitor import ClubType, Shot + +DIRECTION_FIELDS = ("launch_angle_horizontal", "club_path_deg", "spin_axis_deg") + + +def _monitor(**kwargs): + kwargs.setdefault("processing_step_s", 0) + return server_module.MockLaunchMonitor(**kwargs) + + +# -- monitor -------------------------------------------------------------------------- + + +class TestProcessingStates: + def test_shot_reports_capturing_then_calculating(self): + states, shots = [], [] + monitor = _monitor() + monitor.start(shot_callback=shots.append, processing_callback=states.append) + + shot = monitor.simulate_shot() + + assert states == ["capturing", "calculating"] + assert shots == [shot] + + def test_failed_shot_reports_failed_and_records_nothing(self): + states, shots = [], [] + monitor = _monitor() + monitor.start(shot_callback=shots.append, processing_callback=states.append) + + assert monitor.simulate_shot(fail=True) is None + + assert states == ["capturing", "calculating", "failed"] + assert shots == [] + assert monitor.get_shots() == [] + + def test_without_processing_callback_shots_still_work(self): + monitor = _monitor() + monitor.start() + + assert monitor.simulate_shot() is not None + assert monitor.simulate_shot(fail=True) is None + + def test_processing_callback_errors_do_not_stop_the_shot(self): + def broken(_state): + raise RuntimeError("UI went away") + + monitor = _monitor() + monitor.start(processing_callback=broken) + + assert monitor.simulate_shot() is not None + assert len(monitor.get_shots()) == 1 + + +class TestSimulatedEnrichment: + def test_disabled_by_default_and_shots_are_complete(self): + monitor = _monitor() + monitor.start() + + shot = monitor.simulate_shot() + + assert monitor.enrichment_ms == 0 + assert all(getattr(shot, field) is not None for field in DIRECTION_FIELDS) + + def test_enabled_shot_waits_for_enrichment_to_get_direction(self): + monitor = _monitor(enrichment_ms=1) + monitor.start() + shot = monitor.simulate_shot() + assert all(getattr(shot, field) is None for field in DIRECTION_FIELDS) + + elapsed_ms = monitor.enrich(shot) + + assert elapsed_ms >= 1 + assert all(getattr(shot, field) is not None for field in DIRECTION_FIELDS) + assert shot.launch_angle_horizontal_source == "mock" + + def test_rejects_negative_enrichment(self): + with pytest.raises(ValueError): + server_module.MockLaunchMonitor(enrichment_ms=-1) + + +# -- server --------------------------------------------------------------------------- + + +def _mock_shot(): + return Shot( + ball_speed_mph=150.0, + timestamp=datetime(2026, 9, 28, 12, 0, 0), + club=ClubType.DRIVER, + mode="mock", + ) + + +def test_mock_shots_skip_slow_enrichment_by_default(monkeypatch): + monkeypatch.setattr(server_module, "monitor", _monitor()) + + assert server_module._has_slow_shot_enrichment(_mock_shot()) is False + + +def test_mock_enrichment_routes_mock_shots_through_slow_path(monkeypatch): + monkeypatch.setattr(server_module, "monitor", _monitor(enrichment_ms=1)) + + assert server_module._has_slow_shot_enrichment(_mock_shot()) is True + + +def test_simulate_shot_handler_can_fail_a_shot(monkeypatch): + monitor = _monitor() + states, shots = [], [] + monitor.start(shot_callback=shots.append, processing_callback=states.append) + monkeypatch.setattr(server_module, "monitor", monitor) + + server_module.handle_simulate_shot({"fail": True}) + server_module.handle_simulate_shot() + + assert states == ["capturing", "calculating", "failed", "capturing", "calculating"] + assert len(shots) == 1 + + +# -- phone, over the BLE loopback ----------------------------------------------------- + + +@pytest.fixture +def pi(monkeypatch, tmp_path): + with server_loopback(monkeypatch, tmp_path) as loopback: + yield loopback + + +def _run_enrichment_in_threads(monkeypatch): + monkeypatch.setattr(server_module, "shot_enrichment_task", None) + monkeypatch.setattr( + server_module, + "shot_enrichment_queue", + server_module.queue.Queue(maxsize=server_module._SHOT_ENRICHMENT_QUEUE_CAPACITY), + ) + + def start_background_task(target, *args, **kwargs): + thread = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True) + thread.start() + return thread + + monkeypatch.setattr(server_module.socketio, "start_background_task", start_background_task) + + +def _wait_idle(): + with server_module._shot_finalization_condition: + assert server_module._shot_finalization_condition.wait_for( + lambda: ( + not server_module._shot_finalization_order + and not server_module._shot_finalization_running + ), + timeout=5, + ) + + +def _started_monitor(monkeypatch, **kwargs): + monitor = _monitor(**kwargs) + monitor.start( + shot_callback=server_module.on_shot_detected, + processing_callback=server_module.on_shot_processing, + ) + monkeypatch.setattr(server_module, "monitor", monitor) + return monitor + + +def _processing_states(phone): + return [ + message["state"] + for message in phone.decoded(CONTROL_V2_CHARACTERISTIC_UUID) + if message.get("type") == "shot_processing" + ] + + +def test_phone_sees_processing_then_provisional_then_final(pi, monkeypatch): + _run_enrichment_in_threads(monkeypatch) + monitor = _started_monitor(monkeypatch, enrichment_ms=50) + phone = pi.central("v2-app") + phone.subscribe(*V2_UUIDS) + settle(pi) + + monitor.simulate_shot() + _wait_idle() + + provisional, final = phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 2) + assert (provisional["final"], final["final"]) == (False, True) + assert provisional["event_id"] == final["event_id"] + assert provisional["enrichment"] == {"status": "pending"} + assert final["enrichment"] == {"status": "complete"} + assert provisional["club_path_deg"] is None + assert final["club_path_deg"] is not None + phone.wait_for( + CONTROL_V2_CHARACTERISTIC_UUID, + lambda message: message.get("state") == "calculating", + ) + assert _processing_states(phone) == ["capturing", "calculating"] + + +def test_enrichment_past_the_deadline_finalizes_as_skipped(pi, monkeypatch): + """What ``--mock-enrichment-ms`` above the 20 s deadline shows, sped up.""" + _run_enrichment_in_threads(monkeypatch) + monkeypatch.setattr(server_module, "_SHOT_ENRICHMENT_DEADLINE_S", 0.2) + monitor = _started_monitor(monkeypatch, enrichment_ms=1500) + phone = pi.central("v2-app") + phone.subscribe(*V2_UUIDS) + settle(pi) + + monitor.simulate_shot() + + provisional, final = phone.wait_for_count(SHOT_V2_CHARACTERISTIC_UUID, 2, timeout=10) + assert provisional["enrichment"] == {"status": "pending"} + assert final["final"] is True + assert final["enrichment"] == {"status": "skipped", "reason": "deadline"} + assert final["event_id"] == provisional["event_id"] + # Let the late enrichment finish so it cannot leak into the next test. + worker = server_module.shot_enrichment_task + if worker is not None: + worker.join(timeout=5) + _wait_idle() + + +def test_phone_sees_failed_processing_and_no_shot(pi, monkeypatch): + monitor = _started_monitor(monkeypatch) + phone = pi.central("v2-app") + phone.subscribe(*V2_UUIDS) + settle(pi) + + monitor.simulate_shot(fail=True) + + phone.wait_for(CONTROL_V2_CHARACTERISTIC_UUID, lambda message: message.get("state") == "failed") + settle(pi, rounds=5) + assert _processing_states(phone) == ["capturing", "calculating", "failed"] + assert phone.decoded(SHOT_V2_CHARACTERISTIC_UUID) == [] + + +# -- command line --------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "argv", + [ + ["--mock-enrichment-ms", "500"], + ["--mock-swing-speed", "--mock-enrichment-ms", "500"], + ["--mock", "--mock-enrichment-ms", "-1"], + ], +) +def test_mock_enrichment_flag_is_validated_before_startup(monkeypatch, argv): + monkeypatch.setattr(server_module.sys, "argv", ["openflight-server", *argv]) + + with pytest.raises(SystemExit) as exit_info: + server_module.main() + + assert exit_info.value.code == 2 diff --git a/tests/test_mock_power.py b/tests/test_mock_power.py new file mode 100644 index 000000000..da1a4359c --- /dev/null +++ b/tests/test_mock_power.py @@ -0,0 +1,137 @@ +"""The simulated battery (``--battery mock``) for testing power status without a UPS.""" + +import argparse + +import pytest + +from openflight import server as server_module +from openflight.power import ( + SUPPORTED_BATTERY_PROVIDERS, + BatteryProvider, + PowerMonitor, + PowerState, + create_power_reader, +) +from openflight.power.providers import MockPowerReader + + +def _samples(reader, count): + return [reader.read() for _ in range(count)] + + +def _fake_linux_battery(root): + """A sysfs tree that LinuxPowerReader would happily read.""" + battery = root / "battery" + battery.mkdir() + (battery / "type").write_text("Battery\n", encoding="ascii") + (battery / "capacity").write_text("47\n", encoding="ascii") + (battery / "voltage_now").write_text("3787500\n", encoding="ascii") + mains = root / "charger@0" + mains.mkdir() + (mains / "type").write_text("Mains\n", encoding="ascii") + (mains / "online").write_text("1\n", encoding="ascii") + + +def test_mock_is_a_supported_provider(): + assert "mock" in SUPPORTED_BATTERY_PROVIDERS + assert BatteryProvider("mock") is BatteryProvider.MOCK + + +def test_cycle_starts_full_on_battery(): + first = MockPowerReader().read() + + assert first.battery_percent == 100.0 + assert first.external_power is False + + +def test_cycle_reaches_every_available_state(): + reader = MockPowerReader() + + states = {PowerMonitor._state_for(sample) for sample in _samples(reader, reader.cycle_length)} + + assert states == { + PowerState.ON_BATTERY, + PowerState.LOW, + PowerState.CRITICAL, + PowerState.PLUGGED_IN, + } + + +def test_discharges_on_battery_then_charges_when_plugged_in(): + reader = MockPowerReader() + cycle = _samples(reader, reader.cycle_length) + unplugged = [sample for sample in cycle if not sample.external_power] + plugged = [sample for sample in cycle if sample.external_power] + + assert unplugged and plugged + # One unplugged stretch, then one plugged-in stretch. + assert cycle == unplugged + plugged + unplugged_levels = [sample.battery_percent for sample in unplugged] + plugged_levels = [sample.battery_percent for sample in plugged] + assert unplugged_levels == sorted(unplugged_levels, reverse=True) + assert plugged_levels == sorted(plugged_levels) + assert plugged_levels[-1] == 100.0 + + +def test_cycle_repeats_exactly(): + reader = MockPowerReader() + + first = _samples(reader, reader.cycle_length) + second = _samples(reader, reader.cycle_length) + + assert first == second + + +def test_readings_stay_plausible(): + reader = MockPowerReader() + + for sample in _samples(reader, reader.cycle_length): + assert 0.0 <= sample.battery_percent <= 100.0 + assert 3.3 <= sample.battery_voltage_v <= 4.2 + + +def test_voltage_tracks_charge(): + reader = MockPowerReader() + unplugged = [ + sample for sample in _samples(reader, reader.cycle_length) if not sample.external_power + ] + + voltages = [sample.battery_voltage_v for sample in unplugged] + assert voltages == sorted(voltages, reverse=True) + + +def test_factory_uses_mock_even_when_a_real_battery_exists(tmp_path): + """A laptop's own battery must not silently replace the simulated one.""" + _fake_linux_battery(tmp_path) + + reader = create_power_reader("mock", power_supply_path=tmp_path) + + assert isinstance(reader, MockPowerReader) + + +def test_power_monitor_publishes_mock_statuses(): + published = [] + power = PowerMonitor(provider="mock", on_status=published.append) + + for _ in range(3): + power.poll_once() + + assert [status.available for status in published] == [True, True, True] + assert {status.provider for status in published} == {"mock"} + assert published[0].battery_percent == 100.0 + assert published[1].battery_percent < published[0].battery_percent + assert power.status == published[-1] + + +def test_cli_accepts_mock_provider(): + parser = argparse.ArgumentParser() + server_module._add_battery_arguments(parser) + + assert parser.parse_args(["--battery", "mock"]).battery == "mock" + + +def test_rejects_non_positive_steps(): + with pytest.raises(ValueError): + MockPowerReader(discharge_step=0) + with pytest.raises(ValueError): + MockPowerReader(charge_step=-1) diff --git a/tests/test_phone_catch_up.py b/tests/test_phone_catch_up.py new file mode 100644 index 000000000..e485d7af0 --- /dev/null +++ b/tests/test_phone_catch_up.py @@ -0,0 +1,113 @@ +"""Selecting and caching the session shots a reconnecting phone missed.""" + +import threading + +import pytest + +from openflight.phone_catch_up import ( + CATCH_UP_LIMIT, + PhoneShotCache, + normalize_last_event_id, + select_catch_up, +) + + +def _entries(count): + return [(f"id-{index}", f"payload-{index}".encode()) for index in range(count)] + + +def _ids(entries): + return [event_id for event_id, _payload in entries] + + +class TestSelectCatchUp: + def test_known_id_returns_that_shot_and_later_ones_in_order(self): + """The named shot is resent too: the phone may only have its provisional.""" + assert _ids(select_catch_up(_entries(5), "id-2")) == ["id-2", "id-3", "id-4"] + + def test_latest_id_returns_only_that_shot(self): + assert _ids(select_catch_up(_entries(5), "id-4")) == ["id-4"] + + def test_no_id_returns_whole_session(self): + assert _ids(select_catch_up(_entries(3), None)) == ["id-0", "id-1", "id-2"] + + def test_unknown_id_returns_whole_session(self): + """A cleared session, deleted shot or Pi restart: the phone's anchor is gone.""" + assert _ids(select_catch_up(_entries(3), "not-in-session")) == ["id-0", "id-1", "id-2"] + + def test_empty_session_returns_nothing(self): + assert select_catch_up([], None) == [] + assert select_catch_up([], "id-0") == [] + + def test_caps_to_most_recent_shots(self): + selected = select_catch_up(_entries(CATCH_UP_LIMIT + 5), None) + + assert len(selected) == CATCH_UP_LIMIT + assert selected[0][0] == "id-5" + assert selected[-1][0] == f"id-{CATCH_UP_LIMIT + 4}" + + def test_caps_missed_shots_after_a_known_id(self): + selected = select_catch_up(_entries(30), "id-2", limit=4) + + assert _ids(selected) == ["id-26", "id-27", "id-28", "id-29"] + + def test_payloads_travel_with_their_ids(self): + assert select_catch_up(_entries(2), "id-1") == [("id-1", b"payload-1")] + + def test_rejects_non_positive_limit(self): + with pytest.raises(ValueError): + select_catch_up(_entries(2), None, limit=0) + + +class TestNormalizeLastEventId: + @pytest.mark.parametrize("value", [None, "", " ", 7, True, ["id"], {"id": 1}, "x" * 65]) + def test_invalid_values_mean_no_anchor(self, value): + assert normalize_last_event_id(value) is None + + def test_strips_whitespace(self): + assert normalize_last_event_id(" abc ") == "abc" + + def test_accepts_uuid(self): + event_id = "05dd37ec-49ed-596b-b1a4-953d54e4f239" + assert normalize_last_event_id(event_id) == event_id + + +class TestPhoneShotCache: + def test_remembers_latest_payload_per_event_id(self): + cache = PhoneShotCache() + cache.remember("a", b"provisional") + cache.remember("a", b"final") + + assert cache.get("a") == b"final" + assert cache.get("missing") is None + + def test_evicts_least_recently_written_when_full(self): + cache = PhoneShotCache(max_entries=2) + cache.remember("a", b"1") + cache.remember("b", b"2") + cache.remember("a", b"1-final") # refreshes "a" + cache.remember("c", b"3") + + assert cache.get("b") is None + assert cache.get("a") == b"1-final" + assert cache.get("c") == b"3" + assert len(cache) == 2 + + def test_rejects_non_positive_size(self): + with pytest.raises(ValueError): + PhoneShotCache(max_entries=0) + + def test_concurrent_writers_stay_bounded(self): + cache = PhoneShotCache(max_entries=50) + + def write(prefix): + for index in range(500): + cache.remember(f"{prefix}-{index}", b"x") + + threads = [threading.Thread(target=write, args=(name,)) for name in "abcd"] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert len(cache) == 50 diff --git a/tests/test_phone_orientation_calibration.py b/tests/test_phone_orientation_calibration.py new file mode 100644 index 000000000..b3b82c215 --- /dev/null +++ b/tests/test_phone_orientation_calibration.py @@ -0,0 +1,191 @@ +"""Phone-assisted IWR6843 mount-orientation calibration.""" + +import json +import math +from types import SimpleNamespace + +import pytest + +from openflight import server +from openflight.iwr6843.calibration import Calibration +from openflight.phone_orientation import ( + PhoneOrientationMeasurement, + PhoneOrientationValidationError, + load_phone_orientation_calibration, +) + + +def _payload(*, tilt_deg: float = 12.0, roll_deg: float = 0.0) -> dict: + tilt_rad = math.radians(tilt_deg) + roll_rad = math.radians(roll_deg) + horizontal_gravity = math.cos(tilt_rad) + return { + "schema_version": 1, + "mount_tilt_deg": tilt_deg, + "roll_deg": roll_deg, + "gravity_x_g": math.sin(roll_rad) * horizontal_gravity, + "gravity_y_g": -math.cos(roll_rad) * horizontal_gravity, + "gravity_z_g": -math.sin(tilt_rad), + "tilt_stddev_deg": 0.08, + "roll_stddev_deg": 0.07, + "sample_count": 120, + "measured_at": "2026-08-07T15:30:00Z", + "device_model": "iPhone", + } + + +def _runtime(*, tilt_deg: float = 10.4, azimuth_offset_deg: float = 1.5): + calibration = Calibration.identity() + calibration.tilt_rad = math.radians(tilt_deg) + return SimpleNamespace( + calibration=calibration, + azimuth_offset_deg=azimuth_offset_deg, + ) + + +def test_measurement_recomputes_orientation_from_gravity(): + measurement = PhoneOrientationMeasurement.from_payload(_payload(tilt_deg=12.25, roll_deg=-1.5)) + + assert measurement.mount_tilt_deg == pytest.approx(12.25) + assert measurement.roll_deg == pytest.approx(-1.5) + assert measurement.sample_count == 120 + + +@pytest.mark.parametrize( + ("change", "message"), + [ + ({"sample_count": 10}, "at least 30"), + ({"tilt_stddev_deg": 0.8}, "stable"), + (_payload(roll_deg=4.0), "level the radar left-to-right"), + ({"mount_tilt_deg": 20.0}, "gravity vector"), + ], +) +def test_measurement_rejects_untrustworthy_samples(change, message): + payload = _payload() + payload.update(change) + + with pytest.raises(PhoneOrientationValidationError, match=message): + PhoneOrientationMeasurement.from_payload(payload) + + +def test_phone_calibration_requires_an_enabled_ti_radar(monkeypatch, tmp_path): + monkeypatch.setattr(server, "iwr6843_runtime", None) + monkeypatch.setattr(server, "PHONE_ORIENTATION_CALIBRATION_PATH", tmp_path / "orientation.json") + + response = server.app.test_client().post( + "/api/calibration/iwr6843/orientation", + json=_payload(), + ) + + assert response.status_code == 409 + assert "not enabled" in response.get_json()["error"] + assert not (tmp_path / "orientation.json").exists() + + +def test_phone_calibration_applies_persists_and_logs_tilt(monkeypatch, tmp_path): + runtime = _runtime() + original_calibration = runtime.calibration + config = {"enabled": True, "azimuth_offset_deg": runtime.azimuth_offset_deg} + changes = [] + session = SimpleNamespace( + log_config_change=lambda value, source: changes.append((value, source)) + ) + path = tmp_path / "orientation.json" + monkeypatch.setattr(server, "iwr6843_runtime", runtime) + monkeypatch.setattr(server, "iwr6843_runtime_config", config) + monkeypatch.setattr(server, "inclinometer_service", None) + monkeypatch.setattr(server, "PHONE_ORIENTATION_CALIBRATION_PATH", path) + monkeypatch.setattr(server, "get_session_logger", lambda: session) + + response = server.app.test_client().post( + "/api/calibration/iwr6843/orientation", + json=_payload(tilt_deg=12.25), + ) + + assert response.status_code == 200 + body = response.get_json() + assert body["status"] == "applied" + assert body["configured_iwr_tilt_deg"] == pytest.approx(12.25) + assert body["azimuth_offset_deg"] == 1.5 + assert body["persistent"] is True + assert runtime.calibration is not original_calibration + assert math.degrees(runtime.calibration.tilt_rad) == pytest.approx(12.25) + assert math.degrees(original_calibration.tilt_rad) == pytest.approx(10.4) + assert runtime.azimuth_offset_deg == 1.5 + assert config["tilt_deg"] == pytest.approx(12.25) + assert config["tilt_source"] == "ios_companion" + assert changes[-1][1] == "ios_companion" + + persisted = load_phone_orientation_calibration(path) + assert persisted["configured_iwr_tilt_deg"] == pytest.approx(12.25) + assert persisted["measurement"]["device_model"] == "iPhone" + + +def test_phone_calibration_subtracts_live_enclosure_pitch(monkeypatch, tmp_path): + runtime = _runtime() + snapshot = SimpleNamespace(calibrated_pitch_deg=1.75) + selection = SimpleNamespace(snapshot=snapshot, status="stable") + sensor = SimpleNamespace(wait_for_stable=lambda timeout_s: selection) + monkeypatch.setattr(server, "iwr6843_runtime", runtime) + monkeypatch.setattr(server, "iwr6843_runtime_config", {"enabled": True}) + monkeypatch.setattr(server, "inclinometer_service", sensor) + monkeypatch.setattr(server, "PHONE_ORIENTATION_CALIBRATION_PATH", tmp_path / "orientation.json") + monkeypatch.setattr(server, "get_session_logger", lambda: None) + + response = server.app.test_client().post( + "/api/calibration/iwr6843/orientation", + json=_payload(tilt_deg=12.25), + ) + + assert response.status_code == 200 + body = response.get_json() + assert body["measured_mount_tilt_deg"] == pytest.approx(12.25) + assert body["enclosure_pitch_deg"] == pytest.approx(1.75) + assert body["configured_iwr_tilt_deg"] == pytest.approx(10.5) + assert math.degrees(runtime.calibration.tilt_rad) == pytest.approx(10.5) + + +def test_phone_calibration_fails_safe_when_enclosure_sensor_is_unstable(monkeypatch, tmp_path): + runtime = _runtime() + original_tilt = runtime.calibration.tilt_rad + selection = SimpleNamespace(snapshot=None, status="moving") + sensor = SimpleNamespace(wait_for_stable=lambda timeout_s: selection) + path = tmp_path / "orientation.json" + monkeypatch.setattr(server, "iwr6843_runtime", runtime) + monkeypatch.setattr(server, "iwr6843_runtime_config", {"enabled": True}) + monkeypatch.setattr(server, "inclinometer_service", sensor) + monkeypatch.setattr(server, "PHONE_ORIENTATION_CALIBRATION_PATH", path) + + response = server.app.test_client().post( + "/api/calibration/iwr6843/orientation", + json=_payload(), + ) + + assert response.status_code == 409 + assert "enclosure sensor" in response.get_json()["error"] + assert runtime.calibration.tilt_rad == original_tilt + assert not path.exists() + + +def test_saved_phone_tilt_is_used_on_restart_unless_cli_overrides(monkeypatch, tmp_path): + path = tmp_path / "orientation.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "configured_iwr_tilt_deg": 11.75, + "measurement": _payload(tilt_deg=12.25), + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(server, "PHONE_ORIENTATION_CALIBRATION_PATH", path) + + assert server._resolve_iwr_mount_tilt(10.4, explicit_tilt_deg=None) == ( + pytest.approx(11.75), + "ios_companion", + ) + assert server._resolve_iwr_mount_tilt(10.4, explicit_tilt_deg=8.0) == ( + pytest.approx(8.0), + "command_line", + ) diff --git a/tests/test_phone_transport_server.py b/tests/test_phone_transport_server.py new file mode 100644 index 000000000..23cd8a5fb --- /dev/null +++ b/tests/test_phone_transport_server.py @@ -0,0 +1,104 @@ +"""Server-level behaviour of the phone transports (Socket.IO, SSE and BLE).""" + +import logging +import sys + +import pytest + +from openflight import server as server_module +from openflight.ble import BleShotPublisher +from openflight.launch_monitor import ClubType + + +class _ClubPublisher: + def __init__(self): + self.clubs = [] + + def publish_club(self, club): + self.clubs.append(club) + return True + + +@pytest.fixture +def no_monitor_transports(monkeypatch): + """No launch monitor, with every club transport captured.""" + stream = _ClubPublisher() + ble = _ClubPublisher() + emitted = [] + monkeypatch.setattr(server_module, "monitor", None) + monkeypatch.setattr(server_module, "shot_stream", stream) + monkeypatch.setattr(server_module, "ble_publisher", ble) + monkeypatch.setattr(server_module, "active_club", ClubType.DRIVER) + monkeypatch.setattr( + server_module.socketio, + "emit", + lambda event, data, **_kwargs: emitted.append((event, data)), + ) + return stream, ble, emitted + + +def test_socket_set_club_without_monitor_still_broadcasts(no_monitor_transports): + """Before the monitor exists, a club change is recorded and broadcast, as before.""" + stream, ble, emitted = no_monitor_transports + + server_module.handle_set_club({"club": "7-iron"}) + + assert server_module.active_club is ClubType.IRON_7 + assert emitted == [("club_changed", {"club": "7-iron"})] + assert stream.clubs == ["7-iron"] + assert ble.clubs == ["7-iron"] + + +def test_network_club_post_without_monitor_applies_and_broadcasts(no_monitor_transports): + stream, ble, emitted = no_monitor_transports + + response = server_module.app.test_client().post("/api/club", json={"club": "pw"}) + + assert response.status_code == 200 + assert response.get_json() == {"status": "applied", "club": "pw"} + assert server_module.active_club is ClubType.PW + assert emitted == [("club_changed", {"club": "pw"})] + assert stream.clubs == ["pw"] + assert ble.clubs == ["pw"] + + +@pytest.mark.parametrize("payload", [{"club": "putter"}, {"club": "unknown"}, {}, None, "pw"]) +def test_network_club_post_rejects_invalid_selection(no_monitor_transports, payload): + stream, ble, emitted = no_monitor_transports + + response = server_module.app.test_client().post("/api/club", json=payload) + + assert response.status_code == 400 + assert "error" in response.get_json() + assert server_module.active_club is ClubType.DRIVER + assert emitted == [] + assert stream.clubs == [] + assert ble.clubs == [] + + +@pytest.mark.parametrize("payload", [{"club": "putter"}, {"club": "unknown"}, None]) +def test_socket_set_club_ignores_invalid_selection(no_monitor_transports, payload): + stream, ble, emitted = no_monitor_transports + + server_module.handle_set_club(payload) + + assert server_module.active_club is ClubType.DRIVER + assert emitted == [] + assert stream.clubs == [] + assert ble.clubs == [] + + +def test_ble_without_bless_logs_unavailable_and_continues(monkeypatch, caplog): + """`--ble` on macOS or without the `ble` extra must not take the server down.""" + # A None entry makes `from bless import ...` raise ImportError. + monkeypatch.setitem(sys.modules, "bless", None) + publisher = BleShotPublisher() + + with caplog.at_level(logging.WARNING): + publisher.start() + publisher._thread.join(timeout=2) # pylint: disable=protected-access + + assert not publisher._thread.is_alive() # pylint: disable=protected-access + assert "Bluetooth unavailable" in caplog.text + assert publisher.publish_club("driver") is True + publisher.stop() diff --git a/tests/test_phone_transport_v2.py b/tests/test_phone_transport_v2.py new file mode 100644 index 000000000..bd4b52682 --- /dev/null +++ b/tests/test_phone_transport_v2.py @@ -0,0 +1,365 @@ +"""Server-level schema v2 behaviour: SSE opt-in, event fan-out and command routing.""" + +import json +from datetime import datetime + +import pytest + +from openflight import server as server_module +from openflight.launch_monitor import ClubType, Shot +from openflight.power import PowerStatus +from openflight.power.models import PowerState +from openflight.profiles import ProfileStore +from openflight.shot_stream import HEARTBEAT_FRAME, ShotStreamBroker + + +def _shot_data(ball_speed=150.0, **extra): + data = { + "timestamp": "2026-09-25T12:00:00.000001", + "club": "driver", + "ball_speed_mph": ball_speed, + "estimated_carry_yards": 250, + "shot_number": 1, + } + data.update(extra) + return data + + +def _decode(event): + return event.name, json.loads(bytes(event).decode("utf-8")) + + +def _drain(subscriber): + events = [] + while not subscriber.empty(): + events.append(_decode(subscriber.get_nowait())) + return events + + +class _PhoneTransport: + """Records what the server hands to one phone transport.""" + + def __init__(self): + self.v1_shots = [] + self.v2_shots = [] + self.events = [] + self.clubs = [] + + def publish(self, shot_data): + self.v1_shots.append(shot_data) + return True + + def publish_v2_shot(self, shot_data, *, final, enrichment=None): + self.v2_shots.append((shot_data, final, enrichment)) + return True + + def publish_event_v2(self, event): + self.events.append(event) + return True + + def publish_club(self, club): + self.clubs.append(club) + return True + + +@pytest.fixture +def phones(monkeypatch, tmp_path): + """Capture Socket.IO, SSE and BLE output of a monitor-less server.""" + stream = _PhoneTransport() + ble = _PhoneTransport() + emitted = [] + monkeypatch.setattr(server_module, "shot_stream", stream) + monkeypatch.setattr(server_module, "ble_publisher", ble) + monkeypatch.setattr(server_module, "monitor", None) + monkeypatch.setattr(server_module, "power_monitor", None) + monkeypatch.setattr(server_module, "active_club", ClubType.DRIVER) + monkeypatch.setattr(server_module, "profile_store", ProfileStore(tmp_path / "profiles.json")) + monkeypatch.setattr( + server_module.socketio, + "emit", + lambda event, data=None, **_kwargs: emitted.append((event, data)), + ) + return stream, ble, emitted + + +# -- SSE broker ---------------------------------------------------------------------- + + +def test_default_subscriber_stays_on_version_one(): + broker = ShotStreamBroker() + v1 = broker.subscribe() + v2 = broker.subscribe(schema=2) + + broker.publish(_shot_data()) + broker.publish_v2_shot(_shot_data(), final=False, enrichment={"status": "pending"}) + broker.publish_event_v2({"schema_version": 2, "type": "shot_processing", "state": "failed"}) + broker.publish_club("pw") + + v1_events = _drain(v1) + assert [name for name, _ in v1_events] == ["shot", "club_changed"] + assert v1_events[0][1]["schema_version"] == 1 + assert "final" not in v1_events[0][1] + assert v1_events[1][1] == {"schema_version": 1, "type": "club_changed", "club": "pw"} + v2_events = _drain(v2) + assert [name for name, _ in v2_events] == ["shot", "shot_processing", "club_changed"] + assert v2_events[0][1]["final"] is False + assert v2_events[2][1] == {"schema_version": 2, "type": "club_changed", "club": "pw"} + + +def test_v2_subscriber_is_seeded_with_state_then_latest_v2_shot(): + broker = ShotStreamBroker() + broker.publish(_shot_data(140.0)) + broker.publish_v2_shot(_shot_data(151.0), final=True) + seed = [{"schema_version": 2, "type": "club_changed", "club": "7-iron"}] + + v2 = broker.subscribe(schema=2, initial_events=seed) + v1 = broker.subscribe() + + v2_events = _drain(v2) + assert [name for name, _ in v2_events] == ["club_changed", "shot"] + assert v2_events[1][1]["ball_speed_mph"] == 151.0 + assert _drain(v1)[0][1]["ball_speed_mph"] == 140.0 + + +def test_unsupported_stream_schema_is_rejected(): + with pytest.raises(ValueError): + ShotStreamBroker().subscribe(schema=3) + + +def test_v2_stream_route_opts_in_and_seeds_current_state(monkeypatch, tmp_path): + broker = ShotStreamBroker(heartbeat_interval_s=0.01) + # The session, not the broker's latest shot, decides what a v2 client is + # seeded with (see tests/test_shot_stream_catch_up.py). + monitor = server_module.MockLaunchMonitor() + monitor._shots.append( + Shot( + ball_speed_mph=150.0, + timestamp=datetime(2026, 9, 25, 12, 0, 0, 1), + club=ClubType.DRIVER, + shot_number=1, + ) + ) + monkeypatch.setattr(server_module, "monitor", monitor) + monkeypatch.setattr(server_module, "phone_shot_cache", server_module.PhoneShotCache()) + monkeypatch.setattr(server_module, "shot_stream", broker) + monkeypatch.setattr(server_module, "active_club", ClubType.IRON_7) + monkeypatch.setattr(server_module, "power_monitor", None) + monkeypatch.setattr(server_module, "profile_store", ProfileStore(tmp_path / "profiles.json")) + + response = server_module.app.test_client().get("/api/shots/stream?schema=2") + try: + assert response.status_code == 200 + frames = response.response + assert next(frames).decode("utf-8") == HEARTBEAT_FRAME + events = [] + for _ in range(3): + frame = next(frames).decode("utf-8") + name = frame.split("\n", 1)[0].removeprefix("event: ") + events.append((name, json.loads(frame.split("data: ", 1)[1]))) + finally: + response.close() + + assert [name for name, _ in events] == ["club_changed", "profiles", "shot"] + assert events[0][1] == {"schema_version": 2, "type": "club_changed", "club": "7-iron"} + assert events[1][1]["profiles"][0]["name"] == "Profile 1" + assert events[2][1]["schema_version"] == 2 and events[2][1]["final"] is True + assert broker.subscriber_count == 0 + + +def test_stream_route_rejects_unknown_schema(monkeypatch): + monkeypatch.setattr(server_module, "shot_stream", ShotStreamBroker()) + + response = server_module.app.test_client().get("/api/shots/stream?schema=3") + + assert response.status_code == 400 + + +# -- event fan-out --------------------------------------------------------------------- + + +def test_shot_processing_power_and_profiles_reach_v2_phones(phones): + stream, ble, emitted = phones + status = PowerStatus( + available=True, + provider="geekworm", + state=PowerState.ON_BATTERY, + battery_percent=80.0, + battery_voltage_v=4.0, + external_power=False, + updated_at="2026-09-25T12:00:00+00:00", + ) + + server_module.on_shot_processing("calculating") + server_module._on_power_status(status) + server_module.handle_get_profiles() + + for transport in (stream, ble): + types = [event["type"] for event in transport.events] + assert types == ["shot_processing", "power_status", "profiles"] + assert transport.events[0]["state"] == "calculating" + assert transport.events[1] == { + "schema_version": 2, + "type": "power_status", + **status.to_dict(), + } + assert [event for event, _ in emitted] == ["shot_processing", "power_status", "profiles"] + + +# -- v2 command routing ------------------------------------------------------------------ + + +def test_v2_dispatch_uses_the_socketio_operations(phones): + stream, _ble, emitted = phones + store = server_module.get_profile_store() + first = store.get_active() + second = store.add("Sam") + + response, status = server_module.dispatch_phone_control_command_v2( + "set_active_profile", {"profile_id": first.id} + ) + via_ble = emitted[-1] + server_module.handle_set_active_profile({"profile_id": first.id}) + via_socket = emitted[-1] + + assert (status, response) == (200, {"status": "applied", "active_profile_id": first.id}) + assert via_ble == via_socket + assert via_ble[0] == "profiles" + assert stream.events[-1]["active_profile_id"] == first.id + assert second.id in {item["id"] for item in stream.events[-1]["profiles"]} + + +def test_socket_clear_session_also_notifies_v2_phones(phones): + stream, 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": []})] + for transport in (stream, ble): + assert transport.events == [ + {"schema_version": 2, "type": "session_cleared", "profile_id": active} + ] + + +def test_socket_delete_shot_notifies_v2_phones_only_on_success(phones, monkeypatch): + stream, ble, emitted = phones + shot = Shot( + ball_speed_mph=150.0, + timestamp=datetime(2026, 9, 25, 12, 0, 0, 1), + 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"}) + for transport in (stream, ble): + assert transport.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() == [] + for transport in (stream, ble): + assert transport.events == [ + { + "schema_version": 2, + "type": "shot_deleted", + "timestamp": shot.timestamp.isoformat(), + } + ] + + +@pytest.mark.parametrize("command", ["clear_session", "delete_shot"]) +def test_destructive_commands_are_not_routed_over_ble(phones, command): + _stream, _ble, emitted = phones + + for dispatch in ( + server_module.dispatch_phone_control_command, + server_module.dispatch_phone_control_command_v2, + ): + response, status = dispatch(command, {"timestamp": "2026-09-25T12:00:00"}) + assert status == 400 + assert response["error"] == f"Unsupported phone command: {command}" + assert emitted == [] + + +def test_v2_power_status_command_returns_the_socketio_payload(phones, monkeypatch): + status = PowerStatus( + available=True, + provider="geekworm", + state=PowerState.PLUGGED_IN, + battery_percent=100.0, + battery_voltage_v=4.2, + external_power=True, + updated_at="2026-09-25T12:00:00+00:00", + ) + + class _Monitor: + pass + + power = _Monitor() + power.status = status + monkeypatch.setattr(server_module, "power_monitor", power) + + assert server_module.dispatch_phone_control_command_v2("get_power_status", {}) == ( + status.to_dict(), + 200, + ) + + +def test_v1_dispatch_does_not_grow_v2_commands(phones): + for command in ("get_profiles", "set_active_profile", "get_power_status"): + response, status = server_module.dispatch_phone_control_command(command, {}) + assert status == 400 + assert response["error"] == f"Unsupported phone command: {command}" + + +# -- shot publication ---------------------------------------------------------------------- + + +def test_fast_path_publishes_one_final_shot_without_enrichment(phones, monkeypatch): + stream, ble, _emitted = phones + for name in ("kld7_vertical", "kld7_horizontal", "camera_capture_runtime", "iwr6843_runtime"): + monkeypatch.setattr(server_module, name, None) + for name in ("ball_speed_correction_enabled", "calculated_spin_enabled", "ballistics_enabled"): + monkeypatch.setattr(server_module, name, False) + monkeypatch.setattr(server_module, "sim_connectors", []) + monkeypatch.setattr(server_module, "get_session_logger", lambda: None) + + server_module.on_shot_detected( + Shot( + ball_speed_mph=150.0, + timestamp=datetime(2026, 9, 25, 12, 0, 1), + club=ClubType.DRIVER, + ) + ) + with server_module._shot_finalization_condition: + assert server_module._shot_finalization_condition.wait_for( + lambda: ( + not server_module._shot_finalization_order + and not server_module._shot_finalization_running + ), + timeout=5, + ) + + for transport in (stream, ble): + assert len(transport.v1_shots) == 1 + [(shot_data, final, enrichment)] = transport.v2_shots + assert final is True + assert enrichment is None + assert shot_data is transport.v1_shots[0] + + +@pytest.mark.parametrize( + ("emit_event", "skipped_reason", "expected"), + [ + ("shot", None, None), + ("shot_update", None, {"status": "complete"}), + ("shot_update", "deadline", {"status": "skipped", "reason": "deadline"}), + ("shot_update", "queue_full", {"status": "skipped", "reason": "queue_full"}), + ], +) +def test_final_enrichment_describes_what_happened(emit_event, skipped_reason, expected): + enrichment = server_module._ShotEnrichmentResult(skipped_reason=skipped_reason) + + assert server_module._final_phone_enrichment(emit_event, enrichment) == expected 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..e8890aa8f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -29,6 +29,7 @@ swing_speed_to_dict, swing_speed_to_shot_dict, ) +from openflight.shot_stream import HEARTBEAT_FRAME, SSE_MIMETYPE, ShotStreamBroker from openflight.swing_speed import SwingSpeedEvent @@ -513,6 +514,11 @@ def test_init_iwr6843_has_no_host_freeze_delay(self, monkeypatch, tmp_path): """Production capture must always request the firmware-frozen boundary ring immediately.""" captured = {} calibration = Calibration.identity() + monkeypatch.setattr( + server_module, + "PHONE_ORIENTATION_CALIBRATION_PATH", + tmp_path / "missing-phone-orientation.json", + ) class FakeCaptureMonitor: def __init__(self, **kwargs): @@ -561,6 +567,11 @@ def test_init_iwr6843_wires_horizontal_calibration_into_runtime(self, monkeypatc club path relative to boresight instead of the target line. """ calibration = Calibration.identity() + monkeypatch.setattr( + server_module, + "PHONE_ORIENTATION_CALIBRATION_PATH", + tmp_path / "missing-phone-orientation.json", + ) class FakeCaptureMonitor: def __init__(self, **kwargs): @@ -1236,6 +1247,64 @@ def test_display_route_falls_back_when_dist_missing(self, monkeypatch, tmp_path) assert b'
' in response.data +class TestShotStreamRoute: + """Tests for the Server-Sent Events shot endpoint used by the iOS app.""" + + @staticmethod + def _shot_data(ball_speed=151.4): + return { + "timestamp": "2026-07-29T19:42:10", + "club": "driver", + "ball_speed_mph": ball_speed, + "club_speed_mph": None, + "smash_factor": None, + "estimated_carry_yards": 264, + "launch_angle_vertical": None, + "launch_angle_horizontal": None, + "spin_rpm": None, + "club_path_deg": None, + "spin_axis_deg": None, + } + + def test_stream_replays_latest_shot_as_sse(self, monkeypatch): + """A phone that connects mid-session gets the last shot immediately.""" + broker = ShotStreamBroker(heartbeat_interval_s=0.01) + broker.publish(self._shot_data()) + monkeypatch.setattr(server_module, "shot_stream", broker) + + response = server_module.app.test_client().get("/api/shots/stream") + try: + assert response.status_code == 200 + assert response.mimetype == SSE_MIMETYPE + assert response.headers["Cache-Control"] == "no-cache" + + frames = response.response + assert next(frames).decode("utf-8") == HEARTBEAT_FRAME + + frame = next(frames).decode("utf-8") + assert frame.startswith("event: shot\ndata: ") + event = json.loads(frame.split("data: ", 1)[1]) + assert event["schema_version"] == 1 + assert event["ball_speed_mph"] == 151.4 + finally: + response.close() + + assert broker.subscriber_count == 0 + + def test_stream_refuses_clients_beyond_the_limit(self, monkeypatch): + """The threaded dev server must not be swamped by stream connections.""" + broker = ShotStreamBroker(heartbeat_interval_s=0.01, max_subscribers=1) + monkeypatch.setattr(server_module, "shot_stream", broker) + client = server_module.app.test_client() + + first = client.get("/api/shots/stream") + try: + second = client.get("/api/shots/stream") + assert second.status_code == 503 + finally: + first.close() + + class TestShotToDict: """Tests for shot_to_dict conversion.""" @@ -4038,6 +4107,98 @@ def test_mock_shot_missing_angles_gets_fallback_values(self, monkeypatch): assert shot.launch_angle_vertical == pytest.approx(20.5) assert shot.launch_angle_horizontal == pytest.approx(0.0) + def test_ble_publish_survives_websocket_failure(self, monkeypatch): + """A broken web client must not suppress the independent BLE transport.""" + published = [] + publisher = SimpleNamespace(publish=lambda payload: published.append(payload)) + + monkeypatch.setattr(server_module, "monitor", None) + monkeypatch.setattr(server_module, "sim_connectors", []) + monkeypatch.setattr(server_module, "ble_publisher", publisher) + monkeypatch.setattr(server_module, "debug_mode", False) + monkeypatch.setattr(server_module, "get_session_logger", lambda: None) + monkeypatch.setattr( + server_module.socketio, + "emit", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("socket closed")), + ) + + shot = Shot( + ball_speed_mph=100.0, + timestamp=datetime.now(), + club=ClubType.IRON_7, + mode="mock", + ) + + on_shot_detected(shot) + _wait_for_shot_finalization_idle() + + assert len(published) == 1 + assert published[0]["ball_speed_mph"] == 100.0 + + def test_shot_stream_publish_survives_websocket_failure(self, monkeypatch): + """A broken web client must not suppress the independent network transport.""" + broker = ShotStreamBroker() + subscriber = broker.subscribe() + + monkeypatch.setattr(server_module, "monitor", None) + monkeypatch.setattr(server_module, "sim_connectors", []) + monkeypatch.setattr(server_module, "ble_publisher", None) + monkeypatch.setattr(server_module, "shot_stream", broker) + monkeypatch.setattr(server_module, "debug_mode", False) + monkeypatch.setattr(server_module, "get_session_logger", lambda: None) + monkeypatch.setattr( + server_module.socketio, + "emit", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("socket closed")), + ) + + on_shot_detected( + Shot( + ball_speed_mph=100.0, + timestamp=datetime.now(), + club=ClubType.IRON_7, + mode="mock", + ) + ) + _wait_for_shot_finalization_idle() + + streamed = json.loads(subscriber.get_nowait()) + assert streamed["schema_version"] == 1 + assert streamed["ball_speed_mph"] == 100.0 + + def test_shot_stream_failure_does_not_break_shot_processing(self, monkeypatch): + """A raising broker must not escape into the shot pipeline.""" + forwarded = [] + + class BrokenBroker: + def publish(self, _shot_data): + raise RuntimeError("stream is wedged") + + monkeypatch.setattr(server_module, "monitor", None) + monkeypatch.setattr(server_module, "ble_publisher", None) + monkeypatch.setattr(server_module, "shot_stream", BrokenBroker()) + monkeypatch.setattr(server_module, "debug_mode", False) + monkeypatch.setattr(server_module, "get_session_logger", lambda: None) + monkeypatch.setattr(server_module.socketio, "emit", lambda *_a, **_k: None) + monkeypatch.setattr( + server_module, + "_forward_shot_to_simulators", + lambda shot: forwarded.append(shot), + ) + + on_shot_detected( + Shot( + ball_speed_mph=100.0, + timestamp=datetime.now(), + club=ClubType.IRON_7, + mode="mock", + ) + ) + _wait_for_shot_finalization_idle() + + assert len(forwarded) == 1 + def test_implausible_club_aoa_is_rejected(self, monkeypatch): """A +31° club AoA is physically impossible and should be discarded.""" diff --git a/tests/test_shot_stream.py b/tests/test_shot_stream.py new file mode 100644 index 000000000..8b3570f81 --- /dev/null +++ b/tests/test_shot_stream.py @@ -0,0 +1,203 @@ +"""Tests for Server-Sent Events shot delivery, buffering, and isolation.""" + +import json +import logging +import queue + +import pytest + +from openflight.shot_stream import ( + HEARTBEAT_FRAME, + ShotStreamBroker, + ShotStreamFull, + format_event, +) + + +def _shot_data(ball_speed=150.0): + return { + "timestamp": "2026-07-29T19:42:10", + "club": "driver", + "ball_speed_mph": ball_speed, + "club_speed_mph": None, + "smash_factor": None, + "estimated_carry_yards": 250, + "launch_angle_vertical": None, + "launch_angle_horizontal": None, + "spin_rpm": None, + "club_path_deg": None, + "spin_axis_deg": None, + } + + +def _decode_frame(frame): + lines = frame.split("\n") + assert lines[0] == "event: shot" + assert lines[2] == "" + assert lines[3] == "" + return json.loads(lines[1].removeprefix("data: ")) + + +def test_rejects_nonsense_configuration(): + with pytest.raises(ValueError): + ShotStreamBroker(queue_size=0) + with pytest.raises(ValueError): + ShotStreamBroker(heartbeat_interval_s=0) + with pytest.raises(ValueError): + ShotStreamBroker(max_subscribers=0) + + +def test_event_frame_is_one_data_line_terminated_by_blank_line(): + frame = format_event(b'{"schema_version":1}') + + assert frame == 'event: shot\ndata: {"schema_version":1}\n\n' + assert frame.count("\ndata:") == 1 + + +def test_publish_reaches_every_subscriber(): + broker = ShotStreamBroker() + first = broker.subscribe() + second = broker.subscribe() + + assert broker.publish(_shot_data(151.4)) + + assert _decode_frame(format_event(first.get_nowait()))["ball_speed_mph"] == 151.4 + assert _decode_frame(format_event(second.get_nowait()))["ball_speed_mph"] == 151.4 + assert broker.subscriber_count == 2 + + +def test_club_change_reaches_every_subscriber(): + broker = ShotStreamBroker() + first = broker.subscribe() + second = broker.subscribe() + + assert broker.publish_club("3-wood") + + expected = ( + 'event: club_changed\ndata: {"club":"3-wood","schema_version":1,"type":"club_changed"}\n\n' + ) + assert format_event(first.get_nowait()) == expected + assert format_event(second.get_nowait()) == expected + + +def test_subscriber_replays_latest_shot_on_connect(): + broker = ShotStreamBroker() + broker.publish(_shot_data(140.0)) + broker.publish(_shot_data(151.4)) + + subscriber = broker.subscribe() + + replayed = _decode_frame(format_event(subscriber.get_nowait())) + assert replayed["ball_speed_mph"] == 151.4 + assert subscriber.empty() + + +def test_invalid_shot_does_not_raise_into_caller(caplog): + broker = ShotStreamBroker() + subscriber = broker.subscribe() + + with caplog.at_level(logging.WARNING): + assert broker.publish({}) is False + + assert "Failed to encode shot payload" in caplog.text + assert subscriber.empty() + + +def test_full_queue_drops_oldest_unsent_shot(caplog): + broker = ShotStreamBroker(queue_size=2) + subscriber = broker.subscribe() + + with caplog.at_level(logging.WARNING): + for speed in (100.0, 110.0, 120.0): + broker.publish(_shot_data(speed)) + + assert "dropped oldest unsent shot" in caplog.text + remaining = [ + _decode_frame(format_event(subscriber.get_nowait()))["ball_speed_mph"] for _ in range(2) + ] + assert remaining == [110.0, 120.0] + + +def test_slow_subscriber_does_not_block_a_healthy_one(): + broker = ShotStreamBroker(queue_size=1) + slow = broker.subscribe() + healthy = broker.subscribe() + + broker.publish(_shot_data(100.0)) + healthy.get_nowait() + broker.publish(_shot_data(120.0)) + + assert _decode_frame(format_event(healthy.get_nowait()))["ball_speed_mph"] == 120.0 + assert _decode_frame(format_event(slow.get_nowait()))["ball_speed_mph"] == 120.0 + + +def test_subscriber_limit_is_enforced(): + broker = ShotStreamBroker(max_subscribers=1) + broker.subscribe() + + with pytest.raises(ShotStreamFull): + broker.subscribe() + + +def test_unsubscribe_frees_a_slot_and_tolerates_repeats(): + broker = ShotStreamBroker(max_subscribers=1) + subscriber = broker.subscribe() + + broker.unsubscribe(subscriber) + broker.unsubscribe(subscriber) + + assert broker.subscriber_count == 0 + assert broker.subscribe() is not subscriber + + +def test_stream_opens_with_a_heartbeat_so_response_headers_flush(): + """A WSGI server withholds headers until the first chunk of the body.""" + broker = ShotStreamBroker(heartbeat_interval_s=30.0) + frames = broker.frames(broker.subscribe()) + + assert next(frames) == HEARTBEAT_FRAME + + frames.close() + + +def test_stream_emits_shots_then_heartbeats_while_idle(): + broker = ShotStreamBroker(heartbeat_interval_s=0.01) + broker.publish(_shot_data(151.4)) + frames = broker.frames(broker.subscribe()) + + opening = next(frames) + replayed = next(frames) + idle = next(frames) + + assert opening == HEARTBEAT_FRAME + assert _decode_frame(replayed)["ball_speed_mph"] == 151.4 + assert idle == HEARTBEAT_FRAME + assert broker.subscriber_count == 1 + + frames.close() + assert broker.subscriber_count == 0 + + +def test_stream_unsubscribes_when_client_disconnects(): + broker = ShotStreamBroker(heartbeat_interval_s=0.01) + frames = broker.frames(broker.subscribe()) + next(frames) + + frames.close() + + assert broker.subscriber_count == 0 + broker.publish(_shot_data()) + assert broker.subscriber_count == 0 + + +def test_publish_after_disconnect_still_replays_to_the_next_client(): + broker = ShotStreamBroker(heartbeat_interval_s=0.01) + frames = broker.frames(broker.subscribe()) + frames.close() + + broker.publish(_shot_data(133.0)) + subscriber = broker.subscribe() + + assert _decode_frame(format_event(subscriber.get_nowait()))["ball_speed_mph"] == 133.0 + with pytest.raises(queue.Empty): + subscriber.get_nowait() diff --git a/tests/test_shot_stream_catch_up.py b/tests/test_shot_stream_catch_up.py new file mode 100644 index 000000000..aef897175 --- /dev/null +++ b/tests/test_shot_stream_catch_up.py @@ -0,0 +1,241 @@ +"""Network (SSE) schema v2 catch-up: the same rule BLE ``hello`` applies. + +A v2 stream client names the last shot it has with the standard +``Last-Event-ID`` header (or ``?last_event_id=``) and is seeded with the +session shots after it. v2 shot frames carry ``id: `` so an +``EventSource`` sends the header on its own when it reconnects. +""" + +import json +from datetime import datetime + +import pytest + +from openflight import server as server_module +from openflight.ble.protocol import encode_shot_event_v2, stable_shot_event_id +from openflight.launch_monitor import ClubType, Shot +from openflight.phone_catch_up import CATCH_UP_LIMIT, PhoneShotCache +from openflight.profiles import ProfileStore +from openflight.shot_stream import HEARTBEAT_FRAME, ShotStreamBroker, format_event + + +def _shot_data(number=1, ball_speed=150.0): + return { + "timestamp": f"2026-09-28T12:00:{number:02d}.000001", + "club": "driver", + "ball_speed_mph": ball_speed, + "estimated_carry_yards": 250, + "shot_number": number, + } + + +def _catch_up_entry(number): + data = _shot_data(number) + return stable_shot_event_id(data), encode_shot_event_v2(data, final=True) + + +def _drain(subscriber): + events = [] + while not subscriber.empty(): + events.append(subscriber.get_nowait()) + return events + + +def _parse(frame): + """(name, id or None, decoded data) from one SSE frame.""" + fields = dict(line.split(": ", 1) for line in frame.strip().split("\n")) + return fields["event"], fields.get("id"), json.loads(fields["data"]) + + +# -- broker --------------------------------------------------------------------------- + + +def test_catch_up_replaces_latest_seed_and_keeps_order(): + broker = ShotStreamBroker() + broker.publish_v2_shot(_shot_data(9), final=True) + entries = [_catch_up_entry(number) for number in (1, 2, 3)] + state = [{"schema_version": 2, "type": "club_changed", "club": "7-iron"}] + + subscriber = broker.subscribe(schema=2, initial_events=state, catch_up=entries) + + frames = [_parse(format_event(event)) for event in _drain(subscriber)] + assert [name for name, _id, _data in frames] == ["club_changed", "shot", "shot", "shot"] + assert [event_id for _name, event_id, _data in frames[1:]] == [eid for eid, _ in entries] + assert [data["shot_number"] for _name, _id, data in frames[1:]] == [1, 2, 3] + assert frames[0][1] is None # only shots carry an id + + +def test_empty_catch_up_seeds_no_shot(): + broker = ShotStreamBroker() + broker.publish_v2_shot(_shot_data(9), final=True) + + subscriber = broker.subscribe(schema=2, catch_up=[]) + + assert _drain(subscriber) == [] + + +def test_without_catch_up_the_latest_shot_is_still_seeded(): + broker = ShotStreamBroker() + broker.publish_v2_shot(_shot_data(9), final=True) + + events = _drain(broker.subscribe(schema=2)) + + assert [_parse(format_event(event))[2]["shot_number"] for event in events] == [9] + + +def test_v1_subscriber_ignores_catch_up(): + broker = ShotStreamBroker() + broker.publish(_shot_data(4)) + + events = _drain(broker.subscribe(catch_up=[_catch_up_entry(1)])) + + assert len(events) == 1 + name, event_id, data = _parse(format_event(events[0])) + assert (name, event_id, data["schema_version"]) == ("shot", None, 1) + + +def test_full_catch_up_fits_the_seed_queue(): + broker = ShotStreamBroker(queue_size=2) + entries = [_catch_up_entry(number) for number in range(1, CATCH_UP_LIMIT + 1)] + state = [{"schema_version": 2, "type": "club_changed", "club": "driver"}] + + events = _drain(broker.subscribe(schema=2, initial_events=state, catch_up=entries)) + + assert len(events) == CATCH_UP_LIMIT + 1 + + +def test_live_v2_shot_frames_carry_event_id_and_v1_frames_do_not(): + broker = ShotStreamBroker() + v1 = broker.subscribe() + v2 = broker.subscribe(schema=2) + + broker.publish(_shot_data(5)) + broker.publish_v2_shot(_shot_data(5), final=False, enrichment={"status": "pending"}) + broker.publish_event_v2({"schema_version": 2, "type": "shot_processing", "state": "failed"}) + + (v1_frame,) = [_parse(format_event(event)) for event in _drain(v1)] + v2_frames = [_parse(format_event(event)) for event in _drain(v2)] + assert v1_frame[1] is None + assert v2_frames[0][1] == stable_shot_event_id(_shot_data(5)) + assert v2_frames[0][2]["event_id"] == v2_frames[0][1] + assert v2_frames[1][1] is None + + +# -- route ---------------------------------------------------------------------------- + + +@pytest.fixture +def session(monkeypatch, tmp_path): + """A mock monitor with five shots behind the real stream route; returns event ids.""" + monitor = server_module.MockLaunchMonitor() + for index in range(5): + monitor._shots.append( + Shot( + ball_speed_mph=120.0 + index, + timestamp=datetime(2026, 9, 28, 12, 0, index, 1000), + club=ClubType.DRIVER, + shot_number=index + 1, + ) + ) + monkeypatch.setattr(server_module, "monitor", monitor) + monkeypatch.setattr(server_module, "shot_stream", ShotStreamBroker(heartbeat_interval_s=0.01)) + monkeypatch.setattr(server_module, "phone_shot_cache", PhoneShotCache()) + monkeypatch.setattr(server_module, "active_club", ClubType.DRIVER) + monkeypatch.setattr(server_module, "power_monitor", None) + monkeypatch.setattr(server_module, "profile_store", ProfileStore(tmp_path / "profiles.json")) + return [stable_shot_event_id(server_module.shot_to_dict(shot)) for shot in monitor.get_shots()] + + +def _stream_shot_ids(path, headers=None): + """Event ids of the shots seeded on connect (before the first idle heartbeat).""" + response = server_module.app.test_client().get(path, headers=headers or {}) + try: + assert response.status_code == 200 + frames = response.response + assert next(frames).decode("utf-8") == HEARTBEAT_FRAME + ids = [] + for raw in frames: + frame = raw.decode("utf-8") + if frame == HEARTBEAT_FRAME: + break + name, event_id, data = _parse(frame) + if name == "shot": + assert event_id == data.get("event_id") + ids.append(event_id) + return ids + finally: + response.close() + + +def test_route_without_anchor_seeds_whole_session(session): + assert _stream_shot_ids("/api/shots/stream?schema=2") == session + + +def test_route_last_event_id_header_resends_it_and_seeds_missed_shots(session): + ids = _stream_shot_ids("/api/shots/stream?schema=2", {"Last-Event-ID": session[2]}) + + assert ids == session[2:] + + +def test_route_last_event_id_query_parameter_matches_header(session): + assert _stream_shot_ids(f"/api/shots/stream?schema=2&last_event_id={session[2]}") == session[2:] + + +def test_route_header_takes_precedence_over_query(session): + ids = _stream_shot_ids( + f"/api/shots/stream?schema=2&last_event_id={session[0]}", + {"Last-Event-ID": session[3]}, + ) + + assert ids == session[3:] + + +def test_route_unknown_anchor_seeds_whole_session(session): + ids = _stream_shot_ids("/api/shots/stream?schema=2", {"Last-Event-ID": "not-a-shot"}) + + assert ids == session + + +def test_route_v1_stream_ignores_last_event_id(session): + server_module.shot_stream.publish( + server_module.shot_to_dict(server_module.monitor.get_shots()[-1]) + ) + + response = server_module.app.test_client().get( + "/api/shots/stream", headers={"Last-Event-ID": session[0]} + ) + try: + frames = response.response + next(frames) + name, event_id, data = _parse(next(frames).decode("utf-8")) + finally: + response.close() + + assert (name, event_id, data["schema_version"], data["ball_speed_mph"]) == ( + "shot", + None, + 1, + 124.0, + ) + + +def test_route_matches_ble_catch_up_for_the_same_anchor(session): + """Both transports apply one rule: same anchor, same shots, same bytes.""" + shared = server_module.phone_catch_up_v2(session[1]) + + assert _stream_shot_ids("/api/shots/stream?schema=2", {"Last-Event-ID": session[1]}) == [ + event_id for event_id, _payload in shared + ] + + +def test_route_survives_catch_up_failure_with_latest_replay(session, monkeypatch): + server_module.shot_stream.publish_v2_shot( + server_module.shot_to_dict(server_module.monitor.get_shots()[-1]), final=True + ) + + def broken(_last_event_id=None): + raise RuntimeError("session unavailable") + + monkeypatch.setattr(server_module, "phone_catch_up_v2", broken) + + assert _stream_shot_ids("/api/shots/stream?schema=2") == [session[-1]] diff --git a/tests/test_start_kiosk.py b/tests/test_start_kiosk.py index 740796105..70b252d40 100644 --- a/tests/test_start_kiosk.py +++ b/tests/test_start_kiosk.py @@ -181,6 +181,23 @@ def test_camera_capture_uses_system_python_for_sync_and_server_start(): assert 'uv run "${UV_RUN_ARGS[@]}" "${SERVER_CMD[@]}" &' in script +def test_ble_flag_is_forwarded_to_server(): + command = _dry_run("--mock", "--ble") + + assert "--mock" in command + assert "--ble" in command + + +def test_ble_extra_is_synced_only_when_ble_is_requested(): + script = _script() + sync_block = script[ + script.index("UV_SYNC_ARGS=(--quiet)") : script.index("\nconfigure_kld7_latency\n") + ] + + assert "if has_server_arg --ble; then\n UV_SYNC_ARGS+=(--extra ble)\nfi" in sync_block + assert "--extra ble" not in script.replace(sync_block, "") + + def test_startup_applies_kld7_latency_setup_before_server_start(): script = _script() 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" = [