Frame Formats¶
The Ninebot protocol has evolved through four generations. The current production protocol is Encryption2 (0x5A 0xA5 header).
Protocol versions¶
| Version | Header | Encryption | Max payload | Status |
|---|---|---|---|---|
| Protocol 1 | 55 AA |
Optional XOR | 255 bytes | Legacy |
| Protocol 2 | 5A A5 |
Optional XOR | 255 bytes | Legacy |
| Encryption2 | 5A A5 |
AES-128 CTR | 255 bytes | Current |
| Encryption3 | 5A A5 |
AES-128 CTR (V3Auth) | 255 bytes | Newest BLE |
| WiFi v2 | 5A B5 |
AES-128 CTR | 65,535 bytes | WiFi only |
Version selection¶
Devices report their protocol version and encryption support in their configuration:
protocol=2 AND encrypt=2 → BleEncryption2Protocol2 (current standard)
protocol=2 AND encrypt=3 → BleEncryption3Protocol2 (newer variant)
protocol=2 AND encrypt=0 → BleProtocol2 (XOR only)
protocol=1 AND encrypt=2 → BleEncryption2Protocol1
protocol=1 AND encrypt=0 → BleProtocol1 (no encryption)
Version constants¶
VERSION_NO_CRYPTO = 0 → Protocol 1 or 2 (no AES)
VERSION_MI_CRYPTO = 1 → Reserved (Mi encryption, unused)
VERSION_CRYPTO = 2 → Encryption2+ (AES-128)
Protocol 1 — Legacy (0x55 0xAA)¶
The original Ninebot protocol, still used by very old devices.
Offset Size Field Description
────── ──── ───── ───────────
0 1 SYNC_1 0x55
1 1 SYNC_2 0xAA
2 1 LENGTH Payload length + 2 (includes ID and CMD)
3 1 TARGET_ID Destination board ID
4 1 CMD Command byte
5 1 INDEX Register address
6.. N PAYLOAD Command data (N = LENGTH - 2)
6+N 1 CRC_LO Checksum low byte
7+N 1 CRC_HI Checksum high byte
Total frame size: LENGTH + 6 bytes
Optional XOR encryption (Protocol 1)¶
When encryptKey is set (16 bytes), bytes 3 through end are XOR'd:
Key derivation for XOR mode:
# password = BT name string (6 chars), random_number = 16-byte from device
key = bytes([(random_number[i] ^ password[i % 6]) & 0xFF for i in range(16)])
Protocol 2 — Legacy (0x5A 0xA5)¶
An evolution of Protocol 1 with an added BT_ID field.
Offset Size Field Description
────── ──── ───── ───────────
0 1 SYNC_1 0x5A
1 1 SYNC_2 0xA5
2 1 LENGTH Payload length (data bytes only)
3 1 BT_ID Always 0x3E (62) — protocol identifier
4 1 TARGET_ID Destination board ID
5 1 CMD Command byte
6 1 INDEX Register address
7.. N PAYLOAD Command data (N = LENGTH)
7+N 1 CRC_LO Checksum low byte
8+N 1 CRC_HI Checksum high byte
Total frame size: LENGTH + 9 bytes
Differences from Protocol 1¶
- Header changed from
55 AAto5A A5 - Added
BT_IDfield at offset 3 (always0x3E) - LENGTH counts only payload bytes (not ID+CMD)
- Same XOR encryption scheme available
BT_ID validation¶
On receive, the BT_ID field is verified — frames with BT_ID != 0x3E are silently discarded.
Encryption2 — Current (0x5A 0xA5)¶
This is the current production protocol used by all modern Segway-Ninebot devices.
Same header as Protocol 2
Encryption2 uses the same 0x5A 0xA5 sync bytes as Protocol 2. The 0x5A 0xB5 header is used exclusively by the WiFi v2 transport, not by any BLE protocol. This has been confirmed by decompilation of BleEncryption2Protocol.java (field FRAME_BEGIN = -91, i.e. 0xA5).
Plaintext frame (before encryption)¶
Offset Size Field Description
────── ──── ───── ───────────
0 1 SYNC_1 0x5A
1 1 SYNC_2 0xA5
2 1 LENGTH Payload length (data bytes only)
3 1 BT_ID Always 0x3E (62)
4 1 TARGET_ID Destination board ID
5 1 CMD Command byte
6 1 INDEX Register address
7.. N PAYLOAD Command data (N = LENGTH)
Plaintext size: LENGTH + 7 bytes
Encrypted frame (on the wire)¶
Offset Size Field Description
────── ──── ───── ───────────
0 1 SYNC_1 0x5A ─┐
1 1 SYNC_2 0xA5 ├── NOT encrypted
2 1 LENGTH Payload length ─┘
3.. M CIPHERTEXT Encrypted(BT_ID ‖ TARGET_ID ‖ CMD ‖ INDEX ‖ PAYLOAD)
3+M 4 MAC Authentication tag (SN mode) or padding (non-SN)
7+M 2 COUNTER Packet counter (SN mode) or checksum (non-SN)
Encrypted size: LENGTH + 13 bytes (plaintext + 6 bytes overhead)
First 3 bytes are never encrypted
The sync bytes and length field are always transmitted in plaintext. Only the payload (starting from BT_ID) is encrypted.
SN-mode tail (counter > 0)¶
[...ciphertext...][MAC_0][MAC_1][MAC_2][MAC_3][CTR_HI][CTR_LO]
└──── 4-byte tag ────┘ └─ 2-byte counter ─┘
The counter is a big-endian unsigned 16-bit integer.
Non-SN-mode tail (counter == 0)¶
WiFi transport¶
Standard WiFi (0x5A 0xA5)¶
Identical frame format to Encryption2 (including header bytes), but encryption can be toggled:
- Encryption off: frame is
LENGTH + 9bytes (no MAC/counter tail) - Encryption on: frame is
LENGTH + 15bytes
WiFi v2 (extended length, 0x5A 0xB5)¶
The only protocol that uses 0xB5 as the second sync byte. Supports payloads > 255 bytes with a 2-byte length field and 2-byte register index:
Offset Size Field Description
────── ──── ───── ───────────
0 1 SYNC_1 0x5A
1 1 SYNC_2 0xB5
2 1 LEN_LO Payload length, low byte
3 1 LEN_HI Payload length, high byte
4 1 BT_ID 0x3E
5 1 TARGET_ID Destination board ID
6 1 CMD Command byte
7 1 INDEX_0 Index byte 0 ─┐
8 1 INDEX_1 Index byte 1 ─┘ 2-byte index
9.. N PAYLOAD Command data
Maximum payload: 65,535 bytes.
Checksums¶
15-bit inverted sum (Protocol 1 & 2)¶
def checksum(data: bytes, start: int, end: int) -> int:
total = sum(b & 0xFF for b in data[start:end])
return (~total) & 0x7FFF
| Protocol | Checksum covers |
|---|---|
| Protocol 1 | bytes [2 .. 5+N] (LENGTH through end of PAYLOAD) |
| Protocol 2 | bytes [2 .. 6+N] (LENGTH through end of PAYLOAD) |
16-bit inverted sum (Encryption2 non-SN mode)¶
Wire examples¶
Unencrypted read request (Protocol 2)¶
Reading 2 bytes from register 0x10 on the CTRL board (target_id = 0x01):
5A A5 02 3E 01 01 10 02 [CRC_LO] [CRC_HI]
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ └── DATA: bytes to read (2)
│ │ │ │ │ │ └── INDEX: register 0x10
│ │ │ │ │ └── CMD: 0x01 (read)
│ │ │ │ └── TARGET_ID: 0x01 (CTRL)
│ │ │ └── BT_ID: 0x3E
│ │ └── LENGTH: 2
│ └── SYNC_2: 0xA5
└── SYNC_1: 0x5A
Encrypted read request (Encryption2)¶
Same read command, but encrypted: