Skip to content

feat(ble): Bluetooth LE connection for phone apps - #285

Open
btripp wants to merge 2 commits into
open-flight:mainfrom
btripp:feat/phone-ble
Open

btripp wants to merge 2 commits into
open-flight:mainfrom
btripp:feat/phone-ble

Conversation

@btripp

@btripp btripp commented Sep 29, 2026 •

Copy link
Copy Markdown

Reviewing this PR

Most of the +5,309 lines are tests and generated fixtures. The production code is about 1,120 lines:

Part Lines Where
Production code ~1,120 src/openflight/ble/ (864), src/openflight/server.py wiring (256)
Setup scripts 146 scripts/setup/configure_bluetooth.sh, setup.sh, start-kiosk.sh, pyproject.toml
Tests 2,315 tests/test_ble_*.py, tests/ble_harness.py, server and transport tests
Generated goldens 836 tests/fixtures/ble_goldens/, written by scripts/ble/generate_goldens.py and checked by a test
Dev tools 463 the golden generator and scripts/hardware-test/test_ble_advertise.py (advertising probe)
Docs 429 docs/ios-ble.md, changelog, nav

Suggested reading order: ble/protocol.py (the wire contract), then ble/publisher.py, then the server.py diff, then tests/test_ble_loopback.py for end-to-end behaviour. The goldens can be skimmed: they are generated, and a test fails if they drift from the encoder.

What does this PR do?

Adds a Bluetooth LE connection for phone apps: --ble advertises one GATT service with a shot (notify) and a control (write + notify) characteristic, speaking phone schema version 2.

  • Shots carry event_id, shot_number, profile, carry_range, spin_source, launch_angle_confidence, final and enrichment. Hardware-enriched shots arrive twice, provisional then final, with one event_id.
  • Events: club_changed, profiles, power_status, shot_processing, session_cleared, shot_deleted.
  • Commands: hello, get_club, set_club, get_profiles, set_active_profile, get_power_status. Each calls the same server function as its Socket.IO counterpart, so the kiosk and every client see the same broadcasts.
  • Read-and-select only: BLE is unauthenticated, so clearing sessions, deleting shots and editing profiles stay on the kiosk.
  • One Pi-owned club selection (apply_club_selection), shared by the kiosk, phones and simulators and broadcast over Socket.IO and BLE.
  • scripts/setup/configure_bluetooth.sh (offered by setup.sh) sets [GATT] Client = false in /etc/bluetooth/main.conf. Without it, BlueZ's own GATT client makes iOS show a pairing prompt every ~30 s and drops the link. Stock Raspberry Pi OS lists #Client under [CSIS], where bluetoothd ignores it, so the script writes it under [GATT].
  • Optional ble extra (bless==0.3.0 on Linux). start-kiosk.sh --ble syncs it, and without BlueZ or the extra, --ble logs that Bluetooth is unavailable and carries on.

Wire format and troubleshooting: docs/ios-ble.md.

Why was this required?

This is the first piece of #282, which reviewers asked to have split up (it was ~7,000 lines). This PR is the core that makes a phone app work over Bluetooth. It is v2-only: no app was released against the earlier version one, so the second (v1) characteristic pair and its compatibility code are not included.

Follow-ups, each a separate PR on top of this one:

  1. Network transport: SSE /api/shots/stream and /api/club
  2. Catch-up of missed shots after a reconnect (last_event_id / Last-Event-ID)
  3. Phone-assisted IWR6843 tilt calibration
  4. Mock-mode simulation of battery, processing states and provisional → final shots

Breaking for unreleased apps: a phone app must discover the schema-2 characteristics (ED365FE6… shot, 7BA96E63… control). The KMP companion app has been updated for this. jake-fishtech's SwiftUI app on feat/iOS-ble still expects the v1 pair and needs the same change.

