Files
Go2_navigation/unitree_ui/unitree_ui-main/docs/bluetooth-v3.md

338 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Bluetooth Protocol V3 — Unitree G1 + Go2
V3 is a small extension to the V1/V2 BLE protocol. First shipped on the Unitree G1 with firmware 1.5.1, then back-ported to the Unitree Go2 starting with firmware 1.1.15. It runs over the **same** GATT service and characteristics (FFE0 / FFE1 / FFE2 — see [bluetooth-v1-v2.md § Service UUIDs](bluetooth-v1-v2.md#service-uuids)) and coexists on the same connection: a single notify subscription receives both V1/V2 (encrypted) and V3 (unencrypted) frames, distinguished by their first byte.
V3 introduces two distinct categories of frames on top of V1/V2:
1. **Plaintext probe frames** — `0xF1` (device-ID) and `0xF2` (RSA-wrapped key blob). Magic-prefixed, unencrypted, used before any AES key is established.
2. **AES-128-GCM-wrapped command frames** — every legacy V1/V2 op (`GET_SN`, `GET_AP_MAC`, `WIFI_TYPE`, `WIFI_SSID`, `WIFI_PWD`, `COUNTRY`, …) is now AES-GCM-encrypted with a per-device 16-byte key. The plaintext payload inside the GCM envelope is the same `[0x52][len][op][…][cksum]` shape used on V1/V2, so the inner dispatcher is unchanged once decrypted.
The plaintext probe path:
- `0xF1` — historically named `VERSION` in the apk's enum, but the response carries the **device serial number** along with a small header (BLE module version byte + a flag byte). On V3-capable firmware (G1 ≥ 1.5.1, Go2 ≥ 1.1.15) this is what the apk uses to identify the robot before any GCM-encrypted command exchange — see [F1 Layout](#f1-version--device-id) below.
- `0xF2` — historically named `GCM_KEY`, but the response is **not** a plain AES key — it's a 256-byte RSA-encrypted blob (344 base64 chars) that the cloud server-side decrypts to derive the per-device 16-byte AES-128 key used for WebRTC `data2=3` authentication and for GCM-wrapping V1/V2 BLE commands.
Plaintext F1/F2 frames are not AES-CFB encrypted; they are framed with a fixed magic prefix so receivers can tell them apart from V1/V2 ciphertext. GCM-wrapped command frames have no magic prefix — they are demuxed from V1/V2 ciphertext by attempting GCM-decrypt first when an AES key is loaded.
## Firmware Compatibility
| Robot | Firmware | V3 supported |
|---|---|---|
| Unitree G1 | `≥ 1.5.1` | ✅ Yes |
| Unitree G1 | `< 1.5.1` | ❌ No (V1/V2 only) |
| Unitree Go2 | `≥ 1.1.15` | ✅ Yes |
| Unitree Go2 | `< 1.1.15` | ❌ No (V1/V2 only) |
A client targeting both robot families must:
1. Connect normally per [V1/V2 Connection Flow](bluetooth-v1-v2.md#robot-connection-flow).
2. Send a V3 request and treat a timeout as "V3 not supported" rather than as a failure. The robot silently drops V3 frames it does not recognize.
## Table of Contents
- [Magic Prefix](#magic-prefix)
- [Packet Format](#packet-format)
- [Commands](#commands)
- [GCM Command Frames](#gcm-command-frames)
- [Authenticated Handshake](#authenticated-handshake)
- [WiFi Configuration Flow](#wifi-configuration-flow)
- [GCM Key Usage](#gcm-key-usage)
- [Coexistence With V1/V2](#coexistence-with-v1v2)
- [Quick Reference](#quick-reference)
---
## Magic Prefix
Every V3 frame — request or response — starts with the 5-byte magic:
```
0x00 0x55 0x54 0x32 0x35 ("\0UT25")
```
The leading `0x00` is significant: V1/V2 frames begin with `0x52` (request) or `0x51` (response, after AES-CFB decryption). Any frame whose first byte is `0x00` on the notify characteristic is V3 and must **not** be passed through `AES.decrypt()` — doing so would produce garbage.
## Packet Format
V3 packets are sent as plaintext (no encryption). The checksum is the same scheme as V1/V2: `(-sum(bytes_so_far)) & 0xFF`, computed over every byte of the frame except the checksum itself, including the magic prefix.
### Client → Robot (Request)
```
[0x00] [0x55] [0x54] [0x32] [0x35] [command] [checksum]
```
| Field | Size | Description |
|---|---|---|
| Magic | 5 | Fixed `00 55 54 32 35` |
| Command | 1 | Opcode (`0xF1` = VERSION, `0xF2` = GCM_KEY) |
| Checksum | 1 | `(-sum(magic..command)) & 0xFF` |
Total: **7 bytes**, written unencrypted to the V1 (FFE2) or V2 (NUS TX) write characteristic.
### Robot → Client (Response)
The two opcodes use **different** response layouts:
**`0xF1` VERSION** — single, fixed-size frame (not chunked):
```
[0x00] [0x55] [0x54] [0x32] [0x35] [0xF1] [version_byte] [needShowNetSwitch] [cksum]
```
**`0xF2` GCM_KEY** — chunked. Each notification carries one chunk of a larger payload:
```
[0x00] [0x55] [0x54] [0x32] [0x35] [0xF2] [chunk_idx] [total_chunks] [data...] [checksum]
```
| Field | Size | Description |
|---|---|---|
| Magic | 5 | Fixed `00 55 54 32 35` |
| Command | 1 | Echoed opcode |
| Chunk Index | 1 | 1-based index of this chunk |
| Total Chunks | 1 | Total number of chunks |
| Data | 0-N | Chunk payload (ASCII characters of the base64 key) |
| Checksum | 1 | Per-chunk checksum |
> **Checksum note.** Frames are delivered inside fixed-MTU BLE notifications, which often carry padding past the logical frame end. The `(-sum(bytes)) & 0xFF` check therefore fails on real-world traces and the Unitree app skips it — trust the magic prefix and the opcode-specific layout instead.
### Reassembly
Buffer chunks per `command` until `len(received) == total_chunks`, then concatenate in index order. Decode the assembled bytes as UTF-8 and strip trailing `\x00` / whitespace.
```python
buckets: dict[int, dict[int, bytes]] = {}
def on_v3_frame(raw: bytes) -> tuple[int, str] | None:
if len(raw) < len(MAGIC) + 2 or raw[:5] != b"\x00UT25":
return None
cmd = raw[5]
if cmd == 0xF1:
return cmd, str(raw[6]) # F1: not chunked
if len(raw) < len(MAGIC) + 4:
return None
idx, total, data = raw[6], raw[7], raw[8:-1]
bucket = buckets.setdefault(cmd, {})
bucket[idx] = data
if total > 0 and len(bucket) >= total:
full = b"".join(bucket[i] for i in sorted(bucket)).rstrip(b"\x00").strip()
del buckets[cmd]
return cmd, full.decode("utf-8")
return None
```
## Commands
| Command | ID | Request payload | Response payload |
|---|---|---|---|
| VERSION | `0xF1` | (none) | Single frame: `[magic][F1][version_byte][needShowNetSwitch_flag][reserved(4)][sn_len(1)][sn_ascii][cksum]` — **the SN is embedded** length-prefixed at offset 12. Truncated to 7 chars under MTU=23; full 16 chars under MTU=104. Also pushed in response to the V1/V2 SECRET handshake. |
| GCM_KEY | `0xF2` | (none) | 344 ASCII chars of base64 (256 raw bytes, RSA-encrypted), delivered as 4 chunks. Truncated to 11 chars/chunk under MTU=23. |
### `0xF1` VERSION / Device-ID
Despite the name, this command's response carries the **device serial number** along with a small header (BLE module version + a flag byte). It's both a "do you speak V3?" probe *and* the canonical way to identify the connected robot before any AES key is established — the apk's `dogSn` field is set straight from this frame on V3-capable firmware (G1 ≥ 1.5.1, Go2 ≥ 1.1.15).
#### F1 Layout
```
[0..4] magic (5) 00 55 54 32 35
[5] opcode (1) F1
[6] version (1) 03 ← BLE module version (0x03 = V3)
[7] flags (1) bit 0 = needShowNetSwitch
[8..11] reserved (4) 00 00 00 00
[12] sn_len (1) length of the SN that follows (typically 0x10 = 16)
[13..] sn_ascii (N) ASCII SN bytes (e.g. "E21D6000PBF9ELG5")
[last] cksum (1) (-sum(prev bytes)) & 0xFF
```
Full size = `13 + sn_len + 1`. For a 16-char SN that's **30 bytes**, which fits comfortably under MTU=104 but is **truncated to 20 bytes (only 7 chars of SN) under default MTU=23**. Negotiate MTU first if you want the full SN out of this frame.
The frame is pushed unsolicited in response to the V1/V2 SECRET handshake — clients don't need to send `build_v3(0xF1)` explicitly to get it.
### `0xF2` GCM_KEY
> **Important: this is misnamed.** What the firmware returns is **not** an
> AES-128-GCM key — it's a **2048-bit RSA-encrypted blob** (256 bytes,
> base64-encoded as 344 chars including `==` padding) that wraps the actual
> per-device key plus device metadata. The cloud's `device/bindExtData`
> endpoint RSA-decrypts it server-side.
The reply is delivered as **4 chunked F2 frames** (`idx=1..4`, `total=4`).
The data carried per chunk depends on the negotiated BLE MTU:
| MTU | Notify size | Data per chunk | Reassembled length |
|---|---|---|---|
| 23 (default) | 20 B | 11 B | 44 chars (truncated) |
| 104 (apk default) | 101 B | 86 B | 344 chars (full) |
**You must negotiate MTU ≥ 32 to get the full payload** — the apk does
`exchange_mtu(104)` immediately after subscribing to notifications. Under
the default MTU=23 each chunk is truncated and the cloud subsequently
fails to RSA-decrypt the input with `"sk decode error"`.
The on-robot key file is `/unitree/etc/key/aes_key.bin` — but it's the
*encrypted* package, not the raw AES-128 key. The 16-byte key the cloud
returns to the client (and stores as `dev.key`) is what's actually used
for `data2=3` SDP authentication and for GCM-wrapping V1/V2 BLE commands.
## GCM Command Frames
Once an AES-128 key is loaded, every V1/V2 command op the firmware accepts (`GET_SN`, `GET_AP_MAC`, `WIFI_TYPE`, `WIFI_SSID`, `WIFI_PWD`, `COUNTRY`, …) is wrapped in an AES-GCM envelope before being written to the V1 (`FFE2`) characteristic. The robot's replies come back through the same channel using the same envelope.
### Envelope Layout
Both directions share one wire format:
```
[nonce_len(1)] [nonce(12)] [tag_len(1)] [tag(16)]
[cipher_len(1)] [ciphertext(N)] [outer_cksum(1)]
```
- `nonce_len` is always `0x0c` (12) and `tag_len` is always `0x10` (16). They are still on the wire because the firmware parses the lengths rather than hard-coding them.
- `outer_cksum` is `(-sum(body)) & 0xFF`, computed over every byte from `nonce_len` through the last ciphertext byte (i.e. excluding the checksum itself).
- `ciphertext` decrypts under `AES.GCM(key, nonce)` to a V1/V2 *inner* frame whose first byte is the request marker (`0x52` from app, `0x51` from robot).
### Inner-Frame Shapes
There are two inner shapes, mirroring V1's simple/chunked split:
```
simple = [0x52|0x51] [len] [op] [data...] [inner_cksum]
chunked = [0x52|0x51] [len] [op] [idx] [total] [data...] [inner_cksum]
```
`len` is the inner frame length including the trailing checksum. `inner_cksum` is `(-sum(prev bytes)) & 0xFF`.
### Which Ops Use Which Shape
The V3 firmware is strict about this — sending the wrong shape causes the request to be silently dropped:
| Op | Shape | Notes |
|---|---|---|
| `GET_SN` (`0x02`) | simple | empty data |
| `WIFI_TYPE` (`0x03`) | simple | 1-byte data: `0x01`=AP, `0x02`=STA |
| `WIFI_SSID` (`0x04`) | **chunked** | UTF-8 SSID; fits single chunk under MTU=104 |
| `WIFI_PWD` (`0x05`) | **chunked** | UTF-8 password; fits single chunk under MTU=104 |
| `COUNTRY` (`0x06`) | simple | `<country_utf8> ‖ <open_byte>` — see [WiFi flow](#wifi-configuration-flow) |
| `GET_AP_MAC` (`0x07`) | simple | empty data |
| `GET_TIME_3` (`0x0b`) | simple | handshake init from app, empty data |
| `CHECK_3` (`0x0c`) | simple | handshake reply, 8-byte BE timestamp+1 |
Even for ops where chunking isn't strictly necessary (the data is small and fits in one MTU), the firmware still pattern-matches on the inner-frame shape; SSID and password must use the chunked layout with `idx=1, total=1` or the request is dropped.
## Authenticated Handshake
Before the robot will respond to any GCM-wrapped command, the app must complete a tiny handshake that proves it holds the AES-128 key and is roughly time-synchronised:
1. App writes `GET_TIME_3` (op `0x0b`, simple inner, GCM-wrapped). Empty data payload.
2. Robot replies with `op=0x0b` carrying an **8-byte big-endian uint64 timestamp** (Unix seconds).
3. App writes `CHECK_3` (op `0x0c`, simple inner, GCM-wrapped) with the same timestamp **+1** as 8 BE bytes.
4. Robot replies with `op=0x0c, result=0x01` if the value matches; further GCM ops are now accepted.
The endianness is non-obvious — the apk reads it via `ByteBuffer.getLong()` (BE) but a few BLE wire references suggest little-endian; the firmware accepts BE only.
After this exchange the AES key is "armed" and `GET_SN` / `GET_AP_MAC` / WiFi ops will be answered. The handshake is done once per BLE connection.
## WiFi Configuration Flow
WiFi config is a five-step sequence, all GCM-wrapped:
1. `WIFI_TYPE` (simple, 1-byte data: `0x01`=AP, `0x02`=STA).
2. `WIFI_SSID` (chunked).
3. `WIFI_PWD` (chunked).
4. `COUNTRY` (simple) — payload is `<country_utf8> ‖ <open_byte>` where `open_byte=0x02` opens the AP / kicks the STA join, and `0x01` closes the AP. **This frame is not optional in AP mode** — without it the robot stores the credentials but never brings the AP up. It's also what triggers the actual radio change.
5. **Wait for unsolicited push** of `op=0x08, result=0x01` (the apk calls this `BleResultEvent type="9"`). This is the real success signal — COUNTRY's `0x06` ack only means "config accepted, opening AP / connecting now". Robot sends `op=0x08, result=<non-1>` on failure (e.g. `0x04` for an AP that couldn't start). 40-second timeout in the apk.
Each of steps 1–4 acks with `[0x51][len][op][result][inner_cksum]`. `result==0x01` means accepted, anything else is a per-step rejection.
```
APP → WIFI_TYPE (op 0x03) ──► robot ◄── ack op 0x03 result 0x01
APP → WIFI_SSID (op 0x04) ──► robot ◄── ack op 0x04 result 0x01
APP → WIFI_PWD (op 0x05) ──► robot ◄── ack op 0x05 result 0x01
APP → COUNTRY (op 0x06) ──► robot ◄── ack op 0x06 result 0x01 (config accepted)
◄── push op 0x08 result 0x01 (AP up / STA joined)
```
## GCM Key Usage
The key returned by `0xF2` is the secret used for `data2=3` WebRTC SDP authentication. The Unitree app derives a session nonce from the SDP offer/answer, encrypts it with this key under AES-128-GCM, and includes the ciphertext + tag in the signaling payload. The robot decrypts and validates the nonce before establishing the WebRTC peer connection.
Without the GCM key:
- WebRTC handshakes against V3-capable firmware (G1 ≥ 1.5.1, Go2 ≥ 1.1.15) will fail at the `data2=3` step.
- Older firmware (G1 `< 1.5.1`, Go2 `< 1.1.15`) does not require this auth — `con_notify` returns `data2=2` (static GCM key) and the SDP exchange is accepted unauthenticated.
The key is per-device and not user-secret in the strong sense (the robot freely hands it out over BLE to any client that completes the V1/V2 handshake), but it should be cached locally rather than re-fetched on every WebRTC session.
## Coexistence With V1/V2
The V1/V2 `HANDSHAKE` / `0x01` SECRET exchange is still required at connect time to bring the link into a usable state — V3 firmware answers it with an unsolicited F1 frame instead of the legacy AES-CFB reply, but the app must still send it.
After connect, the picture differs by firmware:
- **V3-capable firmware (G1 ≥ 1.5.1, Go2 ≥ 1.1.15).** Plaintext F1/F2 frames flow alongside GCM-wrapped command frames. The legacy V1/V2 AES-CFB path is **not** used for `GET_SN` / `GET_AP_MAC` / WiFi ops on this firmware — those have all moved into the GCM envelope. Sending a V1/V2 AES-CFB request here is silently dropped.
- **Legacy firmware (G1 < 1.5.1, Go2 < 1.1.15).** No V3 at all. `GET_SN` / `GET_AP_MAC` / WiFi flow stay on the V1/V2 AES-CFB path documented in [bluetooth-v1-v2.md](bluetooth-v1-v2.md).
A correctly-implemented notify handler therefore demuxes three classes of inbound frame:
```python
def on_notify(raw: bytes) -> None:
# 1) V3 plaintext (F1/F2): magic-prefixed, no encryption.
if len(raw) >= 5 and raw[:5] == b"\x00UT25":
handle_v3_plaintext(raw)
return
# 2) V3 GCM-wrapped command response — try this first when an AES key
# is loaded. Returns the inner V1/V2 frame on success.
if aes_key is not None:
plain = gcm_decrypt(raw, aes_key)
if plain is not None:
handle_v1_v2_inner(plain)
return
# 3) Legacy V1/V2 AES-CFB ciphertext.
plain = aes_cfb_decrypt(raw)
if len(plain) >= 4 and plain[0] == 0x51:
handle_v1_v2_inner(plain)
```
Routing V3 plaintext frames to the AES-CFB decryptor is a frequent porting bug — the decrypted output looks valid (random bytes), no exception is raised, and the framework silently drops the result because the first byte isn't `0x51`.
## Quick Reference
### Plaintext probe frames
| Constant | Value |
|---|---|
| Magic prefix | `00 55 54 32 35` (`"\0UT25"`) |
| Request length | 7 bytes |
| VERSION opcode | `0xF1` |
| GCM_KEY opcode | `0xF2` |
| Checksum | `(-sum(bytes)) & 0xFF`, includes magic |
| Encryption | None (plaintext) |
### GCM-wrapped command ops (inner `0x52`/`0x51` frame)
| Op | Name | Inner shape | Notes |
|---|---|---|---|
| `0x02` | GET_SN | simple | empty data |
| `0x03` | WIFI_TYPE | simple | `0x01`=AP, `0x02`=STA |
| `0x04` | WIFI_SSID | chunked | UTF-8 |
| `0x05` | WIFI_PWD | chunked | UTF-8 |
| `0x06` | COUNTRY | simple | `<country_utf8> ‖ <0x02 open / 0x01 close>` |
| `0x07` | GET_AP_MAC | simple | empty data |
| `0x08` | (push) | simple | unsolicited "AP up / STA joined" success — `result=0x01` |
| `0x0b` | GET_TIME_3 | simple | handshake init / robot timestamp reply (8 BE bytes) |
| `0x0c` | CHECK_3 | simple | handshake reply with timestamp+1 (8 BE bytes) |
### Misc
| Constant | Value |
|---|---|
| Min firmware | G1 1.5.1 / Go2 1.1.15 |
| Min MTU | 32 (negotiate 104 to match the apk) |
| AES key length | 16 bytes (32 hex chars) |
| Per-device AES key file (on-robot) | `/unitree/etc/key/aes_key.bin` (RSA-wrapped, not raw) |
| Cloud key source | `device/bind/list` → `dev.key` (32 hex chars) |
For scanning, V1/V2 commands, and WiFi configuration, see [bluetooth-v1-v2.md](bluetooth-v1-v2.md). For the BLE remote control and the WebRTC relay that forwards its inputs to the robot, see [remote-control.md](remote-control.md).