Skip to content

Python BLE Client

Completely untested

This client has never been tested against a real Segway-Ninebot vehicle. The crypto and protocol code was written based on reverse engineering of libnbcrypto.so and the Android app, and round-trip encrypt/decrypt passes self-tests, but no BLE communication with an actual device has been attempted. Use at your own risk — contributions from anyone with a physical device are very welcome.

A reference implementation of the Encryption2 protocol in Python, including the full 3-phase handshake, register read/write operations, and session persistence.


Overview

The client consists of three modules:

Module Purpose
nb_crypto.py AES-128 encryption/decryption, key derivation, password generation
nb_protocol.py Frame builder/parser for Encryption2 format
segway_ble_client.py BLE transport, handshake, CLI interface

Requirements

  • Python 3.12+
  • Bleak 3.0+ (cross-platform BLE library)
  • PyCryptodome 3.23+ (AES implementation)

Install

pip install bleak pycryptodome

Or with uv:

uv pip install bleak pycryptodome

Usage

Scan for devices

python segway_ble_client.py scan

Output:

Scanning for BLE devices (10 seconds)...
  [-62 dBm] AA:BB:CC:DD:EE:FF  S1DCA2045K0123

Connect and authenticate

python segway_ble_client.py -a AA:BB:CC:DD:EE:FF handshake

Read speed limit

python segway_ble_client.py -a AA:BB:CC:DD:EE:FF read-speed

Set speed limit

python segway_ble_client.py -a AA:BB:CC:DD:EE:FF set-speed 45

Options

-v, --verbose     Enable debug logging
-n, --name NAME   BLE device name (used as key material)
-a, --address MAC BLE device MAC address

Architecture

Crypto module (nb_crypto.py)

The crypto module reimplements libnbcrypto.so in pure Python.

NbCrypto class

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 = time + f(auth_param)
    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)

Frame builder and parser for Encryption2.

Constants

SYNC1 = 0x5A
SYNC2 = 0xA5
BT_ID = 0x3E

# Command types
CMD_READ = 0x01
CMD_WRITE = 0x02
CMD_WRITE_NR = 0x03
CMD_READ_RESP = 0x04
CMD_WRITE_RESP = 0x05

# Handshake
CMD_PRE_COMM = 0x5B  # 91
CMD_SET_PWD = 0x5C   # 92
CMD_AUTH = 0x5D       # 93

# Board IDs
BOARD_DIS = 0x01   # Display / Dashboard
BOARD_BLE = 0x04   # BLE module

Building frames

def build_frame(target: int, cmd: int, index: int, data: bytes = b"") -> bytes:
    """Build plaintext Encryption2 frame."""
    length = len(data)
    header = bytes([SYNC1, SYNC2, length, BT_ID, target, cmd, index])
    return header + data

# Convenience builders
def build_pre_comm() -> bytes: ...
def build_set_pwd(password: bytes) -> bytes: ...
def build_auth(sn: bytes) -> bytes: ...
def build_read_speed() -> bytes: ...
def build_write_speed(speed_kmh: int) -> bytes: ...

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 decrypted frame. Returns None if invalid."""
    if len(decrypted) < 7: return None
    if decrypted[0] != 0x5A or decrypted[1] != 0xA5: return None
    if decrypted[4] != 0x3E: return None  # BT_ID check
    return NbFrame(
        length=decrypted[2],
        board_id=decrypted[3],
        cmd=decrypted[5],
        index=decrypted[6],
        data=decrypted[7:7+decrypted[2]]
    )

BLE transport (segway_ble_client.py)

BLE UUIDs

# Ninebot Custom (primary)
NB_SERVICE = "6e400001-0000-0000-006e-696e65626f74"
NB_WRITE   = "6e400002-0000-0000-006e-696e65626f74"
NB_NOTIFY  = "6e400004-0000-0000-006e-696e65626f74"

# Nordic UART (fallback)
NORDIC_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
NORDIC_WRITE   = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
NORDIC_NOTIFY  = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"

Frame reassembly

The BleTransport class handles BLE write/notify operations with frame reassembly:

class BleTransport:
    def __init__(self, client, write_uuid, notify_uuid):
        self._rx_queue = asyncio.Queue()
        self._rx_buffer = bytearray()

    async def send(self, data: bytes): ...
    async def recv(self, timeout: float = 5.0) -> bytes: ...

Incoming BLE notifications are buffered and scanned for the 0x5A 0xA5 sync pattern. Once LENGTH + 13 bytes are accumulated, a complete encrypted frame is delivered.

Handshake flow

async def handshake(transport, crypto, bt_name, mac):
    # Phase 1: PRE_COMM
    crypto.reset_sn()
    crypto.set_key(bt_name.encode(), None)
    # ... send PRE_COMM, get auth + SN ...

    # Phase 2: SET_PWD
    crypto.set_key(bt_name.encode(), auth_param)
    password = generate_password(auth_param)
    # ... send SET_PWD, wait for acceptance ...

    # Phase 3: AUTH
    crypto.set_key(password, auth_param)
    # ... send AUTH with serial number ...

    return password, auth_param, serial_number

Session persistence

Passwords are stored in ~/.segway_session.json keyed by device MAC address. On reconnection, if the device indicates it has a stored password (PRE_COMM INDEX != 0), the SET_PWD phase is skipped.


Extending the client

To add support for additional commands:

  1. Define the frame in nb_protocol.py:
def build_read_battery() -> bytes:
    """Read battery percentage from DIS board."""
    return build_frame(BOARD_DIS, CMD_READ, 0xB5, bytes([0x02]))
  1. Add the command handler in segway_ble_client.py:
async def cmd_read_battery(transport, crypto):
    frame = build_read_battery()
    encrypted = crypto.encrypt(frame)
    await transport.send(encrypted)

    resp_enc = await transport.recv(timeout=5.0)
    resp_plain, rc = crypto.decrypt(resp_enc)
    resp = parse_frame(resp_plain)

    if resp and resp.cmd == CMD_READ_RESP and len(resp.data) >= 2:
        return struct.unpack("<H", resp.data[:2])[0]

Self-test

The crypto module includes built-in self-tests:

python nb_crypto.py

Output:

Key (btName, null): <hex>
JavaRandom(12345).nextBytes(4): <hex>
Non-SN encrypted (13 bytes): <hex>
Non-SN decrypted (rc=0): <hex>
SN encrypted (15 bytes): <hex>
SN decrypted (rc=0): <hex>

All crypto self-tests passed!

This verifies round-trip encrypt/decrypt for both SN and non-SN modes.