1. Introduction
In Part 3, we walked through the init sequence and master_service, the service orchestrator, to see in what order the Unitree Go2’s internal services start and how they are supervised. We then combined that service map with the listening ports on a real unit and Unitree’s published hardware diagram, and picked four components that take input from outside the robot: BLE, RTSP, WebRTC/DDS, and OTA. Of those, we covered the structure and DFD of the RTSP video streaming service first.
Part 4 analyzes the remaining three components the same way, in this order: BLE, the WebRTC/DDS bridge, and OTA. BLE is where the app and the robot first connect. The bridge carries the app’s real-time control messages onto the robot’s internal DDS bus, and OTA takes commands from the cloud, downloads packages, and installs them. The order follows the robot’s life from the moment a user first pairs it, through everyday use, to updates.
As with RTSP in Part 3, each component is covered as basic building blocks → main operational flow → data flow (DFD). Chapter 5 then puts all four components, RTSP included, into one integrated DFD that runs from the external interfaces all the way to the robot’s internal services.
2. BLE Component Analysis
2.1. Basic Building Blocks
The first link between the robot and the app is made over BLE. Before a user can control the robot over the network, the smartphone app has to hand the robot a connection mode and WiFi credentials over short-range radio. This initial setup step, where network settings are pushed to a device that has no network yet, is called onboarding (or provisioning). Go2 uses BLE for it.
Once onboarding is done and the robot joins WiFi, control, video, and status streams all move to WebRTC over WiFi, and BLE stays around only as a secondary setup channel. Communication between the app and the robot therefore splits into two phases: BLE onboarding, and real-time control over WiFi + WebRTC. We start with what the robot-side server does during BLE onboarding.
2.1.1. BLE / GATT Terminology
•
BLE (Bluetooth Low Energy)
◦
Low-power short-range wireless standard introduced in Bluetooth 4.0
•
Low-power short-range wireless standard introduced in Bluetooth 4.0
•
GATT (Generic Attribute Profile)
◦
The standard structure BLE devices use to exchange data
◦
A Service holds one or more Characteristics; a characteristic holds the actual value plus Descriptors, which carry metadata about that value
◦
Services, characteristics, and descriptors are identified by UUIDs (16-bit short form or 128-bit), and at runtime each one is addressed by a 16-bit Handle
◦
Characteristics have properties such as Read / Write / Notify / Indicate; Notify is used when the server pushes a value to the client on its own
•
The standard structure BLE devices use to exchange data
•
A Service holds one or more Characteristics; a characteristic holds the actual value plus Descriptors, which carry metadata about that value
•
Services, characteristics, and descriptors are identified by UUIDs (16-bit short form or 128-bit), and at runtime each one is addressed by a 16-bit Handle
•
Characteristics have properties such as Read / Write / Notify / Indicate; Notify is used when the server pushes a value to the client on its own
•
ATT (Attribute Protocol)
◦
The transport underneath GATT; the first byte of a PDU is an opcode that identifies the operation
◦
Writes come in two forms: Write Request (0x12), which expects a response, and Write Command (0x52), which does not
•
The transport underneath GATT; the first byte of a PDU is an opcode that identifies the operation
•
Writes come in two forms: Write Request (0x12), which expects a response, and Write Command (0x52), which does not
•
Advertising and Scanning
◦
Broadcast packets a device sends periodically to announce itself before any connection exists
◦
Common fields include Local Name (device name) and Manufacturer Specific Data (vendor-defined data, AD type 0xFF)
•
CCCD (Client Characteristic Configuration Descriptor, 0x2902)
◦
Descriptor the client writes to turn Notify/Indicate on or off for a given characteristic
2.1.2. The btgatt-server Binary and How It Starts
The daemon that handles BLE onboarding on the robot is /unitree/module/network_manager/upper_bluetooth/btgatt-server. It is a customized build of btgatt-server, the example program that ships with BlueZ 5.55, and the build path string go2_bluez-5.55-advance left in the binary shows it is a separate custom build.[1] The binary is an aarch64 PIE and is not stripped, so function and global variable names are all still there.
As the path suggests, btgatt-server belongs to the network_manager module. network_manager is updated over OTA, so the module version and implementation details vary with the firmware version.
btgatt-server is launched by master_service, which we analyzed in Part 3. After boot, master_service reads Start.Cmd from /unitree/etc/master_service/service/upper_bluetooth and starts it as a persistent background daemon through start-stop-daemon.
start-stop-daemon --start --background --make-pidfile
--pidfile=/unitree/var/run/btgatt-server.pid
--exec .../btgatt-server -i hci0 -r -v
Plain Text
복사
•
i hci0 selects the HCI adapter, r enables the custom 0xFFE0 service, and v turns on verbose logging. Right after entering main(), the process runs wifi_init.sh and upper_bluetooth_init.sh to bring the adapter up and enable LE advertising and connections. It then builds the GATT DB and waits for connections on the ATT channel.
[ 1.567640] usb 2-1.6: New USB device found, idVendor=0bda, idProduct=a85b, bcdDevice= 0.00
[ 1.567648] usb 2-1.6: Manufacturer: Realtek
...
[ 2.530297] rtk_btusb: Realtek Bluetooth USB driver ver 3.1.6d45ddf.20220519-142432
[ 2.560169] rtk_btusb: read_ver_rsp->lmp_subver = 0x8852
[ 2.560175] rtk_btusb: config filename rtl8852bu_config
[ 2.570385] rtk_btusb: fw name is rtl8852bu_fw
...
[ 2.803314] RTW: rtl8852bu v1.19.3-45-g7583a804a.20230505
[ 3.438853] RTW: rtw_ndev_init(wlan0) if1 mac_addr=xx:xx:xx:xx:xx:xx
[ 3.439099] RTW: rtw_ndev_init(wlan1) if2 mac_addr=xx:xx:xx:xx:xx:xx
[ 3.439384] RTW: [HW-CAP] ic_name:rtl8852b
Plain Text
복사
upper_bluetooth_init.sh cycles the adapter with hciconfig hci0 down/up, sets the advertising interval, and then runs hciconfig hci0 pscan followed by btmgmt le on, connectable on, and advertising on. hci0 is the Bluetooth side of a Realtek RTL8852BU combo chip. In the kernel log you can see the rtk_btusb driver and the rtl8852bu_fw firmware load for a USB-attached Realtek device (0bda:a85b). The WiFi driver for the same chip (rtl8852bu) creates both the wlan0 (STA) and wlan1 (AP) interfaces.
So one USB device, 0bda:a85b, gets both a Bluetooth driver (rtk_btusb) and a WiFi driver (RTW, rtl8852bu). The Bluetooth side identifies the chip by lmp_subver = 0x8852 and loads rtl8852bu_fw; the WiFi side creates two interfaces, wlan0 and wlan1. In practice, AP mode uses wlan1 (192.168.12.1) and STA mode uses wlan0.
flowchart TB
subgraph R1["① launch"]
direction LR
A["Power ON<br/>master_service"] -->|"Start.Cmd<br/>(service/upper_bluetooth)"| B["start-stop-daemon<br/>btgatt-server -i hci0 -r -v"]
B --> C["wifi_init.sh /<br/>upper_bluetooth_init.sh"]
end
subgraph R2["② GATT server setup"]
direction LR
D["set_ble_adv_and_response()<br/>Local Name + manufacturer data"] --> E["Build GATT DB<br/>GAP / GATT / 0xFFE0"]
E --> F["L2CAP ATT socket<br/>accept loop"]
F --> G["0xFFE2 write<br/>write_cb → receive_manager"]
end
R1 --> R2Mermaid
복사
2.1.3. GATT DB Layout
The entire GATT DB is built in main(). Across the whole binary, gatt_db_service_add_characteristic is called from exactly six places, all of them inside main. The three service registrations (gatt_db_add_service) and three descriptor registrations (gatt_db_service_add_descriptor) are in main as well. Because the symbols survived, even the names of the callbacks attached to each characteristic are right there.
This is the part of main in the btgatt_server binary that actually builds the GATT DB:
// main(): GATT DB setup (pseudo code)
// 1. GAP service
bt_uuid16_create(&uuid, 0x1800);
svc = gatt_db_add_service(db, &uuid, primary, 6);
bt_uuid16_create(&uuid, 0x2A00); // Device Name
gatt_db_service_add_characteristic(svc, &uuid, ..., 0x82 /* READ | EXT_PROP */,
gap_device_name_read_cb, gap_device_name_write_cb, ...);
bt_uuid16_create(&uuid, 0x2900); // Characteristic Extended Properties
gatt_db_service_add_descriptor(svc, &uuid, ..., gap_device_name_ext_prop_read_cb, NULL, ...);
bt_uuid16_create(&uuid, 0x2A01); // Appearance
gatt_db_service_add_characteristic(svc, &uuid, ..., 0x02 /* READ */, NULL, NULL, ...);
gatt_db_service_set_active(svc, true);
// 2. GATT service
bt_uuid16_create(&uuid, 0x1801);
svc = gatt_db_add_service(db, &uuid, primary, 4);
bt_uuid16_create(&uuid, 0x2A05); // Service Changed
gatt_db_service_add_characteristic(svc, &uuid, ..., 0x22 /* READ | INDICATE */,
gatt_service_changed_cb, NULL, ...);
bt_uuid16_create(&uuid, 0x2902); // CCCD
gatt_db_service_add_descriptor(svc, &uuid, ..., gatt_svc_chngd_ccc_read_cb,
gatt_svc_chngd_ccc_write_cb, ...);
gatt_db_service_set_active(svc, true);
// 3. Custom service
bt_uuid16_create(&uuid, 0xFFE0);
svc = gatt_db_add_service(db, &uuid, primary, 8);
bt_uuid16_create(&uuid, 0xFFE1); // response channel
gatt_db_service_add_characteristic(svc, &uuid, ..., 0x10 /* NOTIFY */, NULL, NULL, NULL);
bt_uuid16_create(&uuid, 0x2902); // CCCD (0xFFE1 subscription)
gatt_db_service_add_descriptor(svc, &uuid, ..., hr_msrmt_ccc_read_cb, hr_msrmt_ccc_write_cb, ...);
bt_uuid16_create(&uuid, 0x2A38); // Body Sensor Location
gatt_db_service_add_characteristic(svc, &uuid, ..., 0x02 /* READ */, NULL, NULL, ...);
bt_uuid16_create(&uuid, 0xFFE2); // command channel
gatt_db_service_add_characteristic(svc, &uuid, ..., 0x08 /* WRITE */,
NULL, hr_control_point_write_cb, ...);
...
gatt_db_service_set_active(svc, true);
C
복사
For every service, characteristic, and descriptor, the code builds a 16-bit UUID with bt_uuid16_create and then registers it with the matching add function.
The result is two standard 16-bit services and one custom service.
Service | Characteristic | Property / Handle | Callback | Purpose |
0x1800 GAP | 0x2A00 Device Name | R/W | gap_device_name_read_cb / gap_device_name_write_cb | Device name |
0x2A01 Appearance | R | - | Appearance | |
0x1801 GATT | 0x2A05 Service Changed | Indicate | gatt_service_changed_cb | Service change notification |
0xFFE0 (Custom) | 0xFFE1 | Notify, value handle 0x000D | - | Robot → app response channel |
0xFFE2 | Write, value handle 0x0012 | hr_control_point_write_cb | App → robot command channel | |
0x2A38 Body Sensor Location | R | - | Not used for onboarding |
[Table 1] btgatt-server GATT DB layout
The onboarding protocol itself uses only two of these: commands come in on 0xFFE2 (Write) and responses go out on 0xFFE1 (Notify). 0x2A38 plays no part in onboarding.
The same UUIDs appear on the app side. The app’s BLE constants class BleConstant defines all three, and actual transmission appears to go through BleSendManager.write(UUID_SERVER, UUID_WRITE, …).
com.unitree.lib_ble.core.BleConstant
UUID_SERVER = 0000FFE0-0000-1000-8000-00805F9B34FB
UUID_WRITE = 0000FFE2-0000-1000-8000-00805F9B34FB
UUID_NOTI = 0000FFE1-0000-1000-8000-00805F9B34FB
Plain Text
복사
2.2. Onboarding Flow
Onboarding runs from advertising to WiFi setup in this order:
1.
The robot announces itself through advertising.
2.
The app finds the advertisement and opens a GATT connection.
3.
A handshake command authenticates the session.
4.
The app sends the WiFi mode, SSID, and password in turn, changing the robot’s network configuration.
2.2.1. Advertising
The advertising data is assembled directly by set_ble_adv_and_response() using an HCI command (LE Set Advertising Data). The Local Name is the "Go2_" prefix followed by the code value from the file /unitree/robot/basic/bluetooth, and the Manufacturer Specific Data carries device identification info.
The advertised name is built by update_test_device_name. test_device_name[20] in the .data section starts out holding "Go2_", and the code value read from /unitree/robot/basic/bluetooth is appended to it.
.data:0000000000060010 EXPORT test_device_name
.data:0000000000060010 ; char test_device_name[20]
.data:0000000000060010 test_device_name DCB 0x47, 0x6F, 0x32, 0x5F, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
.data:0000000000060010 ; DATA XREF: main+814↑o
.data:0000000000060010 ; main+81C↑o ...
C
복사
0x47 0x6F 0x32 0x5F is "Go2_" in ASCII, and the remaining 16 bytes are zero.
// update_test_device_name
v0 = fopen(go2_ble_name_file, "r"); // /unitree/robot/basic/bluetooth
if ( v0 )
{
for ( i = v0; !feof(i); __isoc99_fscanf(i, "%s", ble_name_data) )
;
fclose(i);
v2 = strlen(test_device_name); // "Go2_" → 4
v3 = __stpcpy_chk(&test_device_name[v2], ble_name_data, 20);
__printf_chk(1, "strlen(test_device_name)=%d\n", v3 - (unsigned int)test_device_name);
__printf_chk(1, " test_device_name =%s\n", test_device_name);
}
C
복사
update_test_device_name appends the code value to the end of the string in this buffer.
// DogBluetoothActivity — scan result filter
private final Set<String> validPrefixes = SetsKt.setOf((Object[]) new String[]{"Go2_", "Uni_", "As2_", "XG_"});
public void onLeScan(BleDevice bleDevice) {
String deviceName = bleDevice.getName();
for (String prefix : validPrefixes)
if (StringsKt.startsWith$default(deviceName, prefix, ...)) { z2 = true; break; }
... // on match, add to deviceList → shown in the list
}
Java
복사
The app’s scan screen (DogBluetoothActivity) only lists devices whose advertised name starts with "Go2_", "Uni_", "As2_", or "XG_". There are several prefixes because the same app serves not just Go2 but also G1, B2, A2, and other models.
Decompiling the app shows that this filter is just a prefix set and a string comparison.
[Figure 1] BLE scan results in the app
2.2.2. Connection, Handshake, and WiFi Setup Sequence
When the user picks the robot from the list, the app opens a GATT connection and subscribes to notifications on 0xFFE1, the response channel. Right after connecting, the app’s BleConnectionManager sends a handshake command to authenticate the session. Once that succeeds, it sends the settings in the order 0x03 (WiFi mode) → 0x04 (SSID) → 0x05 (password) → 0x06 (country code + apply).
Every frame is wrapped in AES and written to 0xFFE2, and the robot returns the result of each step as a 0xFFE1 notification.
sequenceDiagram
participant App as App (lib_ble)
participant Robot as btgatt-server
participant Net as WiFi setup scripts
Note over Robot: advertising on at boot
Robot-->>App: Advertising (Local Name Go2_code + manufacturer data)
App->>Robot: GATT connect
App->>Robot: CCCD(0x2902) subscribe → ready for FFE1 notify
Note over App,Robot: Handshake (session auth)
App->>Robot: 0xFFE2 write: AES( 52 | len | cmd | payload | cksum )
Robot->>Robot: decrypt → verify → set session flag
Robot-->>App: 0xFFE1 notify: AES( 51 | cmd | result )
Note over App,Robot: WiFi provisioning
App->>Robot: 0x03 WIFI_TYPE (1=AP / 2=STA)
Robot-->>App: ack
App->>Robot: 0x04 WIFI_ACCOUNT (SSID, 14B chunks)
Robot-->>App: ssid receive ok
App->>Robot: 0x05 WIFI_PWD (password, 14B chunks)
Robot-->>App: pwd receive ok
App->>Robot: 0x06 COUNTRY + remote switch (apply)
Robot->>Net: AP → hostapd_restart.sh / STA → wpa_supplicant_restart.sh
Net-->>Robot: WiFi restarted
Robot-->>App: 0xFFE1 notify: resultMermaid
복사
The app’s frame builder (BleUtilsKt.getSendData) changes the frame layout depending on the value length. Short values go out in one shot as "52" + len + cmd + data + checksum. Longer values such as the SSID or password are cut into 14-byte pieces and sent as chunks of the form "52" + len + cmd + index + total + data + checksum. The command codes are defined in the app’s DeviceOperate.
Code | Name | Meaning |
0x01 | SECRET | Handshake |
0x02 | GET_SN | Query serial number |
0x03 | WIFI_TYPE | Set WiFi mode (AP=01, STA=02, NET=03) |
0x04 | WIFI_ACCOUNT | Send SSID |
0x05 | WIFI_PWD | Send password |
0x06 | COUNTRY | Country code + apply settings |
0x07 | GET_AP_MAC | Query MAC address |
0x08 | DISCONNECTED | Disconnect |
[Table 2] BLE command codes defined in the app
Once the settings take effect, AP mode pins the robot’s IP at 192.168.12.1. In STA mode, the app looks for the robot on UDP port 10131 of the multicast groups 231.1.1.1 and 239.255.1.1. When the robot answers with its SN and IP on UDP port 10134, the app moves on to the WebRTC connection.
2.3. Command Handling
So how is a command that arrives over BLE actually handled inside the robot? We break it into three stages and go through them in order.
2.3.1. The Command Branch Added to the Write Handler
// branch added by Unitree in write_cb (BlueZ src/shared/gatt-server.c)
v11 = *(unsigned __int8 *)pdu; // pdu[0:2] = target ATT handle, v11 = low byte
...
v20 = v11 == 18; // handle low byte == 0x12 (FFE2 value handle)
if ( v20 )
receive_manager((bt_att_chan *)pdu, v9, (bt_gatt_server *)user_data, ...);
gatt_db_attribute_write(attribute, ...); // stock BlueZ handling from here on
C
복사
For ATT writes, btgatt-server uses write_cb from src/shared/gatt-server.c, BlueZ’s shared library, as is.[1] Attribute lookup, permission checks, and value storage are the same as upstream BlueZ. Unitree added a single branch inside that function: if the low byte of the target handle is 0x12 (the value handle 0x0012 of 0xFFE2), it calls the command parser receive_manager before the default write handling.
2.3.2. Frame Decryption and the Command Set
[0] 0x52 'R' request magic (responses use 0x51 'Q')
[1] packet_len
[2] command
[3..4] index/mode current/total chunk index, or wifi_mode
[5..] payload
[len-1] checksum = (uint8) -(sum of preceding bytes)
Plain Text
복사
receive_manager decrypts the incoming value and then checks the checksum, the magic, and the fields, in that order. The decrypted plaintext frame follows the layout above.
void __fastcall receive_manager(bt_att_chan *chan, unsigned __int16 opcode, bt_gatt_server *pdu, uint16_t length, void *user_data)
{
...
v5 = opcode - 2; // length to decrypt = ATT value length
decrypt_data((char *)&chan->att + 2, v5, &decrypted_data);
puts("check_incoming_pkt_accuracy()");
...
// checksum: two's complement of the byte sum must equal the last byte
if ( (unsigned __int8)*(&decrypted_data + (unsigned __int16)v5 - 1) != v12 )
{
__printf_chk(1, "check result error!");
return;
}
if ( decrypted_data != 82 ) // 0x52 'R' = request magic
return;
v13 = v49; // decrypted[1] = packet_len
...
v14 = v50; // decrypted[2] = command
// v51 = decrypted[3..4] = index/mode, v52 = &decrypted[5] = payload
...
}
C
복사
This is the part of the receive_manager decompilation that deals with that layout. The second argument is named opcode, but it is actually used as the PDU length.
Parsing goes like this:
1.
Skip the 2-byte handle in the PDU and extract the value.
2.
Decrypt it with decrypt_data.
3.
Verify the checksum in the last byte (two’s complement of the sum of the preceding bytes).
4.
Check that the first byte is the request magic 0x52 'R'.
5.
Extract packet_len, command, index/mode, and payload.
6.
Branch to a handler based on command.
encrypt_data(send_pdu, n, v60);
bt_gatt_server_send_notification(pdu, 0xDu, (const uint8_t *)v60, n, 0); // 0x0D = FFE1 value handle
C
복사
For the response, send_pdu puts the response magic 0x51 'Q' and the result together, encrypts it with encrypt_data, and sends it to 0xFFE1 via bt_gatt_server_send_notification(..., 0x0D, ...).
In terms of handles: commands come in on 0xFFE2 (value handle 0x0012) and responses go out on 0xFFE1 (value handle 0x000D).
flowchart TB
subgraph R1["① receive / decrypt"]
direction LR
A["0xFFE2 write<br/>(ATT PDU)"] --> B["extract value<br/>(skip 2B handle)"]
B --> C["decrypt_data"]
end
subgraph R2["② validate"]
direction LR
D{"checksum OK?"} -->|Yes| E{"decrypted[0] ==<br/>0x52 'R'?"}
D -->|No| X["drop"]
E -->|No| X
end
subgraph R3["③ dispatch"]
direction LR
F["packet_len / command<br/>index / payload"] --> G{"command"}
G -->|"handshake"| H["verify →<br/>set session flag"]
G -->|"0x02 ~ 0x07"| K["SN / MAC query,<br/>WiFi setup"]
G -->|"0x08 / 0x0A"| I["disconnect /<br/>keep-alive"]
K --> L["encrypt response →<br/>0xFFE1 notify (0x000D)"]
end
R1 --> R2
R2 -->|Yes| R3Mermaid
복사
The commands handled by the robot’s receive_manager are listed in Table 3.
command | Function | What it does |
0x01 | Handshake | Authenticates the session and sets the session flag (valid_incoming_user) |
0x02 | Query serial number | Reads the SN file and returns it in chunks |
0x03 | Reset WiFi settings | Sets wifi_mode (1=AP / 2=STA), clears receive buffers |
0x04 | Receive SSID | Accumulates chunks in index order |
0x05 | Receive password | Accumulates chunks in index order |
0x06 | Country code + apply | Spawns remoteConnectionSettingThreadFunction and wifiSettingThreadFunction threads |
0x07 | Query MAC | Returns the result of ioctl(SIOCGIFHWADDR, "wlan1") |
0x08 | Disconnect | activeDisconnectThreadFunction |
0x0A | keep-alive | Drops the BLE connection if nothing arrives for 3 periods |
[Table 3] receive_manager command set
// BleUtilsKt — request frame assembly
arrayList.add("52"); // magic
arrayList.add(BluetoothUtils.format(src.size() + 4)); // length
arrayList.add(operate.getNum()); // command code
arrayList.addAll(src); // data
// → "52 <len> <cmd> <data...>" → encrypt → 0xFFE2 write
// DeviceOperate — command code definitions
SECRET("01"), GET_SN("02"), WIFI_TYPE("03"), WIFI_ACCOUNT("04"),
WIFI_PWD("05"), COUNTRY("06"), GET_AP_MAC("07"), DISCONNECTED("08"), ...
Java
복사
The app code shows the same frame structure we saw on the robot after decryption. Request frames are assembled by BleUtilsKt, and the command codes are defined in the DeviceOperate enum.
The app’s WIFI_ACCOUNT and WIFI_PWD map directly onto the robot’s 0x04 and 0x05. The SSID and password the user types in are wrapped with these command codes in ConnectActivity and sent.
2.3.3. Application-Layer Encryption and Session Authentication
BLE onboarding frames travel encrypted with AES at the application layer. decrypt_data unwraps incoming frames and encrypt_data wraps responses. network_manager, the module btgatt-server belongs to, is updated over OTA, so the details of the encryption and handshake differ between module versions. The app likewise switches its handling based on the BLE version the robot reports.
The session authentication structure is the same across versions. When the handshake command passes verification, the session flag valid_incoming_user is set to 1, and from then on commands 0x02–0x07 (queries and WiFi setup) are processed. What changes between versions is the handshake stage in front of that; the WiFi setup command set itself is identical.
2.4. Data Flow Analysis
2.4.1. Actual UUID Data and Entry Points
To check that the GATT DB layout recovered by static analysis matches the real device, we enumerated the GATT services on our test unit with a GATT DB scanner script.
[Service] 0000ffe0-... (handle 0x000b) Go2 custom provisioning/command container
[Char] 0000ffe1-... h=0x000c [N] response notify (value handle 0x000d)
[Desc] 00002902-... h=0x000e CCC (subscribe on/off)
[Char] 0000ffe2-... h=0x0011 [W] command write channel
[Char] 00002a38-... h=0x000f [R]
[Service] 00001801-... (handle 0x0007) standard GATT
[Char] 00002a05-... h=0x0008 [R,I] Service Changed
[Desc] 00002902-... h=0x000a CCC
=> 2 services / 4 characteristics
Plain Text
복사
The 0x1800 GAP service is registered in main() but did not show up in the scanner’s enumeration. Apart from that, the services, characteristics, and handles matched the static analysis. The advertising data does not carry a 128-bit service UUID either; Go2 is identified by its Go2_* name.
Entry point | UUID / Handle | Direction | Purpose |
Advertising | Local Name, Manufacturer Specific Data (0xFF) | Robot → outside | Device discovery |
Command write | 0xFFE2 / value 0x0012 | App → robot | Enters the command parser receive_manager |
Response notify | 0xFFE1 / value 0x000D | Robot → app | Encrypted responses |
[Table 4] BLE component entry points
Table 4 lists the BLE component’s entry points derived from the analysis so far.
2.4.2. Handling of Onboarding Data
Data crossing BLE falls into two kinds, depending on direction.
•
Robot → app (queries): 0x02 opens /unitree/robot/basic/code (the SN file) read-only and returns it in chunks. 0x07 reads the hardware address of wlan1 with ioctl(SIOCGIFHWADDR) and returns it.
•
App → robot (settings): The WiFi mode, SSID, and password received through 0x03–0x06 are passed to the network setup scripts by wifiSettingThreadFunction, the thread spawned by 0x06. In AP mode it runs hostapd_restart.sh, and in STA mode wpa_supplicant_restart.sh, with the SSID and password as arguments. 0x06 also starts the remote connection settings thread remoteConnectionSettingThreadFunction, which changes the value of /unitree/etc/remote_connection/RemoteConnectionEnabled.
Each script updates the SSID and password entries in the hostapd or wpa_supplicant config file (/etc/hostapd/hostapd.conf, /etc/wpa_supplicant/wpa_supplicant-wlan0.conf) and then restarts that service.
2.4.3. DFD
Putting the analysis together gives the data flow below. The BLE component is a wireless entry point that is open before any WiFi connection exists, and the robot’s network configuration depends on the outcome of onboarding.
---
config:
layout: elk
---
flowchart LR
subgraph RADIO["Trust boundary: BLE radio range"]
APP["Unitree app<br/>(lib_ble)"]
end
subgraph ROBOT["Trust boundary: Go2 robot host"]
P1("1.0 advertising<br/>set_ble_adv_and_response")
D1[("D1 robot/basic<br/>bluetooth, code")]
P2("2.0 ATT server<br/>write_cb")
P3("3.0 command parser<br/>receive_manager")
P4("4.0 WiFi apply<br/>wifiSettingThreadFunction")
P5("5.0 network scripts<br/>hostapd / wpa_supplicant")
D2[("D2 hostapd /<br/>wpa_supplicant conf")]
end
subgraph WIFI["Trust boundary: WiFi network"]
NET["wlan1 (AP)<br/>wlan0 (STA)"]
end
APP <-->|"scan / adv · scan rsp"| P1
APP <-->|"0xFFE2 write /<br/>0xFFE1 notify"| P2
D1 -->|"code / SN"| P1
P2 <-->|"command / response"| P3
D1 -->|"SN"| P3
P3 -->|"mode / SSID / PW"| P4
P4 -->|"SSID / PW"| P5
P5 -->|"update"| D2
D2 -->|"restart"| NETMermaid
복사
The BLE path goes through the robot and comes out on the WiFi network. Values sent by the app pass through receive_manager → the settings thread → the setup scripts and end up in the hostapd/wpa_supplicant configuration. What that produces is the very WiFi network on which RTSP (covered in Part 3) and, later, WebRTC are exposed.
3. WebRTC / DDS Bridge Component Analysis
3.1. Basic Building Blocks
After BLE onboarding gets the robot onto WiFi, the app sets up a WebRTC session with the robot and sends JSON control messages of the form {type, topic, data} over a DataChannel opened on top of it. A bridge process inside the robot relays these messages to DDS topics (rt/*) of the same name.
Inside the robot there is a bus tied together by DDS, a publish/subscribe middleware, and services such as motion control, audio, LiDAR/SLAM, and the cameras talk to each other over it. External WebRTC traffic can reach this internal bus only through the bridge. Chapter 3 looks at which processes make up the bridge and how an external message becomes a DDS topic.
3.1.1. WebRTC / DataChannel / DDS Terminology
•
WebRTC
◦
WebRTC: A set of standardized technologies for low-latency P2P audio, video, and data between browsers. Go2 uses it as the real-time control channel between the app and the robot
◦
Signaling: The negotiation channel two endpoints use to exchange session parameters. The WebRTC standard does not define it, so each application implements its own; on Go2 this is the HTTP signaling (TCP 9991) in xfkTon
◦
SDP offer/answer: A session description with codecs, transport, ICE candidates, DTLS fingerprints, and so on. The app sends an offer and the robot returns an answer
◦
ICE / STUN / TURN: The procedure for finding a working path between two endpoints behind NAT (ICE), plus servers for discovering the public address (STUN) and relaying traffic (TURN)
◦
DTLS: TLS for UDP; once a path is chosen, it derives session keys and encrypts media/data
◦
SCTP DataChannel: A channel on top of DTLS that carries arbitrary application data. Go2’s control JSON flows over it
•
DDS
◦
DDS (Data Distribution Service): A data-centric publish/subscribe middleware standardized by the OMG. Go2 uses Eclipse Cyclone DDS as its implementation[2]
◦
Topic: A unit of data flow identified by a name and a data type (e.g. rt/api/sport/request, type unitree_api::msg::dds_::Request_)
◦
DataWriter / DataReader: The entities that write to and read from a topic. They don’t need to know about each other; matching topics is enough to connect them
◦
Domain: A logical bus that isolates DDS traffic. Only participants in the same domain discover each other
◦
RTPS: The DDS wire protocol. Participants are discovered over UDP multicast by default
◦
QoS: Policies for reliability, durability, deadlines, history, and other aspects of delivery
A single remote-control interaction on Go2 goes SDP exchange over HTTP signaling → path selection with ICE → encrypted channel via DTLS → SCTP DataChannel opened → control JSON sent and received → published to a DDS topic.
3.1.2. Signaling Server and Bridge Processes
root@Unitree:~# ps aux | grep -iE 'rtsp|stream|video|webrtc'
root 1257 ... /unitree/module/video_hub/go2_videohub_rtsp_server
root 1609 ... /unitree/module/video_hub/videohub high
root 1705 ... /unitree/module/webrtc_bridge/bin/unitreeWebRTCClientMaster
root 1714 ... /bin/python3 /unitree/module/webrtc_bridge/src/webrtc_dds_bridge/multicast_responder.py
root 1739 ... /unitree/module/webrtc_bridge/src/webrtc_dds_bridge/xfkTon
C
복사
The bridge is split across several daemons under the /unitree/module/webrtc_bridge module, and master_service starts each of them as a separate service.
Service | Binary | DDS config | Role |
webrtc_multicast_responder | multicast_responder.py | Not used (plain sockets only) | Answers robot IP discovery in STA mode |
webrtc_signal_server | xfkTon | cyclonedds.xml (SHM on) | Listens on TCP 80 / 9991 / 10001 (signaling on 9991), relays signaling ︎ DDS |
webrtc_bridge | unitreeWebRTCClientMaster | cyclonedds_noshm.xml (SHM off) | WebRTC endpoint + DataChannel ︎ DDS bridge |
Media sources | videohub / audiohub / go2_videohub_rtsp_server | cyclonedds.xml | Publish/subscribe video (H.264) and audio (Opus) over DDS |
Libraries | libkvsWebrtcClient.so / libkvsWebrtcSignalingClient.so | - | Fork based on the AWS KVS WebRTC C SDK |
[Table 5] Processes that make up the WebRTC/DDS bridge
The roles are split: xfkTon owns the network-facing signaling, while unitreeWebRTCClientMaster owns the WebRTC media/data endpoint and the DDS bridge logic. The two processes are not wired together through function calls; they pass SDP over DDS topics. The offer that xfkTon decrypts is delivered to the bridge on the rt/xfk_webrtcreq topic, and the answer the bridge produces comes back on rt/xfk_webrtcres.
Broken down, unitreeWebRTCClientMaster does four things:
1.
Session open/close: Takes an offer, sets up the session struct, PeerConnection, DataChannel, and media transceivers, and returns an answer.
2.
Downlink publishing: Parses JSON arriving on the DataChannel, looks the topic up in the publish whitelist, and calls dds_write.
3.
Uplink delivery: Subscribes (dds_take) to response and status topics from the robot’s services, turns them into JSON, and sends them back to the requesting session.
4.
Media relay: Moves the app’s audio onto DDS, and camera frames from DDS onto the WebRTC video track.
3.2. Signaling and Session Handling
3.2.1. Signaling Flow
External clients never connect to the bridge directly; they always go through xfkTon (TCP 9991). Signaling has two steps: a handshake and the offer submission.
sequenceDiagram
autonumber
participant APP as App / WebRTC client
participant XFK as xfkTon (TCP 9991)
participant SIG as DDS signaling topics<br/>rt/xfk_webrtcreq / res
participant BR as unitreeWebRTCClientMaster
participant BUS as Robot DDS domain (eth0)
APP->>XFK: POST /con_notify
XFK-->>APP: {data1 = RSA pubkey (AES-GCM), data2}
APP->>APP: generate session key k, encrypt offer
APP->>XFK: POST /con_ing_(path) {data1 = AES(offer, k), data2 = RSA(k)}
XFK->>XFK: recover k, decrypt offer
XFK->>SIG: DDS write String_ (offer)
SIG->>BR: rt/xfk_webrtcreq
BR->>BR: create session, parse id / token, PeerConnection
BR->>SIG: answer → rt/xfk_webrtcres
SIG->>XFK: answer
XFK-->>APP: HTTP response (AES(answer, k))
Note over APP,BR: ICE / DTLS handshake, SCTP DataChannel open
APP->>BR: DataChannel {type, topic, data}
BR->>BUS: whitelist check → dds_write
BUS->>BR: response / telemetry (subscribed topics)
BR->>APP: {type:'res', topic, data} to requesting sessionMermaid
복사
1.
Handshake (POST /con_notify)
•
When a client calls this endpoint, the robot returns {data1, data2}.
•
data1 holds the robot’s ephemeral RSA public key encrypted with AES-128-GCM, and data2 is a version flag indicating the encryption scheme.
•
On v1.1.15 and later, data2 is 3 and data1 is decrypted with a per-device AES key. That key is generated during BLE binding and reaches the owner’s account through the cloud. On the robot it is stored as /unitree/etc/key/aes_key.bin.
•
When a client calls this endpoint, the robot returns {data1, data2}.
•
data1 holds the robot’s ephemeral RSA public key encrypted with AES-128-GCM, and data2 is a version flag indicating the encryption scheme.
•
On v1.1.15 and later, data2 is 3 and data1 is decrypted with a per-device AES key. That key is generated during BLE binding and reaches the owner’s account through the cloud. On the robot it is stored as /unitree/etc/key/aes_key.bin.
1.
Offer submission (POST /con_ing_<path>)
•
The client generates a random session key k, encrypts the offer JSON with AES under k, encrypts k with the robot’s RSA public key, and sends both as a {data1, data2} body.
•
The <path> at the end of the URL is derived from a token appended to the end of data1 in the con_notify response.
•
The client generates a random session key k, encrypts the offer JSON with AES under k, encrypts k with the robot’s RSA public key, and sends both as a {data1, data2} body.
•
The <path> at the end of the URL is derived from a token appended to the end of data1 in the con_notify response.
xfkTon recovers k with its RSA private key, decrypts the offer, wraps the plaintext in a std_msgs::msg::dds_::String_ message, and publishes it to DDS rt/xfk_webrtcreq. The answer from the bridge travels back the other way (rt/xfk_webrtcres → xfkTon → HTTP response encrypted with k) to the client.
When the app connects remotely through the cloud (robot-api.unitree.com) and a TURN server, the structure doesn’t change: the same keys and encryption are used as on the LAN xfkTon path. The remote path also uses AWS KVS signaling, where unitreeWebRTCClientMaster authenticates to KVS with temporary IoT credentials and SigV4 signatures.
3.2.2. Session Creation and Offer Parsing
The bridge subscribes to both signaling sources: rt/xfk_webrtcreq, which comes through xfkTon, and rt/webrtcreq, a direct source.
// select_webrtc_request_source_topic_candidate (unitreeWebRTCClientMaster)
void __fastcall select_webrtc_request_source_topic_candidate(__int64 a1, const char *a2)
{
if ( !strcmp(a2, "rt/webrtcreq") )
{
*(_DWORD *)(a1 + 46944) = 1;
}
else if ( !strcmp(a2, "rt/xfk_webrtcreq") )
{
*(_DWORD *)(a1 + 46944) = 0;
}
}
C
복사
select_webrtc_request_source_topic_candidate records which topic the offer came in on at offset +46944 of the session struct (1 = rt/webrtcreq, 0 = rt/xfk_webrtcreq). That value decides whether the answer goes back on rt/webrtcres or rt/xfk_webrtcres. According to the topic inventory, the publisher of rt/webrtcreq is robot_state_service.
When an offer arrives, webrtc_create_streaming_session allocates the session struct with calloc(1, 46960). Creating a PeerConnection, registering ICE and DataChannel callbacks, and then attaching video/audio transceivers appears to follow the session setup flow in the AWS KVS WebRTC C SDK sample (kvsWebRTCClientMaster).[3] The binary name unitreeWebRTCClientMaster looks borrowed from that sample too. What Unitree added on top:
•
Track and stream names (unitreeVideoTrack, unitreeAudioTrack, unitreeMediaStream)
•
Recording of the signaling source (+46944, via select_webrtc_request_source_topic_candidate above)
•
Storage fields for the offer’s id/token (getOfferId/getOfferToken below)
•
Two function pointer tables for action dispatch (sess+46864, sess+46896)
The plaintext offer is JSON of the form {sdp, type, token, id}. On a LAN connection id holds a value like STA_localNetwork, and the app requests a DataChannel labeled "data", a receive-only video transceiver, and a send/receive audio transceiver. The identifying fields of the offer JSON are pulled out by getOfferId (offer.id → sess+25873) and getOfferToken (offer.token → sess+26274) and stored in fixed buffers inside the session struct. getTurnServerInfo handles TURN server info the same way.
// getOfferId — src: webrtcUtils.c:1682
root = sub_1C821C(offerJson); // parse offer JSON
node = sub_1C9CEC(root, "id");
if (node) strcpy((char*)(sess + 25873), node[4]); // offer.id → sess+25873
// getOfferToken — src: webrtcUtils.c:1724
node = sub_1C9CEC(root, "token");
if (node) strcpy((char*)(sess + 26274), node[4]); // offer.token → sess+26274
C
복사
sub_1C9CEC looks up a node by key in a JSON object, and node[4] (offset 0x20) is the string value. The source file name (webrtcUtils.c) and line numbers left in the binary made it possible to recover the function names.
3.3. DataChannel
︎ DDS Bridge Logic
3.3.1. Downlink Pipeline
Messages arriving on the DataChannel are parsed in dataChannelOnMessageCallback. It branches on the type field into subscribe/unsubscribe/heartbeat/messages (req, msg), and for actual control messages it looks the topic up in the publish whitelist hash table.
The whitelist is a fixed set of 31 topics that register_dds_publisher fills in at boot, each registered as a (topic name, type, writer callback) triple. On a hit, the topic’s own writer callback copies the JSON fields into a DDS struct and calls dds_write; on a miss the message is dropped with Invalid Topic. There is no publish path to any topic outside the whitelist.
flowchart LR
IN["DataChannel inbound<br/>{type, topic, data}"] --> PARSE["dataChannelOnMessageCallback<br/>parse"]
PARSE -->|"type"| BR1["subscribe / unsubscribe /<br/>heartbeat"]
PARSE -->|"req / msg"| WL{"publish whitelist<br/>lookup (31)"}
WL -->|"miss"| REJ["Invalid Topic /<br/>drop"]
WL -->|"hit"| WR["per-topic writer<br/>write_request ..."]
WR -->|"JSON → DDS struct<br/>dds_write"| BUS["Robot DDS domain<br/>rt/api/*/request ..."]Mermaid
복사
3.3.2. Publish Whitelist (31 Topics → Writer Mapping)
For each topic, the registration function builds a publish endpoint in the order dds_create_topic → QoS setup → dds_create_writer → hashTablePut. The table below lists the 31 publish topics recovered from the registration block and the writer that handles each.
Topic | Writer callback |
rt/api/{sport, motion_switcher, config, vui, gpt, robot_state, audiohub, gas_sensor, fourg_agent, obstacles_avoid, uwbswitch, pet, assistant_recorder, bashrunner, programming_actuator}/request (15) | write_request (shared) |
rt/api/rm_con/request | write_rm_con_service_request |
rt/api/videohub/request | write_api_videohub_request |
rt/qt_command | write_qt_command |
rt/qt_add_node | write_qt_node_cmd |
rt/qt_add_edge | write_qt_edge_cmd |
rt/wirelesscontroller | write_wireless_controller |
rt/wirelesscontroller_unprocessed | write_wireless_controller_unprocessed |
rt/arm_Command | write_armstring |
rt/rtc/state, rt/rtc_status, rt/videohub/inner, rt/uwbswitch, rt/servicestateactivate, rt/utlidar/switch, rt/uslam/client_command, rt/programming_actuator/command (8) | write_string / write_rtc_status / write_videohub_inner |
[Table 6] Publish whitelist and writer mapping
Notably, the 15 rt/api/*/request topics all share one writer, write_request. It reads header.identity.{id, api_id}, parameter, and header.policy.{priority, noreply} from the JSON, fills a unitree_api::Request_ struct, and publishes it. The bridge only decides whether a topic may be published; what an api_id means is up to the service subscribed to that topic.
void write_request(__int64 ctx, __int64 json) {
printf("write_request() topicName =%s\n", *(const char**)(ctx+24));
header = sub_1C9CEC(json, "header");
identity = sub_1C9CEC(header, "identity");
sub_1C9CEC(identity, <id key>); // header.identity.id
if ( sub_1C9CEC(identity, "api_id") ) { // check api_id exists
param = sub_1C9CEC(json, "parameter");
if ( param && param[4] ) { // check parameter string exists
policy = sub_1C9CEC(header, "policy");
priority = policy ? sub_1C9CEC(policy,"priority")[10] : 0;
noreply = (policy && sub_1C9CEC(policy,"noreply")[10]==1) ? 1 : 0;
imp_thunk_dds_write(); // publish to the request topic
}
}
}
C
복사
A message is published only if both api_id and parameter are present; a missing policy falls back to defaults. The topic name is bound to the writer context (ctx+24) at registration time, which is how one function serves all 15 topics.
write_qt_command, write_qt_node_cmd, and write_qt_edge_cmd are writers for QT interaction nodes and share a sequence conversion function that turns a JSON array into a DDS sequence (length + buffer). The bashrunner, programming_actuator, and assistant_recorder topics get additional deadline, durability, and history QoS settings.
3.3.3. Uplink Pipeline and Media Relay
The uplink runs the other way: it subscribes to 44 topics carrying responses and status from the robot’s services and sends them back to the client. Readers are registered in the order hashTablePut → dds_create_topic → dds_create_listener → dds_lset_data_available → dds_create_reader. When the listener signals that a sample has arrived, the per-topic callback pulls it out with dds_take and serializes it to JSON. RPC responses take the form {type:"res", topic, data}, and subscribed status topics {type:"msg", topic, data}.
At send time the bridge walks the session array and delivers over the DataChannel only to the one session whose request-id / uuid matches. Responses are kept per session so that a reply to user A’s request never ends up in user B’s session. The main callbacks are:
Callback | Target |
do_response_data_take | Shared for rt/api/*/response |
do_res_take | Responses with a base64 body |
do_lf_lowstate_data_take, do_sportmode_state_data_take | Robot state |
do_lidar_voxel_map_data_take, do_uslam_point_cloud2_* | LiDAR / SLAM |
do_string_data_take | String notifications |
[Table 7] Main uplink callbacks
The flow of do_response_data_take, which handles rt/api/*/response, shows how per-session routing works.
void do_response_data_take(int reader, __int64 a2) {
v17 = imp_thunk_dds_take();
sample = *(a2+8); // response sample
obj = sub_1CA5EC(); // new JSON object
sub_1C9E78(obj, "type", "res");
sub_1C9E78(obj, "topic", *(a2+16)); // topic name
// build data.header.{identity{api_id,id}, status{code}}, data.data ...
s = sub_1C8268(obj); // serialize JSON
hasBody = *(_DWORD*)(sample+36) != 0; // binary body present?
if (hasBody)
sub_1D330C(s, body, v28); // assemble [n][plen][header][body]
// walk sessions, match request-id → send only to the requesting session
for (i = 0; i < session_count; i++) {
sess = session_array[i];
for (j = 0; j <= 39; j++)
if (sess.pendingReqId[j] == sample.reqId) {
if (hasBody) send_on_datachannel_dispatch_thunk(sess, 1, v28[1]); // binary
else send_on_datachannel_dispatch_thunk(sess, 0, s); // string
break;
}
}
}
C
복사
Each session holds up to 40 pending request-ids, and a response sample goes only to the session holding a matching request-id. Responses with a body are sent as a binary frame prefixed with the header JSON length (n) and body length (plen); responses without one go out as a plain JSON string.
In the media relay, audio and video flow in opposite directions.
•
Audio (app → robot): audioFrameHandler writes WebRTC audio frames to DDS rt/audioreceiver. The writer is created when the first frame arrives.
•
Video (robot → app): A bridge reader receives DDS rt/frontvideostream (Go2FrontVideoData_), published by videohub, and feeds it into each session’s video transceiver with writeFrame.
3.4. Data Flow Analysis
3.4.1. DDS Topic Reachability
Reachability here means whether a user connected over the WebRTC DataChannel can cause a publish or subscribe on a given rt/* topic. A participant that joins the DDS bus directly over the wire (eth0) can reach every topic, but WiFi and remote users have to go through the bridge whitelist, which cuts the reachable set down considerably.
We identified 119 rt/* topics in total. Of those, the bridge can publish to 31 and subscribes to 44; both numbers were recovered from the registration blocks in the bridge binary.
Category | Representative topics | Direction |
Service API (rt/api/* request/response) | sport, motion_switcher, config, vui, gpt, robot_state, audiohub, pet, videohub, etc. | Requests published, responses subscribed |
Low-level motion | rt/lowcmd, rt/lowstate, rt/lf/lowstate, rt/sportmodestate | State subscribed |
LiDAR / mapping | rt/utlidar/* (cloud, grid_map, voxel_map, switch, etc.) | Mostly PointCloud2 subscriptions |
SLAM / localization | rt/uslam/* (client_command, cloud_map, localization, etc.) | Commands published + state subscribed |
Positioning / network | rt/gnss, rt/uwbstate, rt/uwbswitch, rt/public_network_status | Mixed |
Audio / video | rt/audiosender, rt/audioreceiver, rt/frontvideostream, rt/videohub/inner | Media |
AI / interaction | rt/gpt_cmd, rt/gpt_state, rt/gesture/result, rt/pet/flowfeedback | Mixed |
WebRTC / remote | rt/webrtcreq, rt/webrtcres, rt/xfk_webrtcreq, rt/xfk_webrtcres | Signaling |
Robot arm | rt/arm_Command, rt/arm_Feedback, rt/arm/action/state | Mixed |
System status | rt/servicestate, rt/selftest, rt/multiplestate | Status |
Controller input | rt/wirelesscontroller, rt/wirelesscontroller_unprocessed | Published |
QT interaction | rt/qt_command, rt/qt_add_node, rt/qt_add_edge, rt/qt_notice | Published / subscribed |
[Table 8] DDS topic inventory by category (119 topics)
3.4.2. DDS Domain, QoS, and Security (Authentication) Configuration
Dumping ddsls output and participant info shows that Go2’s DDS participants run on the eth0 interface with domain Id="any". Each participant advertises a udp/239.255.0.1:7400 multicast address and a udp/192.168.123.161:<port> unicast address. In other words, the robot’s internal bus is CycloneDDS multicast (UDP 7400) on the internal wired network (192.168.123.0/24).
ddsls lists 24 participants, including unitreeWebRTCClientMaster, xfkTon, vui_service, robot_state_service, motion_switcher_service, videohub, audiohub, unitree_lidar_server, obstacles_avoid, basic_service, and sbus_handle. Eight of them are python3 processes.
Shared memory (SHM) settings differ by process: most robot services use cyclonedds.xml with SHM on, while the bridge uses cyclonedds_noshm.xml with SHM off.
<Security><Authentication>
<Library initFunction="init_authentication" finalizeFunction="finalize_authentication"
path="/unitree/lib/libdds_security_auth.so" />
<IdentityCA>file:/unitree/etc/ds/<CA>.crt</IdentityCA>
<IdentityCertificate>file:/unitree/etc/ds/<cert>.crt</IdentityCertificate>
<PrivateKey>file:/unitree/etc/ds/<key>.crt</PrivateKey>
</Authentication></Security>
XML
복사
The DDS security settings show that cyclonedds.xml enables the DDS Security Authentication plugin.[2]
Joining the DDS bus over the wire requires authenticating with a certificate signed by the IdentityCA and its private key. WiFi and remote users, on the other hand, reach internal topics through the bridge without that certificate, and how far they can reach is set by the bridge’s publish whitelist.
3.4.3. DFD
The DFD below shows the bridge component’s data flow. There are three zones with different trust levels, and an external message crosses the boundaries in the order signaling → bridge → DDS domain.
---
config:
layout: dagre
flowchart:
nodeSpacing: 18
rankSpacing: 28
---
flowchart LR
subgraph EXT["Trust boundary: WiFi LAN / Internet"]
APP["Unitree app<br/>(local / remote)"]
CLOUD["Unitree cloud<br/>robot-api / TURN"]
end
subgraph EDGE["Trust boundary: Go2 robot host (WebRTC bridge)"]
P4("4.0 discovery<br/>multicast_responder.py")
D1[("D1 device key<br/>aes_key.bin")]
P1("1.0 signaling<br/>xfkTon :80 / 9991 / 10001")
P2("2.0 WebRTC session<br/>unitreeWebRTCClientMaster")
P3("3.0 DDS I/O<br/>publish 31 / subscribe 44")
end
subgraph DDSD["Trust boundary: robot DDS domain (eth0, cert auth)"]
SVC["rt/api/* services<br/>sport / config / vui / pet ..."]
MEDIA["videohub / audiohub"]
end
APP <-->|"remote signaling / TURN"| CLOUD
APP <-->|"UDP 10131 / SN + IP"| P4
APP -->|"con_notify / con_ing"| P1
D1 -->|"key"| P1
P1 <-->|"offer / answer"| P2
APP <-->|"DataChannel"| P2
P2 <-->|"whitelist check"| P3
P3 <-->|"dds_write / dds_take"| SVC
P3 <-->|"audio / video"| MEDIAMermaid
복사
Entry point | Protocol | Process | Purpose |
9991 | TCP (HTTP) | xfkTon | Signaling (con_notify, con_ing) |
80 / 10001 | TCP | xfkTon | Listening ports (purpose unconfirmed) |
DataChannel | SCTP / DTLS | unitreeWebRTCClientMaster | Control JSON in and out |
10131 → 10134 | UDP multicast | multicast_responder.py | Receives robot SN/IP discovery queries / replies |
7400 | UDP multicast (eth0) | DDS participant processes | DDS discovery (internal wired network) |
[Table 9] WebRTC/DDS entry points
1.
The bridge is the only way an external message reaches the internal DDS bus. Signaling is protected by an encrypted channel using a per-device key, and the publish scope is fixed at the 31 whitelisted topics.
2.
Inter-process communication uses DDS as well. xfkTon and the bridge exchange offers and answers over the signaling topics (rt/xfk_webrtcreq/res), which makes those topics the point where external signaling meets the internal bus.
4. OTA / UPK Component Analysis
4.1. Basic Building Blocks
BLE, RTSP, and WebRTC are how users interact with the robot. OTA is different in kind from those three: it is how the cloud replaces the robot’s software itself. In Part 3 we got as far as the init script ota_box starting at the same multi-user stage as master_service. Chapter 4 follows this OTA daemon from receiving a command to installing a package.
[Figure 2] Unitree’s official OTA guide
4.1.1. OTA / UPK / MQTT / CDN Terminology
•
OTA (Over-The-Air)
◦
A way for a device to receive firmware/software over the network and update itself, with no cable or manual steps
•
A way for a device to receive firmware/software over the network and update itself, with no cable or manual steps
•
UPK
◦
Unitree’s custom update package container (.upk)
◦
Consists of a 112-byte header (magic UTPK) + TEA-encrypted payload + an inner TAR structure (see Part 2 of the series for the format)
•
Unitree’s custom update package container (.upk)
•
Consists of a 112-byte header (magic UTPK) + TEA-encrypted payload + an inner TAR structure (see Part 2 of the series for the format)
•
MQTT
◦
A lightweight publish/subscribe messaging protocol designed for low-bandwidth environments
◦
A client (the robot) connects to a broker (the server) and subscribes to topics, then receives messages published to those topics in real time
◦
mqtts:// is MQTT wrapped in TLS
•
A lightweight publish/subscribe messaging protocol designed for low-bandwidth environments
•
A client (the robot) connects to a broker (the server) and subscribes to topics, then receives messages published to those topics in real time
•
mqtts:// is MQTT wrapped in TLS
•
mTLS (mutual TLS)
◦
Two-way TLS authentication in which server and client each verify the other’s certificate
•
Two-way TLS authentication in which server and client each verify the other’s certificate
•
CDN
◦
A delivery network that serves large files from geographically distributed servers. Go2 firmware packages are downloaded from a CDN over HTTPS
•
A delivery network that serves large files from geographically distributed servers. Go2 firmware packages are downloaded from a CDN over HTTPS
•
MD5 checksum
◦
An integrity check that computes a 128-bit hash over a file’s contents and compares it to see whether the received file is the expected one
•
An integrity check that computes a 128-bit hash over a file’s contents and compares it to see whether the received file is the expected one
4.1.2. OTA Orchestration Daemon
Go2’s OTA is split into an orchestration daemon that receives and directs commands and a utility suite that does the actual work. The top-level daemon is ota_boxed (version v1.2.1.4).
As we saw in Part 3, ota_boxed is started via start-stop-daemon by the SysV init script /etc/init.d/ota_box. The executable is /unitree/ota/engine/ota_boxed and the PID file is /unitree/var/run/ota_boxed.pid. The init script’s Required-Start lists $local_fs, $network, $named, and master_service, so it starts only after networking and DNS are ready and master_service is up. ota_boxed is also on master_service’s list of managed services, which means it is managed by both the init script and master_service.
The daemon’s settings live in ota_boxed.json, with values like MQTTAuth: 1, CheckUpdateInterval: 5, and ReportVersionInterval: 1800. Logs accumulate under /unitree/var/log/ota_box.
From boot to reporting results, ota_boxed is responsible for:
1.
Keeping a TLS connection to the Unitree cloud MQTT broker after boot.
2.
Subscribing to its own device-specific command topic, cmd/<SN>.
3.
Interpreting incoming JSON commands to decide which helper utility to run and with which arguments.
4.
Running the helper utility as a child process.
5.
Sending the outcome back on the MQTT report topic.
ota_boxed acts as the control plane, relaying “cloud command → local utility run”. ota_boxed and ota_utils are protected binaries, so we unpacked them separately before analysis.
4.1.3. Package Processing Engine
ota_utils (/unitree/ota/engine/bin/ota_utils) is an ARM64 CLI executable that runs the download, unpack, verify, and install state machine for package updates. Its initialization constructor sets up the paths of its sibling utilities as strings, which reveals the makeup of the whole OTA suite.
// initialization constructor sub_9343F8
std::string::basic_string(&unk_D89B28, "/unitree/ota/engine", v89);
sub_925AB8(qword_D89B48, &unk_D89B28, "/bin/ota_utils");
sub_925AB8(qword_D89B68, &unk_D89B28, "/bin/ota_module_utils");
sub_925AB8(qword_D89B88, &unk_D89B28, "/bin/ota_engine_utils");
sub_925AB8(qword_D89BA8, &unk_D89B28, "/bin/ota_report_utils");
sub_925AB8(qword_D89BC8, &unk_D89B28, "/bin/ota_remote_utils");
sub_925AB8(qword_D89BE8, &unk_D89B28, "/bin/ota_verify_utils");
sub_925AB8(qword_D89C08, &unk_D89B28, "/bin/ota_download_utils");
std::string::basic_string(&unk_D89C28, "/unitree/sbin/ota_pipe_cli", v89);
std::string::basic_string(&unk_D89C48, "/unitree/var/run/ota_boxed.sock", v89);
C
복사
sub_925AB8 concatenates two strings: it appends a utility name to the base path /unitree/ota/engine to build a global string. The resulting paths are listed below.
Path | Role |
/unitree/ota/engine/bin/ota_utils | Full package processing engine |
/unitree/ota/engine/bin/ota_module_utils | Per-module install / rollback / removal |
/unitree/ota/engine/bin/ota_engine_utils | Update / rollback of the OTA engine itself |
/unitree/ota/engine/bin/ota_report_utils | Progress and result reporting |
/unitree/ota/engine/bin/ota_remote_utils | Remote handling (inferred from the name) |
/unitree/ota/engine/bin/ota_verify_utils | Handles standalone verification requests (MD5) |
/unitree/ota/engine/bin/ota_download_utils | libcurl-based downloader |
/unitree/sbin/ota_pipe_cli | Pipe CLI (inferred from the name) |
/unitree/var/run/ota_boxed.sock | Unix domain socket for local IPC |
[Table 10] OTA utility suite
ota_utils is a dynamically linked PIE binary with roughly 47,989 functions and 30,148 strings. libcurl, libtar, libssh2, protobuf, beanstalk-client, paho-mqtt, and others are statically linked in, while OpenSSL is imported dynamically. The downloading (libcurl), unpacking (libtar), and reporting (protobuf/beanstalk) described below all run on top of this stack.
ota_boxed and ota_utils form a delegation chain: ota_boxed takes the cloud command and runs ota_utils (or another helper), and ota_utils in turn runs ota_module_utils during module processing. The process chain spans several levels: daemon → engine → module utility.
4.2. OTA Process Flow
The backbone of the OTA flow is receive MQTT cmd/<SN> → parse JSON → run helper → download / MD5 verify / install → report result. Per unit of work, one MQTT message = one OTACmdArgs = one helper run + one result report. The cloud sends verification, download, and installation to the robot as separate MQTT messages, in order.
4.2.1. Startup and Subscribing to MQTT cmd/<SN>
After boot, ota_boxed reads ServerUri from the MQTT config file (/unitree/etc/mqtt/mqtt_client_conf.json), connects to the broker, and subscribes to a topic based on its own serial number. The connection uses AuthType=1 (mTLS) with server certificate verification turned on. The connection settings include KeepAliveInterval: 10, AutoReconnect, and ReconnectInterval: 10, and the broker address is robot-mqtt (CN) or global-robot-mqtt (everywhere else) depending on region.
ssl option enable_server_cert_auth:1, verify:1
connect to the MQTT server success.
subscribe topic success. topic:cmd/<SN>
Plain Text
복사
With this design the robot opens no listening port for OTA. The robot makes the outbound connection to the broker first and receives commands over it, so an OTA command reaches the robot only if it is published through the broker to that robot’s cmd/<SN> topic.
4.2.2. Message Fields and Command Dispatch
An incoming MQTT message goes through this callback chain:
MQTTMessageCallback → MQTTMessageHandler → enqueue OTACmdArgs
→ OTAThreadFunction → OTABox::ExecuteOTACmd
Plain Text
복사
ExecuteOTACmd uses eight fields: command (JSON cmd), token, module, version, package, url, sign, and g (stage). Despite its name, sign is not a digital signature but the expected MD5 value. The JSON cmd value decides which helper runs.
JSON cmd | Helper executed | Role |
updatePackage | ota_utils -c updatepackage ... -g <stage> | Full package update (download / install stage) |
verifyPackage / verifyPackageRequest | ota_verify_utils -c verifypackage ... | Check package presence and MD5 |
updateModule | ota_module_utils -c updatemodule ... | Update a single module |
updateModule + module=ota_engine | ota_engine_utils -c update ... | Update the OTA engine itself |
fallbackPackage | ota_module_utils -c fallbackpackage ... | Roll back the full package |
fallbackModule | ota_module_utils -c fallbackmodule ... | Roll back a module |
removeModule | ota_module_utils -c removemodule ... | Remove a module (except ota_engine) |
[Table 11] OTA commands and the helpers they run
For example, updatePackage runs ota_utils with a command line like this:
/unitree/ota/engine/bin/ota_utils -c updatepackage -v <version> -p <package> -u <url> -i <sign> -t <token> -g <stage> -r
Plain Text
복사
ota_utils parses these arguments with the getopt string "c:v:m:p:u:i:t:s:g:rh" and branches to a per-command handler (updatepackage → OTAUtils::DoUpdatePackage, and so on).
Option | Meaning |
-c | command (updatepackage / updatemodule / fallbackmodule / removemodule) |
-v | Target version |
-m | Module name |
-p | Package file name |
-u | Package URL |
-i | sign (expected MD5) |
-g | Update stage (download / install) |
-t | OTA token |
-r | Enable result reporting |
[Table 12] ota_utils CLI options
Argument parsing and command dispatch are both in sub_923B28, which is called right after main.
sub_925AB8(v23, &unk_D88218, "/etc/ota_engine.json");
sub_925AB8(v24, &unk_D88218, "/etc/ota_engine_log_conf.json");
...
strcpy(shortopts, "c:v:m:p:u:i:t:s:g:rh");
while ( 1 ) {
v22 = getopt(a1, a2, shortopts);
...
}
...
if (sub_925B44(v25, "updatepackage")) v21 = sub_9266DC(v33, v26, v28, v29, v30, v31, v32); // token, ver, pkg, url, sign, stage
else if (sub_925B44(v25, "updatemodule")) v21 = sub_92691C(v33, v26, v27, v28, v29, v30, v31);
else if (sub_925B44(v25, "fallbackmodule")) v21 = sub_926B38(v33, v26, v27, v28);
else v21 = sub_926DA8(v33, v26, v27); // removemodule
C
복사
If the command is none of the four, an earlier check prints command is not supported. and exits. sub_9266DC, the handler for updatepackage, is OTAUtils::DoUpdatePackage. It builds an OTAUpdateTask from the parsed arguments and drives the install state machine.
4.2.3. Download Stage / Install Stage
A full package update does not run end to end from a single command. The cloud sends the same cmd value, updatePackage, as separate messages per stage (config value TowStageUpdateEnable:1). The order is pre-verification request → download stage → install stage. Below is a download trigger message found in the logs of a real unit.
{
"cmd": "updatePackage",
"msgId": "2390f5ad43534a57a53d57e993c11fcb",
"package": {
"name": "package_1.1.11.5_GO2_Edu_Max_Pro_X_1761128358872.upk",
"sign": "716f34ec0391e5a461b26df2fa11eb3e",
"size": 341397620,
"url": "https://firmware-cdn.unitree.com/firmware/release/package_1.1.11.5_GO2_Edu_Max_Pro_X_1761128358872.upk",
"version": "1.1.11"
},
"stage": "download",
"targetPackageVersion": "1.1.2"
}
JSON
복사
cmd is the first-level action branch and stage the second-level stage branch. The package.url to download is not hardcoded on the robot; the message specifies it. After the download finishes, the cloud polls progress roughly every 10 seconds and then sends the install-stage message separately.
sequenceDiagram
autonumber
participant C as Unitree cloud (MQTT 17883)
participant B as ota_boxed (sub cmd/SN)
participant U as ota_utils / helpers
participant CDN as firmware-cdn.unitree.com
B->>C: subscribe cmd/SN + report version
C-->>B: verifyPackageRequest
B->>U: ota_verify_utils -c verifypackage (MD5)
U-->>B: result
Note over C,B: download stage
C-->>B: updatePackage {package{url, sign, size, version}, stage: download}
B->>U: ota_utils -c updatepackage -u url -i md5 -g download -r
U->>CDN: HTTPS GET url (Range 256KB chunks)
CDN-->>U: .upk (≈341MB)
U->>U: MD5(payload) == sign
loop every ~10s
C-->>B: getOtaRunningTaskIdRequest
B-->>C: token (running)
end
Note over C,B: install stage (separate message)
C-->>B: updatePackage {..., stage: install}
B->>U: ota_utils -c updatepackage -g install -r
U->>U: MD5 re-check → unpack → module backup → install cmds
U-->>B: done
B-->>C: final reportMermaid
복사
In this flow the app only issues instructions and shows progress. The app’s HardwareUpdate2Activity screen shows a two-step progress bar for download and install, which maps directly onto the robot-side stage split. The app tells the robot to download and install through the firmware API on robot-api (CN) or global-robot-api (INTL), and queries the running task about every 10 seconds. Despite its name, the firmware/package/download API doesn’t fetch the file either; it tells the robot to start downloading and gets an updateId back. The package binary never passes through the app; it moves only between the cloud and the robot.
4.3. Package Handling and Verification
4.3.1. Where the Download URL Comes From, and TLS Handling
The download URL is set by package.url in the MQTT message. The firmware distribution infrastructure we actually observed is Unitree’s own CDN (firmware-cdn.unitree.com, unitree-firmware-cdn.unitree.com) and Alibaba Cloud OSS (unitree-firmware.oss-accelerate.aliyuncs.com). The command channel (MQTT) and the data channel (HTTPS download) are separate, and the cloud connection runs over both WiFi (wlan0) and 4G (wwan0).
flowchart LR
subgraph Robot["Go2"]
OTA["ota_boxed (sub cmd/SN)"]
ENG["ota_utils / ota_download_utils"]
NET["wlan0 (WiFi) + wwan0 (4G)"]
end
subgraph UnitreeCloud["Unitree cloud"]
MQ["robot-mqtt:17883 / global-robot-mqtt:17883<br/>(mqtts, mTLS)"]
FW["firmware-cdn.unitree.com"]
end
subgraph Aliyun["Alibaba Cloud"]
OSS["unitree-firmware.oss-accelerate.aliyuncs.com"]
end
OTA <-->|"command / report (mTLS)"| MQ
ENG -->|"HTTPS download"| FW
ENG -->|"HTTPS download"| OSS
ENG --> OTA
NET -. "4G always-on" .-> UnitreeCloudMermaid
복사
The command channel (MQTT) connects with both server certificate chain verification (enableServerCertAuth) and hostname verification (verify) turned on. That is the ssl option enable_server_cert_auth:1, verify:1 line in the MQTT connection log.
For the data channel (HTTPS), ota_utils runs ota_download_utils -u <url> -o <packageFile> when needed, and the CurlDownloader (libcurl) inside it does the actual transfer. Downloader settings are read from /unitree/ota/engine/etc/ota_download_utils.json (DownloadDir=/unitree/ota/download, LimitRate=0.65).
The download first checks the remote file info (HTTP 200, file time, size) and then repeatedly requests 256KB (0x40000) chunks with CURLOPT_RANGE, making it a resumable download. The point is to pull firmware hundreds of MB in size (about 341MB measured) over an unreliable network without starting over.
Here is a reconstruction of the download loop in CurlDownloader::Download. It is pseudocode to explain the flow, not the original code.
// CurlDownloader::Download download loop (reconstructed pseudocode)
LoadInfo(); // load previous download info
GetRemoteFileInfo(); // check HTTP 200, filetime, size
current = downloaded_size; // resume position
while (current < remote_size) {
end = min(current + 0x40000, remote_size);
CURLOPT_RANGE = "%ld-%ld" (current, end - 1);
curl_easy_perform(...);
WriteCallback -> Update -> UpdateData; // flush to temp file once the buffer exceeds 0x1fffff
current = end;
}
Complete(); // if sizes match, rename temp → output and delete info
C
복사
If earlier download info exists, CheckInfo compares the remote file’s time and size against the stored values. If either has changed, or the temp file is larger than the remote size, it is wiped with Cleanup and the download starts from scratch.
4.3.2. UPK Integrity Check (MD5 Checksum)
In the OTA flow, package integrity is checked by comparing MD5 checksums. The protocol’s sign field carries the expected MD5 value, and two functions handle verification.
Function | Role |
VerifyPackage | Checks the file size condition, then calls VerifyFile |
VerifyFile | Checks the file exists → computes MD5 → compares with sign |
[Table 13] Package verification functions
# VerifyPackage logic summary
if PackageFileSize > 0:
if GetFileSize(package) != PackageFileSize:
return 40
if sign is not empty:
if VerifyFile(package, sign) == false:
return 40
return 0
Python
복사
VerifyFile runs ExistFile → MD5Checksum → Compare(computed, sign, false) and lowercases both sides before comparing, so the check is case-insensitive. When VerifyFile returns false, VerifyPackage turns that into result code 40; on a match it returns 0. 45 (file not found) is what ota_verify_utils reports for standalone verification requests.
Verification happens at two points.
•
Download stage: DoDownload first uses VerifyFile to see if the file is already on disk, and skips the download if the MD5 matches. If the file is missing or different, it downloads and then verifies again with VerifyPackage.
•
Install stage: DoUnpack runs VerifyPackage again before unpacking, and on failure returns 40 along with unpack verify error.
So MD5 is checked twice: once right after download and again right before install.
4.3.3.Running Install Commands
The install stage runs as a state machine driven by OTAUpdateTask::Execute, with the task state stepping from 3 up to 9.
flowchart TB
subgraph R1["install stage (state 3 ~ 6)"]
direction LR
ST(("start")) --> U["Unpack<br/>state 3"] --> PU["PreUpdate<br/>state 4"] --> CP["CmdPre<br/>state 5"] --> UP["Update<br/>state 6"]
U -.->|"fail"| FAIL(("return<br/>error"))
PU -.->|"fail"| FAIL
UP -.->|"fail"| FAIL
end
subgraph R2["install stage (state 7 ~ 9)"]
direction LR
UK["UpdatePkg<br/>state 7"] --> CO["CmdPost<br/>state 8"] --> CL["Cleanup<br/>state 9"] --> DONE(("install stage<br/>done"))
end
R1 --> R2Mermaid
복사
State | Step |
3 | Unpack the package (UPK → TAR extraction) |
4 | pre-update: parse <unpackDir>/package.json, build the module list |
5 | Run CmdPre |
6 | Update modules |
7 | Update package |
8 | Run CmdPost |
9 | Cleanup |
[Table 14] Install steps by state
State transitions work by calling each step function in turn, logging on success, and bumping the state value.
sub_939350(a1+40, 3);
if (state==3) { sub_96A70C(a1); ... "unpack package success." ; state=4 } // Unpack
if (state==4) { sub_96A744(a1); ... "pre update success." ; state=5 } // pre-update (manifest parsing)
if (state==5) { sub_96A77C(a1); ... "execute cmdpre success." ; state=6 } // CmdPre
if (state==6) { sub_96A7C0(a1); ... "update success." ; state=7 } // update (modules)
if (state==7) { sub_96A8B8(a1); ... "update pkg success." ; state=8 } // update-pkg
if (state==8) { sub_96A8F0(a1); ... "execute cmdpost success." ; state=9 } // CmdPost
if (state==9) { sub_96A934(a1); ... "cleanup success." } // cleanup
... "install stage done. token:"
C
복사
The unpack in state 3 goes through several layers of parsing. The UPK header parser (Package::UnpackFile) reads the name, size, seed, and sign from the 112-byte header; the encrypted container is decrypted by the Tea::DecodeFile family; and Tar::ExtractAll uses libtar to extract it onto the filesystem. In state 4, the package.json manifest in the extracted directory is read and turned into a module list (PackageModuleDesc: Name, Version, File, Sign, etc.) and a list of commands to run (CmdDesc: Cmd, ExpectCode, IgnoreUnexpected, Delay).
The last step of installation, the one that actually changes the system, is running the install commands written in the manifest. Module handling is done by ota_module_utils, which runs the Cmd strings in module.json’s Install.CmdPreList/CmdPostList through OTATask::ExecuteCmd. One example is a service reload command such as /unitree/sbin/mscli reloadservice basic_service.
After replacing module files, OTA reloads or removes services through mscli, the local RPC client of master_service (analyzed in Part 3), so this step connects back to Part 3. Before an installed module actually runs, it goes through the path ota_boxed → ota_utils → ota_module_utils → mscli → master_service.
4.4. Data Flow Analysis
4.4.1. Entry Points
The OTA component differs from the others in that the robot opens no listening port for it. External input comes in over outbound connections the robot opens first.
Entry point | Protocol | Process | Purpose |
MQTT cmd/<SN> | MQTTS (TCP 17883, mTLS) | ota_boxed | Receive OTA commands, report results |
Package download response | HTTPS | ota_download_utils (CurlDownloader) | Receive the UPK file |
ota_boxed.sock | Unix domain socket | ota_boxed / helpers | Local IPC, progress reporting |
Config files | File | ota_boxed / ota_utils | mqtt_client_conf.json, ota_engine.json, ota_download_utils.json |
[Table 15] OTA entry points
4.4.2. DFD
---
config:
layout: dagre
flowchart:
nodeSpacing: 18
rankSpacing: 28
---
flowchart TB
subgraph INET["Trust boundary: Internet"]
MQ["Unitree MQTT broker<br/>robot-mqtt:17883"]
CDN["firmware CDN / OSS"]
end
subgraph ROBOT["Trust boundary: Go2 robot host"]
D1[("D1 mqtt_client_conf.json<br/>client certificate")]
P1("1.0 OTA daemon<br/>ota_boxed")
P2("2.0 package engine<br/>ota_utils")
P3("3.0 downloader<br/>ota_download_utils")
D2[("D2 /unitree/ota/download<br/>*.upk")]
D3[("D3 unpack dir<br/>package.json / module.json")]
P4("4.0 module installer<br/>ota_module_utils")
P5("5.0 service manager<br/>master_service (mscli)")
D4[("D4 /unitree/module/*")]
end
MQ <-->|"cmd/SN JSON / report (mTLS)"| P1
D1 -->|"ServerUri / cert"| P1
P1 -->|"helper cmdline"| P2
P2 -->|"-u url -o file"| P3
CDN -->|"UPK (HTTPS)"| P3
P3 -->|"write"| D2
P2 <-->|"MD5 verify, unpack"| D2
P2 -->|"extract"| D3
D3 -->|"module list / Cmd"| P4
P4 -->|"replace files"| D4
P4 -->|"mscli reload / remove"| P5
P5 -.->|"start / monitor"| D4Mermaid
복사
Walking through the OTA DFD:
1.
The OTA path starts on the Internet and reaches all the way to the robot’s module directory and master_service. Where the other components exchange data with running services, OTA changes the services themselves.
2.
Commands and data arrive on different channels. Commands come over the mTLS-protected MQTT channel, packages come as an HTTPS download from whatever URL the message names, and the two are tied together by the sign (MD5) value.
3.
Processing is spread across several processes. It crosses process boundaries in the order daemon (ota_boxed) → engine (ota_utils) → downloader/module utilities → mscli, and each step hands information to the next through command-line arguments and manifest files.
5. Data Flow from External Components
5.1. Component Summary
The four external components analyzed across Parts 3 and 4, in one table:
Component | External channel | Owning process | Internal target | Protection |
BLE | Bluetooth LE (GATT 0xFFE2/0xFFE1) | btgatt-server | hostapd / wpa_supplicant (WiFi config) | App-layer AES + handshake session auth |
RTSP | WiFi/LAN TCP 8551, wired multicast UDP 1720 | go2_videohub_rtsp_server, videohub | Camera (/dev/video0) | Handled at the GStreamer layer |
WebRTC/DDS | WiFi/LAN TCP 80/9991/10001 + DataChannel, cloud TURN | xfkTon, unitreeWebRTCClientMaster | DDS domain (rt/* topics) | Signaling RSA + AES (per-device key), 31-topic publish whitelist |
OTA | Internet MQTTS 17883 + HTTPS download | ota_boxed, ota_utils suite | Module files, master_service (mscli) | MQTT mTLS + server certificate verification, MD5 checksum |
[Table 16] Summary of external components
The four components line up along how the robot is actually used. BLE creates the WiFi network, RTSP and WebRTC carry video and control over that network, and OTA replaces these services from the cloud.
5.2. Integrated DFD
Merging the four component DFDs gives the diagram below. There are five trust boundaries: BLE radio range, WiFi LAN, Internet, robot host, and the internal wired network (DDS domain).
---
config:
layout: elk
---
flowchart TB
subgraph RADIO["Trust boundary: BLE radio range"]
APPB["Unitree app (BLE)"]
end
subgraph INET["Trust boundary: Internet"]
APPR["Unitree app (remote)"]
CLOUD["Unitree cloud<br/>robot-api / TURN / MQTT"]
CDN["firmware CDN / OSS"]
end
subgraph WIFI["Trust boundary: WiFi LAN (wlan0 STA / wlan1 AP)"]
APPL["Unitree app (local)"]
LANC["LAN client"]
end
subgraph ROBOT["Trust boundary: Go2 robot host"]
BLE("BLE<br/>btgatt-server")
NET("hostapd /<br/>wpa_supplicant")
OTA("OTA<br/>ota_boxed / ota_utils")
MS("master_service")
DISC("discovery<br/>multicast_responder.py")
XFK("signaling<br/>xfkTon :80 / 9991 / 10001")
BR("WebRTC bridge<br/>unitreeWebRTCClientMaster")
RTSP("RTSP<br/>go2_videohub_rtsp_server :8551")
CAM["Camera<br/>/dev/video0"]
VH("videohub")
end
subgraph WIRED["Trust boundary: internal wired network (eth0, DDS domain)"]
DDS["DDS bus (CycloneDDS, UDP 7400)<br/>rt/api/* services"]
MCG["Multicast<br/>230.1.1.1:1720"]
end
APPB <-->|"GATT 0xFFE2 / 0xFFE1"| BLE
BLE -->|"mode / SSID / PW"| NET
APPR <-->|"signaling / TURN"| CLOUD
CLOUD -->|"MQTT cmd/SN (mTLS)"| OTA
CDN -->|"UPK (HTTPS)"| OTA
OTA -->|"mscli reload / remove"| MS
CLOUD <-->|"relay"| BR
APPL <-->|"discovery"| DISC
APPL -->|"con_notify / con_ing"| XFK
APPL <-->|"DataChannel"| BR
XFK <-->|"offer / answer"| BR
LANC <-->|"RTSP / RTP H.264"| RTSP
MCG -->|"RTP H.264"| RTSP
CAM --> VH
VH -->|"RTP H.264"| MCG
VH -->|"rt/frontvideostream"| DDS
BR <-->|"dds_write / dds_take"| DDS
MS -.->|"start / monitor"| DDSMermaid
복사
Trust boundary | Entering component | Entry points |
BLE radio range → robot | BLE | GATT 0xFFE2 write, advertising |
WiFi LAN → robot | RTSP, WebRTC/DDS | TCP 8551, TCP 80/9991/10001, DataChannel, UDP 10131 |
Internet → robot | WebRTC/DDS, OTA | Cloud TURN relay, MQTT cmd/<SN>, HTTPS download |
Robot ︎ internal wired network | RTSP, WebRTC/DDS | UDP 230.1.1.1:1720, DDS UDP 7400 |
[Table 17] Entry points by trust boundary
Looking across components on the integrated DFD, a few things stand out.
1.
Each component has exactly one path by which external input reaches internal services. BLE leads to the WiFi configuration, RTSP to the camera feed, WebRTC to the DDS bus, and OTA to master_service and the module directory.
2.
The internal wired network (eth0) serves as the internal bus. RTSP multicast and DDS discovery both run on it, and input from WiFi or the Internet only reaches it by way of each component’s processes.
3.
The same data flows along more than one path. Camera video goes out two ways, RTSP (LAN) and WebRTC (remote), and the per-device AES key is created during BLE binding and then used in WebRTC signaling.
4.
master_service sits behind every path. It starts and supervises the BLE, RTSP, and WebRTC processes, and OTA tells it to swap services via mscli.
6. Conclusion
Across Parts 3 and 4 we answered three questions: who starts the robot’s services and in what order, which of them are exposed to the outside, and along what paths external input travels into the robot.
•
BLE: btgatt-server, built on the BlueZ example, takes commands on 0xFFE2 (Write) and responds on 0xFFE1 (Notify). Commands pass through a branch added to BlueZ’s write_cb and are decrypted and parsed in receive_manager. After a handshake authenticates the session, it receives the WiFi mode and SSID/password and reconfigures hostapd or wpa_supplicant. The details of the application-layer encryption and handshake depend on the network_manager module version.
•
WebRTC/DDS: Signaling (xfkTon, TCP 9991) and the WebRTC endpoint/bridge (unitreeWebRTCClientMaster) are separate processes that exchange offers and answers over DDS topics. JSON arriving on the DataChannel is published to DDS through a 31-topic publish whitelist, and responses come back from 44 subscribed topics to the requesting session only. The internal DDS bus is CycloneDDS multicast on eth0, with the Authentication plugin enabled.
•
OTA: ota_boxed receives commands on the cloud MQTT topic cmd/<SN> and runs the ota_utils suite. Updates are split into a download stage and an install stage; packages are checked twice with an MD5 checksum before unpacking, and master_service’s services are replaced through the manifest commands and mscli.
Finally, we tied all four components, including RTSP from Part 3, into an integrated DFD that shows on a single page the five trust boundaries (BLE radio range, WiFi LAN, Internet, robot host, internal wired network) and the entry points between them. You can now follow, in one diagram, which processes each of Go2’s external interfaces passes through on its way to the WiFi configuration, the camera, the DDS bus, and master_service.
7. References
1.
BlueZ Project, “BlueZ — Official Linux Bluetooth protocol stack”. https://git.kernel.org/pub/scm/bluetooth/bluez.git/
2.
Eclipse Foundation, “Eclipse Cyclone DDS Documentation”. https://cyclonedds.io/docs/
3.
Amazon Web Services, “Amazon Kinesis Video Streams with WebRTC SDK in C”. https://github.com/awslabs/amazon-kinesis-video-streams-webrtc-sdk-c








