generated from Grigo/AndroidTemplate
246 lines
5.9 KiB
Markdown
246 lines
5.9 KiB
Markdown
# 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**:
|
||
|
||
```text
|
||
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
|
||
|
||
```json
|
||
{"cmd":"hello","client":"android","app_version":"1.2.3","proto":1}
|
||
```
|
||
|
||
Reply: `HELLO_ACK` (payload JSON).
|
||
|
||
### 4.2 get_snapshot
|
||
|
||
```json
|
||
{"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
|
||
|
||
```json
|
||
{"cmd":"subscribe","events":["ownship.update","target.update","base_station.update","aton.update","stats.update"]}
|
||
```
|
||
|
||
```json
|
||
{"cmd":"unsubscribe","events":["stats.update"]}
|
||
```
|
||
|
||
### 4.4 set_filters (reserved)
|
||
|
||
```json
|
||
{"cmd":"set_filters","targets":{"radius_nm":20,"classes":["A","B"]}}
|
||
```
|
||
|
||
### 4.5 ping
|
||
|
||
```json
|
||
{"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
|
||
|
||
```json
|
||
{
|
||
"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):
|
||
|
||
```json
|
||
{"snapshot_id":42,"section":"ownship","seq":1,"more":false,"item":{...}}
|
||
```
|
||
|
||
For `vessels`/`base_stations`/`atons` (batched array):
|
||
|
||
```json
|
||
{"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
|
||
|
||
```json
|
||
{"snapshot_id":42,"ok":true}
|
||
```
|
||
|
||
## 7. STATUS characteristic payload (UTF-8 JSON)
|
||
|
||
`STATUS.ReadValue` returns compact JSON, example:
|
||
|
||
```json
|
||
{
|
||
"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` → `01`
|
||
- `msg_type = HELLO_ACK (0x01)` → `01`
|
||
- `session_msg_id = 0x002A (42)` → `2A 00`
|
||
- `chunk_index = 0` → `00 00`
|
||
- `chunk_count = 1` → `01 00`
|
||
|
||
Payload JSON:
|
||
|
||
```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:
|
||
|
||
```text
|
||
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 = 3` → `03 00`)
|
||
|
||
Chunk #0 header (payload length `0x0078 (120)`):
|
||
|
||
```text
|
||
01 05 42 00 00 00 03 00 78 00 <120 payload bytes>
|
||
```
|
||
|
||
Chunk #1 header:
|
||
|
||
```text
|
||
01 05 42 00 01 00 03 00 78 00 <120 payload bytes>
|
||
```
|
||
|
||
Chunk #2 header (last chunk shorter, e.g. `0x0034 (52)`):
|
||
|
||
```text
|
||
01 05 42 00 02 00 03 00 34 00 <52 payload bytes>
|
||
```
|
||
|
||
Reassembled JSON becomes the original event:
|
||
|
||
```json
|
||
{"type":"target.update","ts":1700000000.123,"data":{"mmsi":506140446,...}}
|
||
```
|
||
|
||
Other live event payloads use the same envelope:
|
||
|
||
```json
|
||
{"type":"base_station.update","ts":1700000000.123,"data":{"mmsi":2570001,"lat":59.9,"lon":10.7,"epfd":1}}
|
||
```
|
||
|
||
```json
|
||
{"type":"aton.update","ts":1700000000.123,"data":{"mmsi":992570001,"lat":59.9,"lon":10.7,"type":3,"name":"FLAKFORTET","virtual":false}}
|
||
```
|
||
|