Automated tests

  • tests/test_ble_protocol.py: payloads, hello, the size budget (worst-case shot and 12 worst-case profile names fit one BLE message) and framing.
  • tests/test_ble_publisher.py: delivery, queueing, subscription hooks, control reassembly, and startup isolation.
  • tests/test_ble_loopback.py: end to end against the real server dispatch, using a loopback fake of Bless/BlueZ (tests/ble_harness.py). Covers latest-shot replay, provisional → final reaching every phone, club/profile/power commands, read-and-select enforcement, and one phone leaving while another keeps receiving.
  • tests/test_ble_goldens.py + tests/fixtures/ble_goldens/: framed hex goldens for client test suites (scripts/ble/generate_goldens.py --check fails on drift).
  • tests/test_configure_bluetooth.py: the BlueZ config script, with sudo/systemctl stubbed so it is safe to run on a Pi.
  • tests/test_phone_transport.py, tests/test_phone_transport_server.py, tests/test_control_commands.py, tests/test_server.py: server fan-out and command routing, and a failed WebSocket emit no longer stopping BLE.

uv run pytest tests/: 1720 passed. The only failures are 11 OpenCV camera tests that already fail on main when cv2 is not installed (fixed separately).

Manual (human) testing

On a Raspberry Pi 5 (Raspberry Pi OS trixie, BlueZ 5.82) running ./scripts/start-kiosk.sh --mock --ble, with the KMP companion app on an iPhone:

  • Without configure_bluetooth.sh, iOS showed a pairing prompt and the link dropped every ~33 s. btmon showed BlueZ's GATT client triggering an SMP Security Request. After the script, there was no SMP traffic and the connection held for 20+ minutes.
  • The app connected with only the two schema-2 characteristics present (log: [BLE] Client subscribed).
  • A live mock shot appeared on the phone (126.7 mph).
  • Club changes worked in both directions: 7-iron set on the kiosk showed on the phone, and Driver set on the phone was applied on the Pi.
  • A profile added on the kiosk appeared on the phone as active. Switching back to "Profile 1" from the phone was applied on the Pi.
  • The same wire contract (all 12 goldens are byte-identical) passed a wider 14-test phone run earlier on feat(phone): add BLE, network shot stream and club API for mobile companion apps #282: shot_deleted, session_cleared, power_status, provisional → final shots, the failed-capture state, and the calibration error path.

Checklist

  • Single feature/fix — this PR is scoped to one thing with a clear story above
  • 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), except the 11 pre-existing OpenCV failures noted above
  • Pylint passes (uv run pylint src/openflight/ --fail-under=9): 9.76, unchanged
  • Ruff passes (uv run ruff check src/openflight/)
  • UI builds (cd ui && npm run build): no UI changes; the kiosk built and ran it on the Pi
  • UI lint passes (cd ui && npm run lint): no UI changes
  • Updated docs or CHANGELOG if needed
  • No unrelated changes mixed in

🤖 Generated with Claude Code

`--ble` advertises one GATT service with a shot and a control
characteristic, speaking phone schema version 2 (the only version; none
was released before it). Shots carry event_id, shot_number, profile,
carry_range, spin_source, launch_angle_confidence, final and enrichment,
and hardware-enriched shots arrive provisional then final with one
event_id. Phones get club_changed, profiles, power_status,
shot_processing, session_cleared and shot_deleted events, and can
get_club, set_club, get_profiles, set_active_profile and
get_power_status through the same server functions Socket.IO uses. BLE
is unauthenticated, so it is read-and-select only.

Club selection becomes one Pi-owned value (apply_club_selection) that
the kiosk, phones and simulators share and that is broadcast over
Socket.IO and BLE. scripts/setup/configure_bluetooth.sh (offered by
setup.sh) sets [GATT] Client = false so BlueZ stops asking iPhones to
pair every 30 s. The optional `ble` extra pins bless 0.3.0 on Linux, and
start-kiosk.sh --ble syncs it.

Tests drive the real publisher over a loopback fake of Bless/BlueZ, and
tests/fixtures/ble_goldens/ holds framed hex goldens for client test
suites; they are byte-identical to what the current phone app was
tested against on a Pi 5.

Ported from jake-fishtech's feat/iOS-ble work and open-flight#282, split out so the
network transport, catch-up, phone calibration and mock hardware
simulation follow as separate PRs.

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

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.

1 participant