Files
AndroidAisMap/BLE_PROTOCOL_V2.md

5.9 KiB
Raw Permalink Blame History

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 (see ble_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 (+ optional notify)
    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 = 0
  • chunk_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_CHUNKSNAPSHOT_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:

  1. Serialize payload as JSON (UTF-8), no pretty formatting required.
  2. Let max_payload_bytes be the per-session limit (default 120; may be reduced based on negotiated ATT MTU).
  3. Split payload bytes into chunks of at most max_payload_bytes.
  4. For each chunk:
    • Build 10-byte header with the same session_msg_id
    • Set chunk_index from 0..chunk_count-1
    • Set chunk_count to total chunks
    • Set payload_len to the chunk length
    • Append payload bytes
  5. Send frames in order.

Client-side reassembly:

  1. Group frames by (session_msg_id, msg_type) within the BLE connection.
  2. Collect chunk_count frames.
  3. Concatenate chunk payloads by chunk_index.
  4. 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 = 101
  • msg_type = HELLO_ACK (0x01)01
  • session_msg_id = 0x002A (42)2A 00
  • chunk_index = 000 00
  • chunk_count = 101 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)05
  • session_msg_id = 0x0042 (66)42 00
  • payload does not fit → split into 3 chunks (chunk_count = 303 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}}