Phone app connection (Bluetooth LE)¶
BLE blocker — check the Raspberry Pi kernel first: Raspberry Pi kernel
6.18.34+rpt-rpi-2712has a confirmed regression that rejects every BLE advertisement. Rununame -ron the Pi. If it reports that version, boot a working kernel such as 6.12.x; there is no userspace workaround. See the full diagnosis.
OpenFlight sends each completed shot from a Raspberry Pi to a phone app over
Bluetooth LE, using the versioned payload described below (schema version 2).
The Kotlin Multiplatform companion for Android and iOS,
btripp/openflight-mobile-kmp,
speaks it. jake-fishtech's SwiftUI app on the
feat/iOS-ble branch of his fork
speaks an earlier, unreleased version one that the Pi does not serve; it needs
updating to schema 2.
Requirements¶
- Raspberry Pi running Raspberry Pi OS (working Bluetooth only for the BLE transport)
- iPhone running iOS 17 or newer
- Mac with Xcode 16 or newer to build the app
- The normal OpenFlight radar setup
The Pi uses Bless to expose a small GATT server through BlueZ.
Run the Pi¶
The interactive setup script installs the optional BLE dependency on new installations:
For an existing checkout, install it, configure BlueZ once so iOS does not keep prompting to pair (see Troubleshooting), and start OpenFlight with BLE enabled:
BLE startup and delivery errors are isolated from shot recording. If Bluetooth is unavailable, the browser UI and session logger continue to work.
Connect the phone app¶
The phone apps are not part of this repository; build and install one from its
own repository. Start OpenFlight on the Pi with --ble. The app scans only for
the OpenFlight service, connects automatically, and subscribes to shot and
control notifications. Hit a shot and its metrics should replace the empty
dashboard. The most recent shot is replayed when a phone connects, so a newly
connected phone does not have to wait for another shot.
Select the club from the iPhone¶
Use Club for next shot on the dashboard to select any supported wood, hybrid, iron, or wedge. OpenFlight applies the club to subsequent shots and confirms the change before the app updates its saved selection. The change goes over the framed control characteristic described below.
The browser UI and simulator integrations use the same server operation, so a phone club change affects the same launch, spin, and carry processing state.
The iOS Simulator can run the automated tests, but CoreBluetooth does not provide a useful end-to-end BLE hardware test there. Use a physical iPhone and Raspberry Pi for manual connection testing.
Wire protocol¶
Phones speak JSON messages, schema version 2, split across BLE notifications. There is no version one on the wire: no app was released against it.
Characteristics¶
| Attribute | UUID | Properties |
|---|---|---|
| Service | B6F633F2-E6E3-45AE-84B4-968ECCA2D9C7 |
|
| Shot notification | ED365FE6-3ABF-4FC3-8E44-D9525A22DABD |
notify |
| Control | 7BA96E63-12C2-4CE0-BB84-3513C7FD1474 |
write with response, notify |
BlueZ notifies every central subscribed to a characteristic from that
characteristic's single value: the GATT D-Bus API has no per-device notify, and
Bless drops the writing device's path. So every connected phone receives every
shot, event and command response; match responses to your own requests by
request_id.
Encoding and framing¶
Messages are compact JSON with sorted keys, and text is UTF-8 rather than
\uXXXX escapes (that is what lets twelve 40-character profile names fit in one
message). Over BLE each message is split into conservative 20-byte
notifications, each a five-byte big-endian header followed by up to 15 payload
bytes:
| Byte(s) | Meaning |
|---|---|
| 0 | Frame version (1) |
| 1–2 | Unsigned 16-bit message sequence, counted per characteristic |
| 3 | Zero-based fragment index |
| 4 | Total fragment count |
| 5–19 | JSON payload fragment |
Group frames by sequence, ignore duplicate fragment indexes, order fragments by
index, and decode UTF-8 only after all fragments arrive: a fragment boundary can
split a multi-byte character. The shot contract fixture is
tests/fixtures/shot_v2.json, and framed byte-level goldens for every message
type live in tests/fixtures/ble_goldens/ (see
Testing without hardware).
Commands and responses¶
Commands are written to the control characteristic in the same framing. A
command contains schema_version, a unique request_id, a type and a JSON
payload; the Pi notifies a response with the matching request_id, ok, and
either result or error:
{"payload":{"club":"7-iron"},"request_id":"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","schema_version":2,"type":"set_club"}
{"ok":true,"request_id":"9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d","result":{"club":"7-iron","status":"applied"},"schema_version":2}
Responses and events share the control characteristic: messages with a type
are events, the rest are responses. A request envelope may carry
schema_version 1 or 2 (so a client can reuse one encoder for hello);
responses always carry schema_version: 2. Each command calls the same server
function as its Socket.IO counterpart, so the kiosk and every other client see
the same broadcasts.
| Command | Payload | Result | Also broadcasts |
|---|---|---|---|
hello |
{"client_schema_max":2} |
see Negotiation | |
get_club |
{} |
{"status":"current","club":"7-iron"}: the Pi-owned club, unchanged |
|
set_club |
{"club":"7-iron"} |
{"status":"applied","club":"7-iron"} |
club_changed |
get_profiles |
{} |
{"status":"sent"} |
profiles: the roster arrives as the event, not in the result |
set_active_profile |
{"profile_id":…} |
{"status":"applied","active_profile_id":…}, or ok:false Unknown profile |
profiles (also when rejected) |
get_power_status |
{} |
the power_status payload, or ok:false Battery monitoring is not enabled / No battery reading yet |
Over BLE the phone is read-and-select only (see Security).
Adding, renaming and removing profiles, clear_session and delete_shot stay
on Socket.IO and the kiosk; sent over BLE, they and any unknown command fail
with Unsupported phone command: <type>. Phones still learn about those
changes from the profiles, session_cleared and shot_deleted events. An
event triggered by a command is normally notified before the command's
response, but clients must accept either order. The Pi processes commands as
they arrive and enforces no busy state or timeout of its own; clients own their
timeouts.
Negotiation¶
- Discover the service and subscribe to the control characteristic.
- Write
hello: The result names the schema, the features and the characteristics: - Subscribe to the shot characteristic. The latest shot is replayed.
- Ask for state:
get_club,get_profilesand, if wanted,get_power_status.
A client_schema_max below 2 fails with ok:false: the Pi speaks schema 2 only.
Shot¶
Sent on the shot characteristic:
| Field | Type | Meaning |
|---|---|---|
schema_version |
2 |
|
type |
"shot" |
|
event_id |
UUID string | Stable per shot: the provisional and final versions of one shot share it. Upsert by event_id |
final |
bool | false: OPS-only provisional shot, sent while optional hardware (IWR6843, camera) is still working. true: the final shot |
timestamp, club |
string | Detection time and the club the shot was hit with |
ball_speed_mph, estimated_carry_yards |
number | |
club_speed_mph, smash_factor, launch_angle_vertical, launch_angle_horizontal, spin_rpm, club_path_deg, spin_axis_deg |
number or null | null when the active hardware could not produce it |
shot_number |
int or null | Per-monitor-run sequence; not reused after a delete |
profile_id, profile_name |
string or null | Profile the shot was attributed to at detection |
carry_range |
[low, high] or null |
Carry range in yards |
spin_source |
string or null | Where spin_rpm came from |
launch_angle_confidence |
number or null | 0–1 |
enrichment |
object or null | {"status":"pending"} on a provisional shot; {"status":"complete"} or {"status":"skipped","reason":"deadline"|"capacity"|"queue_full"|"worker_unavailable"} on a final shot that had a provisional; null when the shot never waited for optional hardware |
Every key is always present; unknown values are null. A shot with no optional
hardware configured is sent once, final. The contract fixture is
tests/fixtures/shot_v2.json, built from a real mock shot:
{"ball_speed_mph":106.1,"carry_range":[144,160],"club":"7-iron","club_path_deg":2.5,"club_speed_mph":83.5,"enrichment":{"status":"complete"},"estimated_carry_yards":152,"event_id":"05dd37ec-49ed-596b-b1a4-953d54e4f239","final":true,"launch_angle_confidence":0.6,"launch_angle_horizontal":-0.7,"launch_angle_vertical":21.2,"profile_id":"0f8e4b2a9c7d4e1f8a6b3c5d7e9f1a2b","profile_name":"Zoë","schema_version":2,"shot_number":7,"smash_factor":1.27,"spin_axis_deg":-1.6,"spin_rpm":6482,"spin_source":null,"timestamp":"2026-09-25T14:03:07.412345","type":"shot"}
Events¶
Notified on the control characteristic. Each has schema_version: 2 and a type, and never a request_id:
type |
Fields | When |
|---|---|---|
club_changed |
club |
Any club change (kiosk, phone, simulator) |
profiles |
profiles: [{id, name}], active_profile_id |
After every profile request or mutation from any client, including rejected ones |
session_cleared |
profile_id |
After a profile's shots are cleared on the kiosk |
shot_deleted |
timestamp (the shot's delete key) |
After a shot is deleted on the kiosk |
shot_processing |
state: capturing, calculating or failed |
Rolling-buffer monitor progress; the next shot ends it |
power_status |
the Socket.IO power_status payload: available, provider, state, battery_percent, battery_voltage_v, external_power, updated_at, error |
Every 5 s with --battery |
{"club":"7-iron","schema_version":2,"type":"club_changed"}
{"active_profile_id":"0f8e…","profiles":[{"id":"0f8e…","name":"Zoë ⛳"},{"id":"7c1d…","name":"Sam"}],"schema_version":2,"type":"profiles"}
{"profile_id":"0f8e…","schema_version":2,"type":"session_cleared"}
{"schema_version":2,"timestamp":"2026-09-25T14:03:07.412345","type":"shot_deleted"}
{"schema_version":2,"state":"calculating","type":"shot_processing"}
{"available":true,"battery_percent":76.5,"battery_voltage_v":3.98,"error":null,"external_power":false,"provider":"geekworm","schema_version":2,"state":"on_battery","type":"power_status","updated_at":"2026-09-25T14:03:05.000000+00:00"}
Profiles carry only id and name. created_at and the open-ended settings
stay on Socket.IO, because the phone only selects profiles here and an
unbounded settings object could not be guaranteed to fit in one BLE message.
Size budget¶
A BLE message is at most 255 fragments × 15 bytes = 3,825 bytes. Tests encode
a worst-case shot (longest float representations everywhere, a 40-character
profile name of six-byte escapes) and a profiles event with twelve such names
(3,610 bytes) and require both to fit. The publisher refuses, and logs, any
message that would not fit instead of sending a truncated one.
Delivery behavior¶
- Shot processing never waits for Bluetooth, and a BLE failure cannot affect the browser UI or session logging.
- Each connected client gets a bounded queue of eight unsent events; the oldest queued event is dropped if that client cannot keep up. One stalled phone cannot slow down another.
- Disconnecting clears that client's queue; the latest completed shot is replayed on the next connection.
- Clients upsert shots by
event_id, which ignores replays of shots they already have and merges a provisional shot with its final version.
Security and scope¶
The phone protocol intentionally has no application authentication or encryption layer. Enable BLE only where nearby Bluetooth devices receiving shots and issuing club and profile selections is acceptable.
BLE is unauthenticated: any nearby device can connect and write the control characteristic. The protocol therefore exposes only reading state, selecting (club, active profile) over Bluetooth. Actions that delete data, clearing a session or deleting a shot, and profile add, rename and remove stay on the kiosk (Socket.IO), where they have the same exposure the browser UI already has. Revisit this only with authenticated pairing.
Testing without hardware¶
Everything above the radio is covered by the normal test suite, with no Pi,
Bluetooth adapter or bless install:
tests/ble_harness.pyruns the realBleShotPublisheron its own thread and event loop against a fake Bless server that behaves like Bless 0.3.0 on BlueZ (subscription hooks called beforeapp.subscribed_characteristicschanges, notifications delivered only to centrals subscribed to that characteristic, writes throughwrite_request_func).VirtualCentrals play the phones: they subscribe, write framed commands and reassemble notifications with the real reassembler.tests/test_ble_loopback.pyuses it end to end against the real server dispatch: latest-shot replay,helloin either request envelope, a provisional-then-final shot reaching every phone with oneevent_id, club and profile commands, unknown commands, and one phone leaving while another keeps receiving.tests/fixtures/ble_goldens/*.jsonhold framed hex for every message type.server_to_clientfiles are generated byuv run python scripts/ble/generate_goldens.pyand checked bytests/test_ble_goldens.py; client test suites decode them.client_to_serverfiles are the reverse: a client commits the frames its own encoder produces (name,characteristic,sequence,message,payload_hex,frames_hex, and anexpectblock withok,schema_versionand expectedresultfields), and the Python tests reassemble them, dispatch them through the loopback server and check the answer.
uv run pytest tests/test_ble_protocol.py \
tests/test_ble_publisher.py tests/test_ble_loopback.py tests/test_ble_goldens.py \
tests/test_phone_transport_server.py tests/test_phone_transport.py \
tests/test_control_commands.py tests/test_configure_bluetooth.py -v
uv run python scripts/ble/generate_goldens.py --check
What still needs a Pi and phones: BlueZ advertising, discovery and connection from iOS and Android, pairing and permission prompts, fragment pacing over a real link, reconnects after a Pi restart, background behaviour, and coexistence with Socket.IO clients.
Troubleshooting¶
The app stays on “Looking for OpenFlight.”
- Confirm OpenFlight was started with
--ble. - Run
bluetoothctl showon the Pi and confirmPowered: yes. - Keep the app in the foreground for the initial connection.
- Restart OpenFlight after changing the Pi Bluetooth configuration.
The iPhone shows a pairing prompt every 30 seconds.
Symptom: the phone connects, then disconnects about every 33 seconds and iOS
asks to pair again. The OpenFlight log shows [BLE] Client subscribed followed
by [BLE] Client unsubscribed exactly 30 seconds later, and bluetoothctl info
<phone> 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):
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:
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:
The probe prints which layer is implicated. For the parameter-level detail, capture the management interface while it runs:
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:
@ MGMT Event: Command Complete Add Extended Advertising Parameters (0x0054)
Status: Success (0x00)
Available adv data len: 31
Available scan rsp data len: 31
@ MGMT Command: Add Extended Advertising Data (0x0055)
Advertising data length: 0
Scan response length: 0
@ MGMT Event: Command Status Add Extended Advertising Data (0x0055)
Status: Invalid Parameters (0x0d)
Nothing in userspace can shrink a zero-byte payload, so no OpenFlight or Bless setting works around this. Boot a kernel without the regression (6.12.x is reported to work) and rerun the probe.
The Pi logs that Bluetooth is unavailable.
- Run
uv sync --extra ble. - Confirm the BlueZ service is running with
systemctl status bluetooth. - Confirm the user running OpenFlight can access the system D-Bus and Bluetooth adapter.
The app connects but no new shot appears.
- Confirm the browser UI received the shot; BLE publishes only completed shot events.
- Look for
[BLE]warnings in the OpenFlight terminal. - Tap Retry in the app to disconnect, scan, and subscribe again.
Automated tests¶
The Python side is covered in Testing without hardware.
Phone apps run their own test suites and decode the committed goldens in
tests/fixtures/ble_goldens/.