11 KiB
Bluetooth Protocol V1 / V2 — Unitree Robots
This document describes the legacy BLE provisioning protocol used to scan, connect, and configure Unitree robots (WiFi setup, serial number, AP MAC).
Related documents:
- bluetooth-v3.md — V3 extension (
VERSION0xF1,GCM_KEY0xF2) introduced on G1 firmware 1.5.1 and back-ported to Go2 1.1.15. - remote-control.md — BLE remote (
Unitree-*) protocol and the WebRTC relay that forwards remote inputs to the robot.
Firmware Compatibility
| Robot | Firmware | Service |
|---|---|---|
| Unitree Go2 | < 1.1.11 |
V1 / V2 (FFE0 service) |
| Unitree Go2 | 1.1.11 – 1.1.14 |
V1 / V2 (NUS service) |
| Unitree Go2 | ≥ 1.1.15 |
V1 / V2 (NUS) + V3 extension |
| Unitree G1 | < 1.5.1 |
V1 / V2 |
| Unitree G1 | ≥ 1.5.1 |
V1 / V2 + V3 extension |
V1 and V2 share the same wire format, encryption, and command set. The only difference is the GATT service UUID used to expose the characteristics — see Service UUIDs.
Table of Contents
- System Requirements
- Scanning
- Service UUIDs
- Encryption
- Packet Format
- Robot Connection Flow
- Commands
- WiFi Configuration
System Requirements
Linux Packages
| Package | Purpose | Install |
|---|---|---|
bluez |
BlueZ daemon, bluetoothctl, gatttool, hciconfig |
sudo apt install bluez |
The bluez package provides both the BlueZ D-Bus daemon (bluetoothd) used by bleak for the robot, and gatttool used by pygatt for the remote control.
Python Packages
All listed in server/requirements.txt:
fastapi>=0.110.0 # REST API
uvicorn>=0.27.0 # ASGI server
bleak>=0.22.0 # BlueZ D-Bus BLE client (used for robot)
pycryptodome>=3.20.0 # AES-128-CFB encryption
pygatt>=4.0.0 # gatttool wrapper (used for remote control)
Install with:
pip install -r server/requirements.txt
User Permissions
On Ubuntu 22.04 (and some other distros), the default D-Bus policy at /etc/dbus-1/system.d/bluetooth.conf already allows any local user to communicate with org.bluez, so no group membership is needed.
On Debian, Fedora, Arch, Ubuntu with hardened D-Bus policies, add your user to the bluetooth group:
sudo usermod -aG bluetooth $USER
# Log out and back in for group changes to take effect
Required Services
BlueZ daemon must be running:
sudo systemctl enable --now bluetooth
systemctl is-active bluetooth # should print "active"
Bluetooth Adapter
Check your HCI adapter(s):
hciconfig -a
If the adapter shows DOWN (common for USB dongles after plug-in), bring it up:
sudo hciconfig hci0 up # or hci1, etc.
The BLE server exposes /adapters and /adapter endpoints to switch between adapters at runtime.
What's NOT Required
- Running as root — not needed
setcap CAP_NET_RAWon binaries —gatttoolandhcitooldelegate privileged ops tobluetoothdwhich already runs as root- Disabling AppArmor/SELinux — default policies allow everything needed
Troubleshooting
| Error | Fix |
|---|---|
Operation not permitted during scan |
Ensure bluetoothd is running (systemctl status bluetooth) |
org.bluez.Error.NotReady |
Adapter is DOWN — sudo hciconfig hciX up |
org.bluez.Error.NotAvailable: br-connection-profile-unavailable (remote) |
Normal for dual-mode remote on BlueZ 5.64; pygatt handles this automatically |
Could not connect to the system bus |
User not in bluetooth group on a distro that requires it |
| Adapter not found | USB dongle not recognized — check `dmesg |
Scanning
Robots and remotes are discovered via BLE advertisement.
Device Name Prefixes
| Prefix | Device |
|---|---|
Go2_ |
Unitree Go2 robot |
G1_ |
Unitree G1 robot |
Unitree- |
BLE Remote Control (e.g. Unitree-32KC0D) — see remote-control.md |
Protocol Detection
The protocol variant is determined by which service UUID appears in the BLE advertisement:
| Service UUID | Variant | Firmware |
|---|---|---|
0000ffe0-0000-1000-8000-00805f9b34fb |
V1 (FFE0) | Go2 < 1.1.11, all G1 |
6e400001-b5a3-f393-e0a9-e50e24dcca9e |
V2 (NUS) | Go2 ≥ 1.1.11 |
Service UUIDs
V1 (FFE0)
| UUID | Role |
|---|---|
0000ffe0-0000-1000-8000-00805f9b34fb |
Service |
0000ffe1-0000-1000-8000-00805f9b34fb |
Notify (robot -> client) |
0000ffe2-0000-1000-8000-00805f9b34fb |
Write (client -> robot) |
V2 (Nordic UART Service)
| UUID | Role |
|---|---|
6e400001-b5a3-f393-e0a9-e50e24dcca9e |
Service |
6e400002-b5a3-f393-e0a9-e50e24dcca9e |
Write / TX (client -> robot) |
6e400003-b5a3-f393-e0a9-e50e24dcca9e |
Notify / RX (robot -> client) |
Both variants use the same encryption, packet format, and command set — only the UUIDs differ.
Encryption
All robot BLE packets are encrypted with AES-128-CFB before transmission and decrypted on receive.
| Parameter | Value |
|---|---|
| Algorithm | AES-128-CFB |
| Segment Size | 128 bits |
| Key | df98b715d5c6ed2b25817b6f2554124a |
| IV | 2841ae97419c2973296a0d4bdfe19a4f |
from Crypto.Cipher import AES
KEY = bytes.fromhex("df98b715d5c6ed2b25817b6f2554124a")
IV = bytes.fromhex("2841ae97419c2973296a0d4bdfe19a4f")
def encrypt(data: bytes) -> bytes:
return AES.new(KEY, AES.MODE_CFB, iv=IV, segment_size=128).encrypt(data)
def decrypt(data: bytes) -> bytes:
return AES.new(KEY, AES.MODE_CFB, iv=IV, segment_size=128).decrypt(data)
Note: The V3 extension (G1 ≥ 1.5.1, Go2 ≥ 1.1.15) sends a small set of additional commands unencrypted with a different magic prefix. See bluetooth-v3.md. On a V1/V2 connection, frames whose first byte is
0x00(rather than the encrypted-frame distribution) belong to V3 and must be routed to the V3 handler before AES decryption is attempted.
Packet Format
Client -> Robot (Request)
After building the plaintext packet, it is AES-encrypted before writing to the GATT characteristic.
Simple Packet
[0x52] [length] [instruction] [data...] [checksum]
| Field | Size | Description |
|---|---|---|
| Header | 1 | Always 0x52 |
| Length | 1 | len(data) + 4 (counts header, length, instruction, checksum) |
| Instruction | 1 | Command ID |
| Data | 0-N | Command payload |
| Checksum | 1 | (-sum(header..data)) & 0xFF |
Chunked Packet
Used when data exceeds the 14-byte chunk limit (SSID, password, handshake).
[0x52] [length] [instruction] [chunk_idx] [total_chunks] [data...] [checksum]
| Field | Size | Description |
|---|---|---|
| Header | 1 | Always 0x52 |
| Length | 1 | len(data) + 6 (counts header through data + checksum) |
| Instruction | 1 | Command ID |
| Chunk Index | 1 | 1-based index of this chunk |
| Total Chunks | 1 | Total number of chunks |
| Data | 1-14 | Chunk payload (max CHUNK_SIZE = 14 bytes) |
| Checksum | 1 | (-sum(header..data)) & 0xFF |
Robot -> Client (Response)
Received as encrypted bytes on the notify characteristic, then AES-decrypted.
[0x51] [length] [instruction] [status] [data...] [checksum]
| Field | Size | Description |
|---|---|---|
| Header | 1 | Always 0x51 |
| Length | 1 | Payload length |
| Instruction | 1 | Echoed command ID |
| Status | 1 | 0x01 = success |
| Data | 0-N | Response payload |
| Checksum | 1 | (-sum(header..data)) & 0xFF |
Chunked responses (e.g. GET_SN) include chunk_idx and total_chunks after the instruction byte, same as the request format.
Robot Connection Flow
1. BLE Scan -> Find device with name prefix Go2_ or G1_
2. GATT Connect -> Connect to the device
3. Detect Protocol -> Check services for NUS (V2) or FFE0 (V1)
4. Subscribe -> Enable notifications on the notify characteristic
5. Handshake -> Send chunked packet: instruction=0x01, data="unitree"
6. Verify -> Response status byte == 0x01 means success
Handshake Detail
The handshake sends the string "unitree" (7 bytes) as a chunked packet with idx=1, total=1:
Plaintext: [0x52] [0x0D] [0x01] [0x01] [0x01] [u] [n] [i] [t] [r] [e] [e] [checksum]
header len=13 CMD idx total -------- "unitree" --------
This is then AES-encrypted and written to the write characteristic.
Commands
| Command | ID | Format | Description |
|---|---|---|---|
| HANDSHAKE | 0x01 |
Chunked | Auth with "unitree" |
| GET_SN | 0x02 |
Simple (no data) | Request serial number (response is chunked) |
| WIFI_TYPE | 0x03 |
Simple | Set WiFi mode: 0x01=AP, 0x02=STA |
| WIFI_SSID | 0x04 |
Chunked | Send SSID (up to 14 bytes per chunk) |
| WIFI_PWD | 0x05 |
Chunked | Send password (up to 14 bytes per chunk) |
| COUNTRY | 0x06 |
Simple | Set country code: [0x01] + "US\x00" |
| GET_AP_MAC | 0x07 |
Simple (no data) | Request AP MAC address |
| DISCONNECT | 0x08 |
Simple (no data) | Disconnect BLE |
| HEARTBEAT | 0x0A |
Simple (no data) | Keep-alive |
Response Parsing
- GET_SN: Comes as multiple chunked response packets. Reassemble chunks by index, decode as UTF-8.
- GET_AP_MAC: MAC bytes at
response[3:length-1], format asXX:XX:XX:XX:XX:XX. - WIFI_TYPE/SSID/PWD/COUNTRY: Success if
response[3] == 0x01.
WiFi Configuration
Full WiFi setup sequence after handshake:
1. Set mode -> WIFI_TYPE (0x03) with 0x01 (AP) or 0x02 (STA)
2. Send SSID -> WIFI_SSID (0x04) chunked, 14 bytes per chunk, 50ms between
3. Send password -> WIFI_PWD (0x05) chunked, 14 bytes per chunk, 100ms between
4. Set country -> COUNTRY (0x06) with [0x01] + country_code + \x00
Each step waits for a success response before proceeding. The password step has a longer timeout (15s) as the robot applies the WiFi configuration.
Security note (CVE-2025-35027): On Go2 firmware up to and including 1.1.11, the robot passes SSID and password to shell scripts via
system()without sanitization, allowing command injection.
Quick Reference
| Constant | Value |
|---|---|
| AES Key | df98b715d5c6ed2b25817b6f2554124a |
| AES IV | 2841ae97419c2973296a0d4bdfe19a4f |
| Request header | 0x52 |
| Response header | 0x51 |
| Chunk size | 14 bytes |
| Handshake string | "unitree" |
| WiFi AP mode byte | 0x01 |
| WiFi STA mode byte | 0x02 |
| Success status | 0x01 |
For the V3 extension (VERSION 0xF1, GCM_KEY 0xF2 — G1 firmware ≥ 1.5.1 and Go2 firmware ≥ 1.1.15), see bluetooth-v3.md. For the BLE remote control and the WebRTC relay that forwards its inputs to the robot, see remote-control.md.