Skip to content

feat(phone): add BLE, network shot stream and club API for mobile companion apps - #282

Draft
btripp wants to merge 20 commits into
open-flight:mainfrom
btripp:feat/phone-connectivity
Draft

btripp wants to merge 20 commits into
open-flight:mainfrom
btripp:feat/phone-connectivity

Conversation

@btripp

@btripp btripp commented Sep 27, 2026 •

Copy link
Copy Markdown

What does this PR do?

Adds phone connectivity so a mobile companion app can follow and drive a running Pi:

  • BLE GATT publisher (v1), ported from jake-fishtech's feat/iOS-ble branch. It is enabled with --ble and needs the new optional ble extra (bless 0.3.0, Linux only). Without the extra or BlueZ, the server logs "Bluetooth unavailable" and keeps running.
  • Network shot stream: GET /api/shots/stream sends final shots and club changes as Server-Sent Events. It works over any IP link (Wi-Fi, Ethernet, …); nothing assumes access-point mode.
  • Shared club API: GET/POST /api/club. Club changes from the kiosk, phones, the API or simulators all go through one path, broadcast on Socket.IO, SSE and BLE.
  • Phone-assisted IWR6843 tilt calibration: POST /api/calibration/iwr6843/orientation.
  • BLE schema v2, which clients opt into with hello on a second characteristic pair (v1 bytes unchanged). It adds shot numbers, profiles, and provisional and final shots sharing an event_id. It also adds processing, power, session-cleared and shot-deleted events. SSE opts in with ?schema=2.

Why one PR: everything shares one club path and one publisher, and a phone app can't use any piece alone. The 13 commits split the work for review. About 4,000 of the 7,178 added lines are tests and fixtures. I can split v2 into a follow-up if you prefer.

Security scope: BLE is unauthenticated, so it can only read state, select the club or profile, and calibrate. Deleting shots, clearing sessions and editing profiles stay on the network path (kiosk, Socket.IO or HTTP).

Behaviour changes: a Socket.IO set_club with no club or "unknown" is now ignored (a missing club used to select driver). With no monitor, a club change still broadcasts, as before. A failed Socket.IO shot emit no longer skips BLE, SSE or simulators. New clients get club_changed on connect. A saved phone calibration sets the tilt unless the tilt argument is given.

Why was this required?

Relates to #203 (headless Pi with a phone as the monitor). Right now a phone can only open the kiosk UI in a browser. #122 was closed because there was no mobile app and no evidence it had been tested. This PR answers both:

  • App: OpenFlight Companion, a native Android and iOS app with a shared Kotlin core. It already speaks BLE v1 and v2 (negotiating v2 and falling back to v1) and this PR's SSE and club API. It works on stock upstream over Socket.IO today; this PR adds Bluetooth, the live shot stream, club control over HTTP and phone calibration. jake-fishtech's iOS app uses BLE v1, which is served unchanged.
  • Field evidence from beta testers: the Companion app is now with TestFlight and Play testers on stock Pis. Their reports are exactly the gaps this PR closes:
    • Bluetooth never connects, because stock has no BLE publisher.
    • Phone calibration fails with HTTP 405, because stock has no calibration route and the static catch-all rejects the POST.
    • Club control needed a Socket.IO workaround in the app.
  • Continuously tested: the app repo's CI runs its integration test (the real app data layer against openflight-server --mock) against this branch on every change. That includes a route-contract check of /api/club and the calibration route.

Automated tests

1,612 → 1,770 passed, 10 skipped. Every commit passes on its own, as do Python 3.10 and a CI-style pip env on 3.11.

  • v1/v2 protocol: framing, 255×15-byte budget, event_id, hello negotiation
  • Publisher: per-characteristic subscriptions, replay of the latest shot, refusal of destructive commands
  • Loopback harness: the real publisher against a fake Bless server with virtual v1 and v2 phones, with no Pi needed
  • 19 framed hex goldens shared with client test suites; a test fails if they drift
  • Server: SSE v1/v2, club API errors, no-monitor broadcast, emit-failure isolation, calibration precedence, kiosk flag

Re-run on the PR head 64649fe (2026-09-28): uv run pytest tests/ gave 1,770 passed and 10 skipped; pylint 9.61; ruff, UI build and UI lint were all clean.

Manual (human) testing

