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¶
Or with uv:
Usage¶
Scan for devices¶
Output:
Connect and authenticate¶
Read speed limit¶
Set speed limit¶
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:
- 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]))
- 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:
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.