Protocol reference for Ninebot and Segway-Ninebot electric unicycles. Implementation target: Kotlin parser and command builder in EUC Planet.
This document describes the wire format only. No code is reproduced from upstream projects. See "Attribution" at the end of the file for sources.
Two protocol families are covered here:
- Ninebot Z (Part A) -- modern, encrypted. Z6, Z10, Segway-rebranded Z-series, plus some E+ / Mini Plus firmwares that ship the new stack.
- Ninebot legacy (Part B) -- older, unencrypted. One E, One E+, One S2, Mini, Mini Pro.
They share neither the GATT profile nor the framing. The adapter must detect which family the wheel speaks and dispatch accordingly.
Status: research-grade. The Ninebot Z encryption section is complete enough to implement; live-data offsets between Z6 and Z10 may differ in detail. Items marked "Unconfirmed" should be validated against a labelled BLE capture before being relied on in production code.
Ninebot Z uses a Nordic-UART-style profile.
| Role | UUID |
|---|---|
| Service | 6e400001-b5a3-f393-e0a9-e50e24dcca9e |
| Notify (wheel -> phone) | 6e400003-b5a3-f393-e0a9-e50e24dcca9e |
| Write (phone -> wheel) | 6e400002-b5a3-f393-e0a9-e50e24dcca9e |
| CCCD descriptor | 00002902-0000-1000-8000-00805f9b34fb |
Notes:
- Distinct write and notify characteristics, unlike legacy Ninebot.
- Subscribe to notifications on
...0003...via the standard CCCD before sending any commands. - Default ATT MTU (payload 20 bytes) is sufficient. Settings fit in one notification; live-data replies span two.
Advertised name formats observed in the wild:
| Pattern | Example | Notes |
|---|---|---|
Ninebot Z<...> |
Ninebot Z10, Ninebot Z6 |
Most common |
ZN<serial> |
ZN1234567 |
Some Z6 firmwares strip the brand prefix |
Segway Z<...> |
Segway Z10 |
Region-specific re-branding |
MiniPLUS<serial> |
MiniPLUS123456 |
Mini Plus uses Z-protocol |
Detection strategy:
- Probe for the Nordic-UART service
6e400001-...; its presence is the strongest single signal of a Z-protocol wheel. - Confirm by attempting the BLE-version handshake (section 5). Reply
from src
0x14, param0x68confirms Z protocol. - Name alone is not reliable: some Mini/S2 firmwares speak Z despite advertising legacy-style names.
offset 0 1 2 3 4 5 6 N+5 N+6 N+7 N+8
5A A5 <len> <src><dst><cmd><param> .. <crc_lo><crc_hi>
|<--- N data bytes --->|
| Offset | Field | Meaning |
|---|---|---|
| 0 | u8 magic[0] | 0x5A |
| 1 | u8 magic[1] | 0xA5 |
| 2 | u8 length | Number of data bytes after the param byte (N). Excludes header, magic, CRC |
| 3 | u8 src | Source address (see section 4) |
| 4 | u8 dst | Destination address |
| 5 | u8 cmd | Command opcode (Read / Write / Get / GetKey, see section 4) |
| 6 | u8 param | Parameter ID (see section 7) |
| 7..6+N | N bytes | Command-specific data payload |
| 7+N | u8 crc_lo | CRC byte 0, little-endian |
| 8+N | u8 crc_hi | CRC byte 1 |
Total on-wire frame size: N + 9 bytes.
Byte order:
- All multi-byte fields use little-endian unless specified otherwise.
- 16-bit CRC stored as u16 LE.
- 32-bit telemetry fields stored as u32 LE.
Checksum:
sum = 0
for byte in [length, src, dst, cmd, param, ...data]: # N+5 bytes
sum = (sum + byte) & 0xFFFF
crc = sum XOR 0xFFFF # i.e. ones complement
Equivalent statement: CRC is the bitwise NOT of the 16-bit running sum of every byte in the frame except the two magic bytes and the CRC bytes themselves. Stored little-endian.
Important: the CRC is computed over the plaintext payload. After encryption (section 6), the CRC bytes themselves are also XOR'd along with the rest. The receiver must therefore decrypt first, then verify.
| Name | Value | Direction |
|---|---|---|
| BMS1 | 0x11 |
Wheel battery 1 |
| BMS2 | 0x12 |
Wheel battery 2 |
| Controller | 0x14 |
Main MCU |
| KeyGenerator | 0x16 |
Encryption-key oracle, replies once during handshake |
| App | 0x3E |
This phone |
The phone uses 0x3E as its source address on every outbound frame.
dst selects which on-wheel subsystem the frame is addressed to.
| Name | Value | Use |
|---|---|---|
| Read | 0x01 |
Phone reads a parameter |
| Write | 0x03 |
Phone writes a parameter |
| Get | 0x04 |
Phone subscribes to a parameter (live data) |
| GetKey | 0x5B |
Phone requests session encryption key from the KeyGenerator |
Replies from the wheel reuse the same opcode that triggered them, with
src and dst swapped.
After the GATT connection and CCCD subscription are up, the adapter walks a fixed sequence of read requests. Each step waits for the matching reply before advancing.
| State | Action | Frame |
|---|---|---|
| 0 | getBleVersion | Read 0x68 from Controller 0x14, data {0x02} |
| 1 | getKey | GetKey from KeyGenerator 0x16, param 0x00, data empty |
| 2 | getSerialNumber | Read 0x10 from Controller |
| 3 | getFirmwareVersion | Read 0x1A from Controller |
| 4 | getParams1 | Read 0x70/0x72/0x74/0x7C..0x7F (lock, alarms) |
| 5 | getParams2 | Read 0xC6..0xCE, 0xD2, 0xD3 (LED, pedal, drive flags) |
| 6 | getParams3 | Read 0xF5 (speaker volume) |
| 7..9 | BMS1 dump | Conditional on bmsMode flag |
| 10..12 | BMS2 dump | Conditional on bmsMode flag |
| 13 | live-data loop | Repeated Get of 0xB0 |
Once state 13 is reached, the adapter loops on Get-0xB0 (live data)
at the desired refresh rate. Settings reads/writes can be interleaved.
Ninebot Z uses a stream cipher built from a 16-byte session key exchanged once at connect. Every frame after the key exchange is encrypted byte-for-byte with this fixed key. There is no IV, no nonce, no key rotation, and no authentication tag. It is XOR with a repeated key.
After the BLE-version handshake (state 0), the phone sends:
plaintext frame:
5A A5 00 3E 16 5B 00 <crc_lo> <crc_hi>
len = 0x00 (no data bytes)
src = 0x3E (App)
dst = 0x16 (KeyGenerator)
cmd = 0x5B (GetKey)
param = 0x00 (GetKey)
crc = ones_complement(sum(0x00, 0x3E, 0x16, 0x5B, 0x00))
= ~(0x00 + 0x3E + 0x16 + 0x5B + 0x00) & 0xFFFF
= ~0x00AF
= 0xFF50 -> on the wire 0x50 0xFF
This first GetKey frame is sent in plaintext (no encryption applied).
The wheel responds from the KeyGenerator address 0x16 with a frame
whose 16 data bytes are the session key. The data field is exactly
16 bytes of pseudo-random material; this is the gamma keystream.
The reply itself is also plaintext (the wheel cannot encrypt a response to a key request before the phone has the key; the phone needs plaintext to read it).
After the key exchange, both the phone and the wheel apply the same XOR transform to every frame, in both directions. The cipher operates on the buffer that starts immediately after the two magic bytes:
encrypted = frame[2..end] # length, addresses, cmd, param, data, CRC
encrypted[0] is left UNTOUCHED # the length byte stays in plaintext
for j in 1 .. encrypted.length - 1:
encrypted[j] ^= gamma[(j - 1) % 16]
Equivalent statement, expressed against the on-wire frame indices:
| On-wire index | Field | Encrypted? | Keystream byte |
|---|---|---|---|
| 0 | magic 0x5A |
No | -- |
| 1 | magic 0xA5 |
No | -- |
| 2 | length | No | -- |
| 3 | src | Yes | gamma[0] |
| 4 | dst | Yes | gamma[1] |
| 5 | cmd | Yes | gamma[2] |
| 6 | param | Yes | gamma[3] |
| 7 | data[0] | Yes | gamma[4] |
| ... | ... | Yes | gamma[(i-3) mod 16] for on-wire index i |
| 7+N | crc_lo | Yes | gamma[(N+4) mod 16] |
| 8+N | crc_hi | Yes | gamma[(N+5) mod 16] |
So two header bytes (magic) and the length byte are plaintext on the wire. Everything else is XOR'd, including the CRC. Decryption on the receive side is the same operation (XOR is involutive); decrypt first, then verify the CRC.
- Key length: 16 bytes (128 bits).
- Cipher: XOR with repeating fixed key, period 16. Not AES, not RC4.
- IV / nonce: none. Keystream restarts at offset 0 for every frame.
- Key rotation: none observed. Key persists for the BLE session and is replaced only on disconnect / reconnect.
- Authentication: none. The CRC is a sum-checksum, not a MAC.
Confidentiality-only obfuscation. We need to interoperate with it; we do not need to defend it.
| Param | Hex | Direction | Notes |
|---|---|---|---|
| GetKey | 0x00 |
RW | Used only with cmd 0x5B |
| SerialNumber | 0x10 |
R | ASCII serial, length varies |
| Firmware | 0x1A |
R | Version word |
| BleVersion | 0x68 |
R | First handshake step |
| LockMode | 0x70 |
RW | 0x01 = lock engaged, 0x00 = unlocked |
| LimitedMode | 0x72 |
RW | Speed-limit feature on/off |
| LimitModeSpeed | 0x74 |
RW | u16 LE, value = km/h * 100 |
| Alarms | 0x7C |
RW | Bitfield, which alarm slots are armed |
| Alarm1Speed | 0x7D |
RW | u16 LE, km/h * 100 |
| Alarm2Speed | 0x7E |
RW | u16 LE, km/h * 100 |
| Alarm3Speed | 0x7F |
RW | u16 LE, km/h * 100 |
| LiveData | 0xB0 |
R / Get | Telemetry, see section 8 |
| LedMode | 0xC6 |
RW | u8, mode 0..7 |
| LedColor1 | 0xC8 |
RW | RGB triple |
| LedColor2 | 0xCA |
RW | RGB triple |
| LedColor3 | 0xCC |
RW | RGB triple |
| LedColor4 | 0xCE |
RW | RGB triple |
| PedalSensitivity | 0xD2 |
RW | u16 LE |
| DriveFlags | 0xD3 |
RW | Bitfield, see below |
| SpeakerVolume | 0xF5 |
RW | u8, written value is volume << 3 |
DriveFlags (0xD3) bit layout (Unconfirmed):
| Bit | Meaning |
|---|---|
| 0 | DRL (daytime running light) |
| 1 | Tail light |
| 2 | Auxiliary light, role unclear |
| 3 | Strain gauge / pedal pressure sensor |
| 4 | Brake assist |
Sent by the wheel as a single response to Get 0xB0. The data payload
is at least 28 bytes; some firmwares append additional fields.
All offsets below are relative to the start of the data payload (i.e. on-wire offset 7 = data[0]).
| Offset | Field | Type | Scale | Units |
|---|---|---|---|---|
| 0..1 | reserved / status | u16 LE | -- | -- |
| 2..3 | reserved / state | u16 LE | -- | -- |
| 4..5 | reserved | u16 LE | -- | -- |
| 6..7 | reserved | u16 LE | -- | -- |
| 8..9 | battery_pct | u16 LE | direct | % (0..100) |
| 10..11 | speed | u16 LE | div 100 | km/h |
| 12..13 | reserved | u16 LE | -- | -- |
| 14..17 | total_distance | u32 LE | direct | meters |
| 18..19 | trip_distance | u16 LE | x10 | meters (multiply by 10) |
| 20..21 | reserved | u16 LE | -- | -- |
| 22..23 | temperature | i16 LE | div 10 | degrees C |
| 24..25 | voltage | u16 LE | div 100 | V (Unconfirmed: some firmwares use direct decivolts) |
| 26..27 | current | i16 LE | div 100 | A (positive = drive, negative = regen) |
Notes:
battery_pctis reported by the wheel; this is the wheel's own SOC estimate, not a calculation from voltage.speedis unsigned. Reverse motion is not separately encoded.temperatureis the controller MOSFET temperature. BMS pack temperatures come from BMS1/BMS2 frames separately.- For Z6 vs Z10 differences see section 9.
How live data is requested:
- Polled: the phone repeats
Get 0xB0at its desired cadence (typically 5..10 Hz). The wheel does not push unsolicited live data. - This is unlike KingSong (which pushes) and InMotion V14 (which mixes).
To write a setting, send a cmd=0x03 (Write) frame with the matching
param and a payload of the appropriate size.
| Setting | Frame template | Encoding |
|---|---|---|
| Lock | cmd=0x03 param=0x70 data={value, 0x00} |
value=1 lock, value=0 unlock |
| Speed-limit on/off | cmd=0x03 param=0x72 data={value, 0x00} |
value=1 enable |
| Speed-limit value | cmd=0x03 param=0x74 data={lo, hi} |
u16 LE, km/h * 100 |
| Alarms armed | cmd=0x03 param=0x7C data={lo, hi} |
bitfield: bit 0..2 enable alarm 1..3 |
| Alarm 1 speed | cmd=0x03 param=0x7D data={lo, hi} |
u16 LE, km/h * 100 |
| Alarm 2 speed | cmd=0x03 param=0x7E data={lo, hi} |
u16 LE, km/h * 100 |
| Alarm 3 speed | cmd=0x03 param=0x7F data={lo, hi} |
u16 LE, km/h * 100 |
| LED mode | cmd=0x03 param=0xC6 data={mode} |
u8, 0..7 |
| LED color slot 1..4 | cmd=0x03 param=0xC8/CA/CC/CE data={r,g,b} |
3 bytes RGB |
| Pedal sensitivity | cmd=0x03 param=0xD2 data={lo, hi} |
u16 LE |
| Drive flags | cmd=0x03 param=0xD3 data={flags, 0x00} |
bitfield, see section 7 |
| Speaker volume | cmd=0x03 param=0xF5 data={vol << 3, 0x00} |
u8, value left-shifted 3 |
To read the same setting back, replace cmd=0x03 with cmd=0x01
(Read) and send no data.
Ninebot Z exposes a software lock through param 0x70:
to lock: Write 0x70 with data {0x01, 0x00}
to unlock: Write 0x70 with data {0x00, 0x00}
to query: Read 0x70 (no data)
Notes:
- The official app gates this behind a numeric PIN enforced in the app, not the wheel. The wheel accepts a raw lock/unlock write from any party that completed the encrypted handshake; no on-wheel PIN.
- Practical security therefore comes from the encryption: a bystander who has not done the GetKey handshake cannot send a valid frame because the wheel will fail the CRC after XOR.
- Our app may surface a UX-level PIN to mirror the official app, but the wheel does not enforce it.
| Role | UUID |
|---|---|
| Service | 0000ffe0-0000-1000-8000-00805f9b34fb |
| Notify | 0000ffe1-0000-1000-8000-00805f9b34fb |
| Write | 0000ffe1-0000-1000-8000-00805f9b34fb |
| CCCD descriptor | 00002902-0000-1000-8000-00805f9b34fb |
Notes:
- Single characteristic
0xFFE1for both write and notify, like KingSong. Subscribe via CCCD then write to the same handle. - Default ATT MTU is sufficient.
| Pattern | Example | Notes |
|---|---|---|
Ninebot One E+ |
Ninebot One E+12345 |
Most common One E+ form |
Ninebot One E |
Ninebot One E1234 |
Original One E |
Ninebot S2 |
Ninebot S2 ABCDE |
One S2 |
Ninebot Mini ... |
Ninebot Mini Pro |
Mini / Mini Pro share legacy stack |
Detection strategy:
- Probe for service
0xFFE0containing characteristic0xFFE1. - If the device name starts with
Ninebot One,Ninebot S2, orNinebot Mini, treat as legacy. - After connect, send a Read of param
0x68(BleVersion). On legacy the reply is a string identifier ("S2", "Mini", "" / empty for "default" One E+ etc.) which selects the address subset (section 13) and live-data offset map (section 15).
offset 0 1 2 3 4 5 6 N+3 N+4 N+5
55 AA <len> <src><dst><param> .. <crc_lo><crc_hi>
|<--- N data bytes --->|
| Offset | Field | Meaning |
|---|---|---|
| 0 | u8 magic[0] | 0x55 |
| 1 | u8 magic[1] | 0xAA |
| 2 | u8 length | Number of data bytes after the param byte (N) |
| 3 | u8 src | Source address |
| 4 | u8 dst | Destination address |
| 5 | u8 param | Parameter ID (no separate cmd byte) |
| 6..5+N | N bytes | Command-specific data |
| 6+N | u8 crc_lo | u16 LE checksum |
| 7+N | u8 crc_hi |
Total on-wire frame size: N + 6 bytes (the unpacker uses len + 6
as the expected total once it has read length, accounting for two
magic + length + addresses + param + 2 CRC).
Differences vs Ninebot Z:
- Magic is
0x55 0xAA(Z is0x5A 0xA5). - There is no separate
cmdbyte. Read vs Write is encoded implicitly by whichparamis targeted and by the request/response direction. - No encryption. Legacy frames are plaintext on the wire.
- One fewer byte of overhead (no cmd byte).
Checksum: same algorithm as Z. Sum every byte from length through
the last data byte, then ones-complement (XOR with 0xFFFF), store
little-endian.
Addresses depend on the protocol-version string returned by the BleVersion read (section 12).
| Name | Default | S2 | Mini |
|---|---|---|---|
| Controller | 0x01 |
0x01 |
0x01 |
| KeyGenerator | 0x16 |
0x16 |
0x16 |
| App | 0x09 |
0x11 |
0x0A |
Parameter IDs in use:
| Param | Hex | Notes |
|---|---|---|
| SerialNumber | 0x10 |
ASCII |
| Firmware | 0x1A |
Version word |
| ActivationDate | 0x69 |
Read-only |
| BleVersion | 0x68 |
Returns variant string ("", "S2", "Mini") |
| LiveData | 0xB0 |
Telemetry, see section 15 |
Legacy exposes far fewer parameters than Z. There is no documented LED, no separate alarm slots, no speaker control through this stack.
Live-data payload is around 32 bytes. Offsets into the data payload:
| Offset | Field | Type | Scale | Units | Notes |
|---|---|---|---|---|---|
| 8..9 | battery_pct | u16 LE | direct | % | |
| 10..11 | speed (Mini) | i16 LE | div 10 | km/h | Mini only |
| 14..17 | total_distance | u32 LE | direct | meters | |
| 22..23 | temperature | i16 LE | div 10 | degrees C | |
| 24..25 | voltage | u16 LE | div 100 | V | Unconfirmed -- some captures suggest direct decivolts |
| 26..27 | current | i16 LE | div 100 | A | |
| 28..29 | speed (S2) | i16 LE | direct | km/h * 100 | S2 only |
Per-variant quirks:
- One E / One E+: speed at offset 10..11 like Mini, no
/10divisor. (Unconfirmed for E+ on newest firmware, which may use the S2 layout.) - S2: speed at offset 28..29, scale
/100. - Mini: speed at offset 10..11, scale
/10.
Polling cadence: a 5-step transmit loop every 25 ms (serial, firmware, live, live, live) gives roughly 24 Hz live-data poll. No unsolicited push from the wheel.
The legacy stack does not expose lock, alarms, light, volume, or LED through any documented parameter ID. Settings writes are not in scope for legacy support. If a user needs to change limits on a One E / S2, they must use the Ninebot mobile app over Bluetooth Classic / BLE side-channels not covered here.
Open question: there is forum chatter about a maintenance / "diag" parameter that exposes calibration; we have not confirmed it is reachable from the BLE stack and have no opcode for it. See section 19.
Not supported. Legacy Ninebot does not expose a software lock over this BLE stack. Hardware ignition / key-card flow on Mini Pro is out-of-band.
| Model | Family | Notes |
|---|---|---|
| Ninebot One E | Legacy | Default address subset (App 0x09). Speed at offset 10..11. No settings writes. |
| Ninebot One E+ | Legacy (mostly) | Same as One E. Some late firmwares may speak Z; detect by service UUID. |
| Ninebot One S2 | Legacy | App address 0x11. Speed at offset 28..29. |
| Ninebot Mini / Mini Pro | Legacy | App address 0x0A. Speed at offset 10..11, scale div 10. |
| Mini Plus | Z (Unconfirmed) | Some captures show Nordic-UART service; route to Z if 6e400001-... is present. |
| Ninebot Z6 | Z | Standard Z layout. |
| Ninebot Z10 | Z | Standard Z layout. Higher voltage (84 V class), so confirm voltage scale on a labelled capture. |
| Segway-Ninebot Z (rebranded) | Z | Same protocol, only the marketing name changes. |
For our WheelCapabilities struct, the two families map to:
| Field | Value | Notes |
|---|---|---|
hasHorn |
false | No documented horn opcode in the public protocol |
hasLight |
true | DRL via DriveFlags 0xD3 bit 0; LED mode via 0xC6 |
hasLock |
true | Param 0x70, see section 10 |
hasMaxSpeed |
true | LimitModeSpeed 0x74 (km/h * 100) |
hasAlarmSpeed |
true | Three independent slots 0x7D/0x7E/0x7F |
hasVolume |
true | SpeakerVolume 0xF5, value << 3 |
hasDRL |
true | DriveFlags 0xD3 bit 0 |
needsAuthForLock |
false | Wheel does not enforce a PIN; encryption is the gate. App-side PIN is UX, not security |
Bonus capabilities not in the standard struct: LED color customisation (four slots), tail light, brake assist toggle, pedal sensitivity tune.
| Field | Value | Notes |
|---|---|---|
hasHorn |
false | Not exposed |
hasLight |
false | Not exposed via this BLE stack |
hasLock |
false | Not exposed |
hasMaxSpeed |
false | No documented opcode |
hasAlarmSpeed |
false | No documented opcode |
hasVolume |
false | Not exposed |
hasDRL |
false | Not exposed |
needsAuthForLock |
n/a | Lock not supported |
Legacy support in EUC Planet should be treated as read-only telemetry: speed, voltage, current, battery, temperature, total distance. No settings writes.
Confirm against a labelled capture from a known-good wheel before trusting any of these:
- Z voltage scale. Existing references are inconsistent (
/100in some places, raw in others). Z10 is nominally 84 V; capture and fix. - Z battery field source. Whether offset 8..9 is the wheel's SOC or BMS pack-1 SOC. BMS dumps may contradict the LiveData value.
- DriveFlags bit map. Public references mark the
0xD3third bit with a "?". Toggle each bit on a Z10 to confirm meaning. - Z BleVersion reply format. Treated as opaque "ready" signal; the reply layout is not well documented but is not needed.
- Z key entropy. Whether the 16-byte key is per-session random or a function of the wheel serial. Two connect cycles would settle it.
- Legacy speed offset on E+. Late-firmware E+ captures sometimes show too-low values; the field may have moved to offset 28..29.
- Legacy current sign. Z uses positive = drive; legacy presumed the same but Unconfirmed.
- Mini Plus family. Reported on forums to advertise Nordic-UART, suggesting Z protocol, but no first-party capture confirms.
- Lock-while-moving. Wheel reaction to
0x70 -> 0x01while moving is undocumented. Official app refuses; we should too. - CRC endianness. All public references say u16 LE; a minority of forum posts say BE. Code is unambiguously LE, we go with LE.
- MTU for BMS frames. BMS dumps on Z (states 7..12) may exceed 20-byte ATT payload and require an MTU bump like KingSong's BMS.
Protocol references (upstream, GPLv3):
- Legacy (One E / E+ / S2 / Mini): https://github.com/Wheellog/wheellog.android/blob/master/app/src/main/java/com/cooper/wheellog/utils/NinebotAdapter.java
- Z encrypted variant: https://github.com/Wheellog/wheellog.android/blob/master/app/src/main/java/com/cooper/wheellog/utils/NinebotZAdapter.java
Cross-checked against forum write-ups on the Electric Unicycle Forum
(esp. Z10 BLE-stack discussions). All tables and offset maps are
re-described in this project's idiom (u8, u16 LE, i16 LE,
0xA5); no third-party GPL source is reproduced.
Ninebot Z encryption: GetKey opcode 0x5B, the 16-byte gamma key,
and the XOR loop data[j] ^= gamma[(j-1) % 16].