On 2026-09-28 I ran this branch at 64649fe as openflight-server --mock --web-port 8282 on my Mac. I used the kiosk web UI in a browser and the Companion app (Android, on an emulator) connected over the network. I did each step by hand and cross-checked it against the server log:

  1. Connect: after I granted the permission prompt, the app connected. The server logged an SSE client subscribing with ?schema=2, then GET /api/club 200.
  2. Shot to the phone: I fired simulated shots from the kiosk. Each one showed up on the phone with ball speed, carry and club (e.g. 145.0 mph / 251 yds, 164.7 mph / 286 yds).
  3. Club from the phone: I picked 7-iron in the app. The server logged POST /api/club 200 "Club changed to 7-iron" and the kiosk switched. The next shot (102.4 mph / 139 yds) was filed as a 7-iron.
  4. Club from the kiosk: I picked Driver in the kiosk. The app's club selector followed without me touching the phone.
  5. Calibration: I ran the phone calibration and tapped Apply. The request reached the new route (POST /api/calibration/iwr6843/orientation) and the server answered 409, as expected in mock mode with no IWR6843 enabled. A real applied calibration still needs the radar (see below).
  6. Reconnect: I stopped the server, watched the app show the lost connection and keep retrying, then restarted it. The app reconnected by itself 14 s later (SSE ?schema=2 re-subscribed, club re-synced) without a Retry tap.

