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

18 KiB
Raw Blame History

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:

  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 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.
  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

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.

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
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.

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.