5.9 KiB
BLE protocol v2 (AIS Hub transport)
Source of truth: ais_hub. BLE is transport for snapshot + live updates.
1. GATT layout
One custom service:
- Service UUID:
AIS_HUB_SERVICE_UUID(seeble_gatt.py)
Characteristics:
CONTROL(AIS_HUB_CONTROL_UUID):write/write-without-response
Client → server commands as UTF-8 JSON.DATA(AIS_HUB_DATA_UUID):notify
Server → client frames with binary envelope + JSON payload.STATUS(AIS_HUB_STATUS_UUID):read(+ optionalnotify)
Short UTF-8 JSON status blob (see section 7).
2. DATA frame format (binary envelope)
Every DATA notify is one frame:
Byte 0 : protocol_version (u8)
Byte 1 : msg_type (u8)
Byte 2-3 : session_msg_id (u16 LE)
Byte 4-5 : chunk_index (u16 LE)
Byte 6-7 : chunk_count (u16 LE)
Byte 8-9 : payload_len (u16 LE)
Byte 10+ : payload bytes (payload_len bytes, UTF-8 JSON)
Encoding: little-endian for all multi-byte integers. Header size = 10 bytes.
If the logical message fits into one frame:
chunk_index = 0chunk_count = 1
3. msg_type enum
Server → client (DATA):
| Hex | Name | When |
|---|---|---|
0x01 |
HELLO_ACK |
Reply to hello |
0x02 |
SNAPSHOT_BEGIN |
Snapshot start |
0x03 |
SNAPSHOT_CHUNK |
Snapshot section chunk |
0x04 |
SNAPSHOT_END |
Snapshot end |
0x05 |
EVENT |
Live event from ais_hub |
0x06 |
STATUS |
Acknowledgements / state updates |
0x07 |
ERROR |
Error payload |
0x08 |
PONG |
Reply to ping |
4. CONTROL commands (client → server)
Write UTF-8 JSON into CONTROL.
4.1 hello
{"cmd":"hello","client":"android","app_version":"1.2.3","proto":1}
Reply: HELLO_ACK (payload JSON).
4.2 get_snapshot
{"cmd":"get_snapshot","include":["ownship","vessels","base_stations","atons","stats"],"max_vessels":500}
Reply sequence: SNAPSHOT_BEGIN → N×SNAPSHOT_CHUNK → SNAPSHOT_END.
4.3 subscribe / unsubscribe
{"cmd":"subscribe","events":["ownship.update","target.update","base_station.update","aton.update","stats.update"]}
{"cmd":"unsubscribe","events":["stats.update"]}
4.4 set_filters (reserved)
{"cmd":"set_filters","targets":{"radius_nm":20,"classes":["A","B"]}}
4.5 ping
{"cmd":"ping","id":123}
Reply: PONG payload: {"id":123,"server_time":...}.
5. Chunking algorithm (server side)
Given a logical message payload object:
- Serialize payload as JSON (UTF-8), no pretty formatting required.
- Let
max_payload_bytesbe the per-session limit (default 120; may be reduced based on negotiated ATT MTU). - Split payload bytes into chunks of at most
max_payload_bytes. - For each chunk:
- Build 10-byte header with the same
session_msg_id - Set
chunk_indexfrom0..chunk_count-1 - Set
chunk_countto total chunks - Set
payload_lento the chunk length - Append
payload bytes
- Build 10-byte header with the same
- Send frames in order.
Client-side reassembly:
- Group frames by
(session_msg_id, msg_type)within the BLE connection. - Collect
chunk_countframes. - Concatenate chunk payloads by
chunk_index. - Decode UTF-8 and JSON-parse.
6. Snapshot payloads (JSON inside DATA)
6.1 SNAPSHOT_BEGIN
{
"snapshot_id": 42,
"sections": ["ownship","vessels","base_stations","atons","stats"],
"total_objects": {"ownship":1,"vessels":183,"base_stations":4,"atons":12,"stats":1}
}
6.2 SNAPSHOT_CHUNK
For ownship/stats (single object):
{"snapshot_id":42,"section":"ownship","seq":1,"more":false,"item":{...}}
For vessels/base_stations/atons (batched array):
{"snapshot_id":42,"section":"vessels","seq":7,"more":true,"items":[{...},{...}]}
base_stations items follow GET /api/v1/base_stations.
atons items follow GET /api/v1/atons.
6.3 SNAPSHOT_END
{"snapshot_id":42,"ok":true}
7. STATUS characteristic payload (UTF-8 JSON)
STATUS.ReadValue returns compact JSON, example:
{
"proto": 1,
"server_time": 1710000000.123,
"gps_fix": null,
"vessels_active": null,
"ws_source_alive": true,
"snapshot_in_progress": false,
"tx_queue": 0,
"tx_dropped": 0
}
8. Examples (real frame bytes)
Notation: header is struct <BBHHHH> = 10 bytes.
8.1 HELLO_ACK example (single chunk)
Assume:
protocol_version = 1→01msg_type = HELLO_ACK (0x01)→01session_msg_id = 0x002A (42)→2A 00chunk_index = 0→00 00chunk_count = 1→01 00
Payload JSON:
{"ok":true,"proto":1,"server":"ais_ble","server_time":1710000000.0,"features":{"snapshot":true,"live_events":true,"filters":true,"compression":false}}
Let payload_len = 0x0096 (150) (example value; actual length depends on JSON formatting) → 96 00.
Frame hex:
01 01 2A 00 00 00 01 00 96 00 7B 22 6F 6B 22 3A 74 72 75 65 ...
Where 7B 22 6F 6B ... is the UTF-8 JSON ({ "ok":true ... }).
8.2 EVENT example (chunked)
Assume:
msg_type = EVENT (0x05)→05session_msg_id = 0x0042 (66)→42 00- payload does not fit → split into 3 chunks (
chunk_count = 3→03 00)
Chunk #0 header (payload length 0x0078 (120)):
01 05 42 00 00 00 03 00 78 00 <120 payload bytes>
Chunk #1 header:
01 05 42 00 01 00 03 00 78 00 <120 payload bytes>
Chunk #2 header (last chunk shorter, e.g. 0x0034 (52)):
01 05 42 00 02 00 03 00 34 00 <52 payload bytes>
Reassembled JSON becomes the original event:
{"type":"target.update","ts":1700000000.123,"data":{"mmsi":506140446,...}}
Other live event payloads use the same envelope:
{"type":"base_station.update","ts":1700000000.123,"data":{"mmsi":2570001,"lat":59.9,"lon":10.7,"epfd":1}}
{"type":"aton.update","ts":1700000000.123,"data":{"mmsi":992570001,"lat":59.9,"lon":10.7,"type":3,"name":"FLAKFORTET","virtual":false}}