18 KiB
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) 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:
- Plaintext probe frames —
0xF1(device-ID) and0xF2(RSA-wrapped key blob). Magic-prefixed, unencrypted, used before any AES key is established. - 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 namedVERSIONin 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 below.0xF2— historically namedGCM_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 WebRTCdata2=3authentication 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:
- Connect normally per V1/V2 Connection Flow.
- 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
- Packet Format
- Commands
- GCM Command Frames
- Authenticated Handshake
- WiFi Configuration Flow
- GCM Key Usage
- Coexistence With V1/V2
- 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)) & 0xFFcheck 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.
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'sdevice/bindExtDataendpoint 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_lenis always0x0c(12) andtag_lenis always0x10(16). They are still on the wire because the firmware parses the lengths rather than hard-coding them.outer_cksumis(-sum(body)) & 0xFF, computed over every byte fromnonce_lenthrough the last ciphertext byte (i.e. excluding the checksum itself).ciphertextdecrypts underAES.GCM(key, nonce)to a V1/V2 inner frame whose first byte is the request marker (0x52from app,0x51from 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 |
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:
- App writes
GET_TIME_3(op0x0b, simple inner, GCM-wrapped). Empty data payload. - Robot replies with
op=0x0bcarrying an 8-byte big-endian uint64 timestamp (Unix seconds). - App writes
CHECK_3(op0x0c, simple inner, GCM-wrapped) with the same timestamp +1 as 8 BE bytes. - Robot replies with
op=0x0c, result=0x01if 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:
WIFI_TYPE(simple, 1-byte data:0x01=AP,0x02=STA).WIFI_SSID(chunked).WIFI_PWD(chunked).COUNTRY(simple) — payload is<country_utf8> ‖ <open_byte>whereopen_byte=0x02opens the AP / kicks the STA join, and0x01closes 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.- Wait for unsolicited push of
op=0x08, result=0x01(the apk calls thisBleResultEvent type="9"). This is the real success signal — COUNTRY's0x06ack only means "config accepted, opening AP / connecting now". Robot sendsop=0x08, result=<non-1>on failure (e.g.0x04for 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=3step. - Older firmware (G1
< 1.5.1, Go2< 1.1.15) does not require this auth —con_notifyreturnsdata2=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.
A correctly-implemented notify handler therefore demuxes three classes of inbound frame:
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. For the BLE remote control and the WebRTC relay that forwards its inputs to the robot, see remote-control.md.