Python BLE Client¶
Reference implementation
A working pure-Python client for the Encryption2 protocol. Handshake and crypto verified byte-for-byte against a captured Segway E125S session (208/208 frames, 0 MAC failures). Handles both protocol generations: Gen2 (0x5A 0xA5 — E-series mopeds, default) and Gen3 (0x5A 0xB5 — kick scooters, P-series, GT-series).
The client is developed in its own repo: segway-ninebot-ble-cli.
Modules¶
| Module | Purpose |
|---|---|
nb_crypto.py |
AES-128 encryption/decryption, SHA-1 key derivation, java.util.Random-compatible password generation. Mirrors libnbcrypto.so. |
nb_protocol.py |
Gen2/Gen3 frame builder and parser, plus convenience builders for every command the CLI exposes. |
segway_ble_client.py |
BLE transport (via Bleak), 3-phase handshake, session persistence, CLI. |
Install¶
pip install bleak pycryptodome qrcode[pil] # qrcode optional; used by the QR helper
# Or with uv:
uv pip install bleak pycryptodome 'qrcode[pil]'
Requires Python 3.12+.
CLI¶
Global options¶
| Flag | Default | Purpose |
|---|---|---|
-v / --verbose |
off | Debug logging (hex dumps of every TX/RX frame). |
-n / --name NAME |
dummy | BLE device name — used as key material in PRE_COMM. Must match the name the vehicle advertises (usually equal to its serial number). |
-a / --address MAC |
— | BLE MAC address. If omitted, the client will scan with the -n filter and auto-pick if exactly one device matches. |
--ident HEX32 |
from session file | 32-char hex command ident (iOS UserDefaults {sn}_ident). Required for vehicle-action commands; cached to the session file on first use. |
Commands¶
| Command | What it does |
|---|---|
scan |
Discover nearby Segway/Ninebot BLE devices. |
handshake |
Run the 3-phase Encryption2 handshake and persist the session. No register traffic. |
read-speed |
Read the current speed limit (DIS:0x93). |
set-speed KMH |
Write a new speed limit, then read back to verify. Asks for confirmation. |
open-acc |
Power on the vehicle (Gen2: VCU 0x09 cmd 0x64 idx 0x4E; Gen3: BLE 0x04 cmd 0x03 idx 0x4E). Requires --ident. |
close-acc |
Power off. Requires --ident. |
open-trunk |
Open the seat / trunk compartment. Requires --ident. |
See Credential QR Setup for how to obtain the --ident value from an existing Segway/Ninebot install.
Worked examples¶
First connection¶
# Discover the vehicle
python segway_ble_client.py scan
# Complete the handshake (this issues SET_PWD and caches the password)
python segway_ble_client.py -n S1DCAxxxxxxxxx -a AA:BB:CC:DD:EE:FF handshake
If the vehicle has never been paired with this script before, the device LED will flash and you'll need to press the power button briefly to accept the new password. After that, the session lives in ~/.segway_session.json and subsequent runs reconnect silently.
Reconnect using credentials from an iOS backup¶
python segway_ble_client.py \
-n S1DCAxxxxxxxxx -a AA:BB:CC:DD:EE:FF \
--ident ffeeddccbbaa99887766554433221100 \
open-trunk
The ident is merged into the session file so you don't need to pass it again.
Read something¶
Write something¶
python segway_ble_client.py -a AA:BB:CC:DD:EE:FF set-speed 45
# → Current speed limit: 25 km/h
# → Setting to: 45 km/h
# → Proceed? [y/N] y
# → Done! Speed limit set to 45 km/h
# → Verified speed limit: 45 km/h
Power & seat control¶
python segway_ble_client.py -a AA:BB:CC:DD:EE:FF open-acc # power on
python segway_ble_client.py -a AA:BB:CC:DD:EE:FF close-acc # power off
python segway_ble_client.py -a AA:BB:CC:DD:EE:FF open-trunk # seat release
Writes without --ident silently fail
cmd=0x64 frames with a wrong payload still decrypt cleanly and MAC-verify — the vehicle just ignores them. If open-trunk looks like it succeeded but nothing happened, 99% of the time you're missing the right --ident. See Vehicle actions and Credential QR Setup.
Session file (~/.segway_session.json)¶
Per-MAC state cached between runs:
{
"AA:BB:CC:DD:EE:FF": {
"password": "00112233445566778899aabbccddeeff", // 16 bytes, {sn}_decrypt
"ident": "ffeeddccbbaa99887766554433221100", // 16 bytes, {sn}_ident (optional)
"sn": "53314443413030303030303030303030", // ASCII serial, hex-encoded
"bt_name": "S1DCAxxxxxxxxx",
"timestamp": 1700000000
}
}
passwordis written after a successfulhandshake/set-speed/ any command that completes AUTH.identappears only after the first command that used--ident(or a future run that passes--identexplicitly). Once cached, subsequent vehicle-action commands auto-pick it up.- Rotating credentials: pass a new
--ident/ re-pair to overwrite; or delete the entry to force full re-pairing.
Architecture¶
Crypto module (nb_crypto.py)¶
Stateful encryption context mirroring the native library's crypto_param_t:
class NbCrypto:
def __init__(self):
self.key1: bytes | None = None
self.key2: bytes | None = None
self.auth: bytes = b"\x00" * 16
self.counter: int = 0
def set_key(self, key1: bytes, key2: bytes | None): ...
def set_auth_param(self, auth: bytes): ...
def start_sn(self): ... # Enable SN mode (counter = 1)
def reset_sn(self): ... # Reset to non-SN mode (counter = 0)
def encrypt(self, plaintext: bytes) -> bytes: ...
def decrypt(self, cipherframe: bytes) -> tuple[bytes, int]: ...
Key derivation¶
def derive_key(key1: bytes, key2: bytes | None) -> bytes:
"""SHA-1(key1_pad16 || key2_pad16)[0:16] -> AES-128 key."""
k1 = (key1 + b"\x00" * 16)[:16]
k2 = (key2 + b"\x00" * 16)[:16] if key2 else b"\x00" * 16
return hashlib.sha1(k1 + k2).digest()[:16]
Password generation¶
Reimplements AbstractCryptoPwdProvider.getRandomData():
def generate_password(auth: bytes, time_ms: int | None = None) -> bytes:
"""Generate 16-byte session password from auth param + time."""
if time_ms is None:
time_ms = int(time.time() * 1000)
seed_value = 0
for i, b in enumerate(auth):
signed_byte = b if b < 128 else b - 256
shift = (i % 8) * 8
val = signed_byte << (shift & 31) # Java int shift semantics
seed_value += val
seed = time_ms + seed_value
rng = JavaRandom(seed) # java.util.Random (48-bit LCG)
random_bytes = rng.next_bytes(16)
return hashlib.sha256(random_bytes).digest()[:16]
Java compatibility
The JavaRandom class reimplements java.util.Random's 48-bit LCG exactly, including the constructor seed transform (seed ^ 0x5DEECE66D) & ((1 << 48) - 1). Java int shift semantics (shift & 31) must be matched for correct password generation.
Protocol module (nb_protocol.py)¶
Sync bytes by generation¶
SYNC1 = 0x5A
SYNC2_GEN2 = 0xA5 # Default — E-series mopeds
SYNC2_GEN3 = 0xB5 # Kick scooters, P-series, GT-series
VALID_SYNC2 = (SYNC2_GEN2, SYNC2_GEN3)
All builders accept a gen="gen2"|"gen3" parameter (default "gen2"); parse_frame accepts either sync byte.
Commands¶
# Command bytes
CMD_READ, CMD_WRITE, CMD_WRITE_NR = 0x01, 0x02, 0x03
CMD_READ_RESP, CMD_WRITE_RESP = 0x04, 0x05
CMD_PRE_COMM, CMD_SET_PWD, CMD_AUTH = 0x5B, 0x5C, 0x5D
CMD_VEH_ACTION = 0x64 # Gen2 vehicle-action
# Board IDs
BOARD_DIS = 0x01 # Display / Dashboard
BOARD_BLE = 0x04 # BLE module
BOARD_VCU = 0x09 # Vehicle Control Unit (Gen2 vehicle actions)
BOARD_CTRL = 0x20 # Main controller
# Vehicle-action registers
REG_OPEN_ACC, REG_CLOSE_ACC, REG_OPEN_TRUNK = 0x4E, 0x46, 0x73
Convenience builders¶
# Handshake
build_pre_comm(gen="gen2") -> bytes
build_set_pwd(password: bytes, gen="gen2") -> bytes
build_auth(sn: bytes, gen="gen2") -> bytes
# Settings
build_read_speed(gen="gen2") -> bytes
build_write_speed(speed_kmh: int, gen="gen2") -> bytes
# Vehicle actions (all require a 16-byte ident)
build_open_acc(ident: bytes, gen="gen2") -> bytes
build_close_acc(ident: bytes, gen="gen2") -> bytes
build_open_trunk(ident: bytes, gen="gen2") -> bytes
Gen3 dispatches vehicle actions to BOARD_BLE / CMD_WRITE_NR instead of BOARD_VCU / CMD_VEH_ACTION; the helpers route automatically.
Parsing frames¶
@dataclass
class NbFrame:
length: int # Data byte count
board_id: int # Source board
cmd: int # Command byte
index: int # Register / status
data: bytes # Payload
def parse_frame(decrypted: bytes) -> NbFrame | None:
"""Parse a decrypted frame; accepts either gen2 or gen3 sync."""
BLE transport (segway_ble_client.py)¶
Service UUIDs¶
# Ninebot Custom (primary on modern hardware)
NB_SERVICE = "6e400001-0000-0000-006e-696e65626f74"
NB_WRITE = "6e400002-0000-0000-006e-696e65626f74"
NB_NOTIFY = "6e400004-0000-0000-006e-696e65626f74"
# Nordic UART (fallback / older firmware)
NORDIC_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
NORDIC_WRITE = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
NORDIC_NOTIFY = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
Frame reassembly¶
Incoming notifications are appended to a per-connection buffer and scanned for the generation-appropriate sync bytes. Once LENGTH + 13 bytes accumulate, one complete encrypted frame is delivered to the waiter queue.
Outgoing frames are split at MTU - 3 bytes per GATT write. At the default MTU of 23, frames larger than 20 bytes are fragmented with a 10 ms inter-fragment delay — sufficient to avoid in-transit reordering on the Segway firmware.
Handshake flow¶
async def handshake(transport, crypto, bt_name, mac):
# Phase 1: PRE_COMM (non-SN; key = SHA1(bt_name ‖ zeros))
crypto.reset_sn()
crypto.set_key(bt_name.encode(), None)
# Phase 2: SET_PWD (SN mode; key = SHA1(bt_name ‖ auth))
crypto.set_key(bt_name.encode(), auth_param)
password = generate_password(auth_param)
# Phase 3: AUTH (SN mode; key = SHA1(password ‖ auth))
crypto.set_key(password, auth_param)
return password, auth_param, serial_number
See Authentication Handshake for the full state machine.
Extending the client¶
To add a new command:
-
Add a builder in
nb_protocol.py: -
Add the handler in
segway_ble_client.py: -
Expose it as a subcommand in
main()and dispatch fromconnect_and_run().
For writes or vehicle actions, pull the 16-byte ident via _resolve_ident(args, mac) so the caller can supply it once and have it persist.
Self-tests¶
Verifies round-trip encrypt/decrypt for both non-SN and SN modes. Prints per-step output so you can spot-check against a capture.
Related¶
- Credential QR Setup — how the Flutter client transports the session password + ident; the CLI's
--identaccepts the same hex string. - Authentication Handshake — protocol-level handshake documentation.
- Command Reference — register map and vehicle-action semantics.