Skip to content

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

segway_ble_client.py [-v] [-n BT_NAME] [-a MAC] [--ident HEX32] <command> [args]

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

python segway_ble_client.py -a AA:BB:CC:DD:EE:FF read-speed
# → Current speed limit: 25 km/h

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
  }
}
  • password is written after a successful handshake / set-speed / any command that completes AUTH.
  • ident appears only after the first command that used --ident (or a future run that passes --ident explicitly). 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:

  1. Add a builder in nb_protocol.py:

    def build_read_battery(gen: str = "gen2") -> bytes:
        """Read battery percentage from DIS board."""
        return build_frame(BOARD_DIS, CMD_READ, 0xB5, bytes([0x02]), gen=gen)
    
  2. Add the handler in segway_ble_client.py:

    async def cmd_read_battery(transport, crypto) -> int:
        frame = build_read_battery()
        await transport.send(crypto.encrypt(frame))
        resp_plain, rc = crypto.decrypt(await transport.recv())
        resp = parse_frame(resp_plain)
        return struct.unpack("<H", resp.data[:2])[0]
    
  3. Expose it as a subcommand in main() and dispatch from connect_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

python nb_crypto.py

Verifies round-trip encrypt/decrypt for both non-SN and SN modes. Prints per-step output so you can spot-check against a capture.