325 lines
11 KiB
Markdown
325 lines
11 KiB
Markdown
# 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](bluetooth-v3.md) — V3 extension (`VERSION` 0xF1, `GCM_KEY` 0xF2) introduced on G1 firmware 1.5.1 and back-ported to Go2 1.1.15.
|
||
- [remote-control.md](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](bluetooth-v3.md) |
|
||
| Unitree G1 | `< 1.5.1` | V1 / V2 |
|
||
| Unitree G1 | `≥ 1.5.1` | V1 / V2 + [V3 extension](bluetooth-v3.md) |
|
||
|
||
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](#service-uuids).
|
||
|
||
## Table of Contents
|
||
|
||
- [System Requirements](#system-requirements)
|
||
- [Scanning](#scanning)
|
||
- [Service UUIDs](#service-uuids)
|
||
- [Encryption](#encryption)
|
||
- [Packet Format](#packet-format)
|
||
- [Robot Connection Flow](#robot-connection-flow)
|
||
- [Commands](#commands)
|
||
- [WiFi Configuration](#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:
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
sudo usermod -aG bluetooth $USER
|
||
# Log out and back in for group changes to take effect
|
||
```
|
||
|
||
### Required Services
|
||
|
||
BlueZ daemon must be running:
|
||
|
||
```bash
|
||
sudo systemctl enable --now bluetooth
|
||
systemctl is-active bluetooth # should print "active"
|
||
```
|
||
|
||
### Bluetooth Adapter
|
||
|
||
Check your HCI adapter(s):
|
||
|
||
```bash
|
||
hciconfig -a
|
||
```
|
||
|
||
If the adapter shows `DOWN` (common for USB dongles after plug-in), bring it up:
|
||
|
||
```bash
|
||
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_RAW` on binaries** — `gatttool` and `hcitool` delegate privileged ops to `bluetoothd` which 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 | tail` for driver errors |
|
||
|
||
---
|
||
|
||
## 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](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` |
|
||
|
||
```python
|
||
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](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 as `XX: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](bluetooth-v3.md). For the BLE remote control and the WebRTC relay that forwards its inputs to the robot, see [remote-control.md](remote-control.md).
|