Hardware testing still needed (Pi and real phones; I don't have a Pi set up yet, so none of these have been run):

  • BlueZ advertising on Pi OS with four characteristics visible
  • iOS (v1 app, Companion) and Android discovery, connect and permission prompts
  • Real-link fragmentation: 12-profile event (~241 frames) and v2 shot arrive intact
  • Provisional → final with IWR6843 or camera enrichment
  • Reconnect after a Pi or Bluetooth restart; background and foreground
  • Kiosk, SSE phone and BLE v1 and v2 phones at once stay in sync
  • Delete and clear refused over a real BLE link

AI assistance: Claude Code helped port the branch, write the v2 protocol, tests and docs, and draft this text. I reviewed every changed line and can explain it. I ran the manual tests above myself.

Checklist

  • Single feature/fix: phone connectivity for companion apps. The story and the reason for one PR are above; I'm happy to split v2 out.
  • Automated tests included: new or updated tests cover this change
  • Manual testing described: I documented what I verified by hand above
  • Python tests pass (uv run pytest tests/ -v)
  • Pylint passes (uv run pylint src/openflight/ --fail-under=9)
  • Ruff passes (uv run ruff check src/openflight/)
  • UI builds (cd ui && npm run build)
  • UI lint passes (cd ui && npm run lint)
  • Updated docs or CHANGELOG if needed
  • No unrelated changes mixed in

btripp and others added 12 commits September 25, 2026 11:37
…odules

Ported unchanged from jake-fishtech/openflight@feat/iOS-ble (b053194): the
Bless GATT publisher and v1 frames, the SSE shot broker, phone orientation
validation, the BLE doc and an advertising hardware test. shot_v1.json now
lives in tests/fixtures.

Co-authored-by: Jake Fishman <jake.fishtech@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bless==0.3.0 is a Linux-only optional extra, and [tool.uv] environments
limits resolution to Linux and macOS. setup.sh installs the extra on the Pi;
start-kiosk.sh syncs it only when --ble is passed, and forwards --ble to the
server like any other server flag.

Co-authored-by: Jake Fishman <jake.fishtech@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds --ble, /api/shots/stream, /api/club and the IWR6843 phone orientation
route. Only finalized shots are published; a failed Socket.IO emit no
longer stops BLE, SSE or sim delivery. All club changes share one path and
broadcast club_changed, even with no monitor. --ble without Bless says
Bluetooth is unavailable.

Co-authored-by: Jake Fishman <jake.fishtech@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v2 shots add type, final, shot_number, profile, carry_range, spin_source,
enrichment and a uuid5 event_id so provisional and final shots upsert.
Adds v2 events and hello {client_schema_max}. v2 is UTF-8 so profile lists
fit one BLE message; v1 is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BlueZ notifies every subscriber and Bless 0.3.0 hides the writer, so v2
cannot be per-central. The publisher adds a v2 shot/control pair beside the
unchanged v1 pair and gates delivery on per-characteristic subscriptions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v2 phones get a provisional and a final shot with one event_id and an
enrichment status; v1 phones are unchanged. Processing, power, profile and
session events fan out to BLE and SSE v2. BLE v2 commands reuse the
Socket.IO handlers. SSE opts in with ?schema=2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/ble_harness.py runs the real publisher against a fake Bless server
with virtual centrals, so BLE is tested end to end without a Pi.
scripts/ble/generate_goldens.py writes framed hex goldens that client
suites share; a test fails when they drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
apply_club_selection writes the module-global active_club and fans out over
Socket.IO, SSE and BLE; two tests leaked "pw" and "3-wood" into later tests.
An autouse fixture now monkeypatches all four.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Documents schema v2 (why a second characteristic pair), its payloads and
commands, get_club and hello, the SSE opt-in and testing without hardware;
removes ios/ paths and adds the page to the nav and changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BLE is unauthenticated, so clear_session and delete_shot are refused over
Bluetooth and stay on Socket.IO. A Socket.IO delete_shot now notifies
shot_deleted to BLE v2 and SSE ?schema=2 clients. Goldens regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Removes clear_session and delete_shot from the v2 command table, adds the
shot_deleted event and SSE name, updates the hello example, and replaces the
security note: BLE is unauthenticated, so v2 over Bluetooth only reads state
and selects club or profile; deleting data and editing profiles require
Wi-Fi. Changelog entry updated to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@HuggeK

HuggeK commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Maybe rename the "WiFi" part to "over network", just because it's indipendat on what transport layer you use your pi to connect to a network - could be over Ethernet or any m other medium, aslong you have a ip-connection. We are not using AP mode deliberately.

@btripp btripp changed the title feat(phone): add BLE, Wi-Fi shot stream and club API for mobile companion apps feat(phone): add BLE, network shot stream and club API for mobile companion apps Sep 27, 2026
Review feedback on open-flight#282: the HTTP/SSE/Socket.IO path works over any IP
link (Wi-Fi, Ethernet, ...), and OpenFlight deliberately does not use AP
mode, so "Wi-Fi" was misleading. Reword docs, comments, log strings and
test names; BLE golden fixtures are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@btripp

btripp commented Sep 27, 2026

Copy link
Copy Markdown
Author

Thanks @HuggeK, good point. Renamed it throughout in 64649fe: the title and description, docs/ios-ble.md, the changelog, server comments and log strings, and the test names now say "network" for the HTTP/SSE/Socket.IO path. The docs say it works over any IP link (Wi-Fi, Ethernet, …), and nothing assumes AP mode. The one place "Wi-Fi" stays is where the docs name the companion app's option label.

btripp and others added 2 commits September 28, 2026 12:41
bluetoothd's GATT client read the iPhone's own GATT database, iOS
answered Insufficient Authentication, and BlueZ sent an SMP Security
Request. No agent on the headless/kiosk Pi confirmed it, so after the
30 s SMP timeout BlueZ disconnected (Authentication Failure) and the
phone reconnected into a new pairing prompt, forever.

Add scripts/setup/configure_bluetooth.sh, which sets Client = false
under [GATT] in /etc/bluetooth/main.conf idempotently, with a
timestamped backup and a bluetooth restart (--check reports only).
Stock Raspberry Pi OS main.conf documents #Client under [CSIS], where
bluetoothd ignores it, so the script writes it into [GATT]. setup.sh
offers the step on a Pi; docs/ios-ble.md gains a troubleshooting entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setup.sh does not install the camera extra, so 11 tests failed on the
Pi with ModuleNotFoundError: cv2. Skip them when OpenCV is absent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jewbetcha

Copy link
Copy Markdown
Member

Thanks for this @btripp - any way we can break this out into separate PRs? 7k lines is a lot to review :D

A phone that left the app or dropped the link got only the latest shot
back, so shots taken meanwhile never reached it. Schema v2 now catches
it up with one rule on both transports:

- BLE: hello accepts an optional last_event_id. The catch-up goes out
  on the v2 shot characteristic after the hello response, held until
  the phone subscribes to it, and queued with backpressure so more
  missed shots than the 8-message queue are not dropped.
- Network: the v2 stream honours Last-Event-ID (or ?last_event_id=)
  and v2 shot frames carry id: <event_id>, so EventSource resumes on
  its own.

The named shot is resent with every current-session shot after it (the
whole session when none is named or it is gone), capped at 20. Resending
the anchor brings a phone that only saw a provisional up to the final.
The session (monitor.get_shots) decides what exists, so cleared and
deleted shots are never replayed, and PhoneShotCache replays the exact
bytes last sent live. hello advertises shot_catch_up; the hello goldens
change only by that entry. Catch-up failures never fail hello: the
phone falls back to the latest-shot replay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9pYpWtxN9nJ6vpqbXwB7m
Adds the catch-up rule for BLE and the network stream to the phone
guide (hello last_event_id, Last-Event-ID, 20-shot cap, anchor resend,
broadcast and queue caveats), updates negotiation, the stream seed and
delivery behaviour, lists the new tests, and notes it in the changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9pYpWtxN9nJ6vpqbXwB7m
@btripp

btripp commented Sep 28, 2026

Copy link
Copy Markdown
Author

BLE hardware test: Pi 5 + iPhone

Tested 0e31cad on a Raspberry Pi 5 (Raspberry Pi OS trixie, BlueZ 5.82) running ./scripts/start-kiosk.sh --mock --ble, with the current iOS app over Bluetooth. Shots were simulated in mock mode; events were triggered from the Pi through the same Socket.IO/HTTP paths the kiosk uses.

# Test Direction Result
1 Shots taken while the phone was disconnected appear on first connect Pi → phone ✅ 3 shots
2 Leave the app, shots are taken, come back Pi → phone ✅ all 5, no duplicates
3 club_changed Pi → phone ✅
4 set_club phone → Pi ✅
5 profiles (profile added on the kiosk) Pi → phone ✅
6 set_active_profile phone → Pi ✅
7 shot_deleted Pi → phone ✅
8 A deleted shot is not replayed after a reconnect Pi → phone ✅
9 session_cleared Pi → phone ✅
10 Live shot with the app open Pi → phone ✅

New since the last review

  • Pairing prompt every ~30 s fixed (c6a168e). BlueZ's own GATT client read the iPhone's GATT database and requested pairing that nothing on a headless Pi could confirm, so the link dropped after the 30 s SMP timeout and looped. scripts/setup/configure_bluetooth.sh (offered by setup.sh) sets Client = false under [GATT] in /etc/bluetooth/main.conf. Note that stock main.conf lists #Client under [CSIS], where bluetoothd ignores it. Verified with btmon: no SMP traffic, and the link stays up.
  • Catch-up after a reconnect (dfe6202, docs 0e31cad). hello accepts an optional last_event_id, and the v2 network stream honours Last-Event-ID. The Pi resends that shot plus every later current-session shot (up to 20), or the whole session when none is named. The current app doesn't send last_event_id yet, so it receives the whole session and dedupes by event_id, which is what tests 1, 2 and 8 exercise. hello advertises shot_catch_up. Details: catch-up.

Not covered on hardware: power_status, shot_processing and IWR6843 calibration need a battery monitor, camera/IWR hardware or the IWR6843, so they're covered only by the loopback tests. Network (SSE) catch-up is covered by tests but hasn't been tried from the app.

Full suite: 1826 passed, 22 skipped.

btripp and others added 2 commits September 28, 2026 16:20
A Pi with no radar, UPS or camera could not produce shot_processing,
provisional-then-final shots or power_status, so phone apps could not
be tested against them. Mock mode now simulates each one through the
real server paths:

- MockLaunchMonitor reports capturing then calculating to the
  processing callback like the rolling-buffer radar, and
  simulate_shot(fail=True) (Socket.IO {"fail": true}) reports failed
  without recording a shot.
- --mock-enrichment-ms MS makes mock shots take the slow-enrichment
  path: recorded without horizontal launch, club path and spin axis,
  published provisional, then finalized once MockLaunchMonitor.enrich
  supplies them after MS. Past the 20 s deadline they finalize as
  skipped, as real hardware does. Off by default, so plain --mock is
  unchanged.
- --battery mock adds a deterministic simulated battery that cycles
  from full through low and critical, then charges back to full. It
  never falls back to a real Linux battery, so a laptop's own battery
  cannot stand in for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9pYpWtxN9nJ6vpqbXwB7m
Adds a phone-guide section on running a hardware-free Pi that produces
every phone event (--battery mock, --mock-enrichment-ms, failed shots),
lists the mock battery provider, and notes it in the changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9pYpWtxN9nJ6vpqbXwB7m
@btripp

btripp commented Sep 28, 2026

Copy link
Copy Markdown
Author

Thanks for this @btripp - any way we can break this out into separate PRs? 7k lines is a lot to review :D

For sure no problem. Will break it out here shortly. Currently Finishing up the On Device testing and its working good so far. Then will break it down.

@btripp
btripp marked this pull request as draft September 29, 2026 03:17
@btripp

btripp commented Sep 29, 2026

Copy link
Copy Markdown
Author

Splitting this PR up

Thanks for the feedback on size. I'm breaking this PR into smaller PRs that each stand on their own, and converting it to a draft. It stays open as the reference for the whole feature until the pieces land.

# PR Status
1 Bluetooth LE connection for phone apps: protocol, BLE publisher, club/profile/power commands, phone events, and the BlueZ pairing-loop fix #285, open for review
2 Network transport: SSE /api/shots/stream and /api/club next
3 Catch-up of missed shots after a reconnect follows 2
4 Phone-assisted IWR6843 tilt calibration follows 1
5 Mock-mode simulation of battery, processing states and provisional → final shots follows 1

One design change in the split: the phone protocol is now schema 2 only. No app was released against version one, so the v1 characteristic pair and its compatibility layer are gone, which removes about 850 lines of compatibility code, tests and goldens. The schema-2 wire format is unchanged: all remaining goldens are byte-identical to this branch. The KMP companion app has been updated to discover the schema-2 characteristics. jake-fishtech's SwiftUI app on feat/iOS-ble still looks for the v1 pair and needs the same change.

#285 was re-verified on a Pi 5 with the updated app: connection, live shots, club changes in both directions, and profile selection.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants