Skip to content

feat(phone): network shot stream, club API and reconnect catch-up - #297

Merged
Cormac131 merged 6 commits into
open-flight:mainfrom
btripp:feat/phone-network-stream
Oct 21, 2026
Merged

Cormac131 merged 6 commits into
open-flight:mainfrom
btripp:feat/phone-network-stream

Conversation

@btripp

@btripp btripp commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Reviewing this PR

Production code is about 520 lines; the rest is tests (~1,115) and docs (~120).

Part Where
SSE broker src/openflight/shot_stream.py (new)
Catch-up rule and shot cache src/openflight/phone_catch_up.py (new)
BLE hello catch-up src/openflight/ble/publisher.py, ble/protocol.py (shot_catch_up and deletion_catch_up features)
Wiring src/openflight/server.py: /api/shots/stream, /api/club, phone_catch_up(), stream fan-out

Suggested reading order: phone_catch_up.py (the rule), shot_stream.py, the server.py diff, then tests/test_shot_stream_catch_up.py and tests/test_ble_catch_up.py.

What does this PR do?

Follow-ups 1 and 2 from #285, in one PR because catch-up spans both transports:

  • Network transport. GET /api/shots/stream sends the same schema 2 messages as BLE as Server-Sent Events. A stream opens with : ping, the current club_changed, profiles and (with --battery) power_status, then the session's shots, then live shots and events, with a heartbeat every 15 s. No flag needed: it exposes nothing the browser UI does not already broadcast. Up to eight clients, 503 beyond that.
  • Club API. GET/POST /api/club reads and sets the club through the same apply_club_selection as the kiosk and BLE; club changes are broadcast on the stream too.
  • Catch-up after a reconnect. A phone names the newest shot it has (last_event_id in BLE hello, or Last-Event-ID / ?last_event_id= on the stream) and gets that shot again plus every later current-session shot, oldest first, up to 100. Replays are the exact bytes last sent live (so a phone that only had a provisional shot gets the final); cleared and deleted shots are never replayed. Shots deleted on the kiosk while the phone was away are sent first, as shot_deleted events (those after the named shot, or the last 100 remembered when the anchor is unknown). A BLE catch-up leaves two delivery-queue slots free, so a shot taken meanwhile doesn't push a missed one out. hello advertises shot_catch_up and deletion_catch_up; the stream names the same features in an OpenFlight-Features header so phone apps can tell an older Pi apart.

Why was this required?

#285 shipped the BLE core of #282, which reviewers asked to have split up. This is the next piece. Phones without working BLE advertising (for example on the Pi kernel 6.18.34+rpt-rpi-2712 regression) need an IP transport, and any phone that leaves the app or walks out of range currently loses every shot taken while away.

This PR is independent of the other two follow-ups (phone-assisted IWR6843 tilt calibration, mock-mode hardware simulation); they can merge in any order.

