Skip to content

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:

for i in range(3, frame_length):
    frame[i] ^= encrypt_key[(i - 3) % len(encrypt_key)]

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 AA to 5A A5
  • Added BT_ID field at offset 3 (always 0x3E)
  • 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)

[...ciphertext...][0x00][0x00][CSUM_LO][CSUM_HI][0x00][0x00]
                   └───── 6-byte checksum block ──────┘

WiFi transport

Standard WiFi (0x5A 0xA5)

Identical frame format to Encryption2 (including header bytes), but encryption can be toggled:

  • Encryption off: frame is LENGTH + 9 bytes (no MAC/counter tail)
  • Encryption on: frame is LENGTH + 15 bytes

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)

def checksum_non_sn(payload: bytes) -> int:
    return (~sum(payload)) & 0xFFFF

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:

Before encryption:
5A A5 02 3E 01 01 10 02

After encryption:
5A A5 02 [encrypted: 3E 01 01 10 02 + padding] [4-byte MAC] [2-byte counter]
         └──────── ciphertext ────────────────┘

Total: LENGTH + 13 = 15 bytes