Automated tests

  • tests/test_shot_stream.py: broker fan-out, seeding, heartbeat, subscriber limit, slow-client dropping.
  • tests/test_phone_catch_up.py: the catch-up rule (named shot resent, unknown/invalid id → whole session, 100-shot cap), the shot cache, and the deletions since an anchor.
  • tests/test_shot_stream_catch_up.py, tests/test_ble_catch_up.py: catch-up end to end on each transport, including hello before subscribing, cleared/deleted shots, provisional → final, deletions missed while away, a 55-shot gap (a tester's), a live shot during catch-up, and the features header.
  • tests/test_phone_transport.py, tests/test_phone_transport_server.py, tests/test_control_commands.py, tests/test_server.py: /api/club, the stream route, fan-out to SSE and BLE, and a failing transport not affecting the shot pipeline.
  • tests/fixtures/ble_goldens/v2_response_hello.json regenerated for the new feature flag (scripts/ble/generate_goldens.py --check passes).

uv run pytest tests/: 1858 passed, 11 skipped. On my Mac 13 camera tests (test_camera_ball_flight.py, test_camera_club_delivery.py, test_camera_club_motion.py) fail; they fail identically on the commit before this one, so they are unrelated.

The phone app (btripp/openflight-mobile-kmp) runs its data layer against openflight-server --mock from this branch: a shot taken and a shot deleted on the kiosk while the app was stopped both reach the phone on reconnect. Against main and #282 the deletion arrives over Wi-Fi (Socket.IO session_state) but the shot does not, and the app shows an "update OpenFlight on your Pi" notice.

Manual (human) testing

On a Mac with openflight-server --mock --web-port 8765:

  • GET /api/club → {"club":"driver","status":"current"}; POST {"club":"7-iron"} applied and appeared as club_changed on an open curl -N stream; POST {"club":"putter"} → 400; ?schema=1 → 400.
  • Three simulated shots arrived live on the stream, each event: shot with an id:.
  • Reconnecting with Last-Event-ID set to the first shot replayed shots 1–3 in order; reconnecting with no id replayed all 3.
  • Eight concurrent streams were accepted and the ninth got 503.

Re-run after merging current main and the deletion catch-up commit: a real openflight-server --mock on macOS, driven over HTTP, SSE and Socket.IO:

  • Club API. GET returns the current club. A valid POST is applied and appears as club_changed on an open stream. Unknown clubs, an empty body, "unknown" and a non-JSON body all return 400, and the club stays the same.
  • Stream. The stream response is text/event-stream and carries an OpenFlight-Features header that includes shot_catch_up and deletion_catch_up. Each event: shot frame's id: equals the payload's event_id. schema=1, 3 and abc return 400.
  • Shot catch-up. I closed the stream after shot 1 and took shots 2–4. Reconnecting with Last-Event-ID replayed 1, 2, 3, 4, oldest first. ?last_event_id= gives the same result, and the header wins when both are sent. No id, or an unknown one, replays the whole session.
  • Deletion catch-up. I deleted shot 3 from the kiosk while the stream was closed. Reconnecting from shot 1 sent shot_deleted first, then shots 1, 2 and 4; shot 3 was not replayed. Anchoring on a shot taken after the delete sends no shot_deleted. An open stream gets shot_deleted live.
  • Limits. Eight concurrent streams return 200, and the ninth gets 503 Shot stream already has 8 clients. A : ping heartbeat arrives every 15 s.
    • A client that closes is only noticed when the next write fails, so it can hold one of the 8 slots for up to about 45 s.

Not yet tested: BLE reconnect catch-up with the phone app on a Pi. No Pi was set up for this PR; I'll add the results here once one is.

Checklist

  • Single feature/fix — the network transport and the catch-up it shares with BLE
  • Automated tests included — new or updated tests cover this change
  • Manual testing described — mock-mode testing above; BLE phone test on a Pi still to do
  • Python tests pass (uv run pytest tests/ -v)
  • Pylint passes (uv run pylint src/openflight/ --fail-under=9): 9.61 after merging current main; no messages on lines this PR adds
  • Ruff passes (uv run ruff check src/openflight/)
  • UI builds (cd ui && npm run build): no UI changes
  • UI lint passes (cd ui && npm run lint): no UI changes
  • Updated docs or CHANGELOG if needed (docs/ios-ble.md, docs/changelog.md)
  • No unrelated changes mixed in

🤖 Generated with Claude Code

Adds the network transport for phone apps and catch-up of missed shots
after a reconnect, on top of the BLE transport from open-flight#285.

- GET /api/shots/stream serves the schema 2 phone messages as
  Server-Sent Events: current club, profiles and power status, then the
  session's shots, then live shots and events, with a 15 s heartbeat.
  Up to eight clients; 503 beyond that.
- GET/POST /api/club reads and sets the club through
  apply_club_selection, and club changes are broadcast on the stream.
- A reconnecting phone names the newest shot it has (last_event_id in
  BLE hello, or Last-Event-ID on the stream) and is sent that shot and
  every later current-session shot, oldest first, up to 20. Replays are
  the exact bytes sent live; cleared and deleted shots are never
  replayed. hello advertises the shot_catch_up feature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tester's phone missed a kiosk deletion and 35 of 55 shots taken while
it was disconnected: catch-up never told the phone about deletions and
sent only the newest 20 shots.

- Catch-up now sends the shots deleted while the phone was away first,
  as shot_deleted events: those made after the named shot was first
  sent, or every remembered one (the last 100) when the anchor is
  unknown. PhoneShotCache orders sends and deletions with one counter.
- The catch-up limit is 100 shots instead of 20.
- A BLE catch-up leaves two delivery-queue slots free, so a live shot
  taken meanwhile no longer pushes a missed shot out of the queue.
- hello advertises deletion_catch_up, and the network stream names the
  same features in an OpenFlight-Features header, so a phone app can
  tell a Pi that predates them and suggest an update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Cormac131
Cormac131 merged commit 1fa05a6 into open-flight:main Oct 21, 2026
14 checks passed
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