Skip to content

BLE Transport Layer

The Segway-Ninebot BLE protocol runs over standard BLE GATT. Three service profiles are supported, selected based on hardware generation.


Service & characteristic UUIDs

Ninebot Custom (primary — modern devices)

This is the primary service used by current Segway-Ninebot vehicles.

Role UUID
Service 6e400001-0000-0000-006e-696e65626f74
Write (app → device) 6e400002-0000-0000-006e-696e65626f74
RCTP Write (secondary) 6e400003-0000-0000-006e-696e65626f74
Notify (device → app) 6e400004-0000-0000-006e-696e65626f74
Test Write (factory) 6e400005-0000-0000-006e-696e65626f74
Test Notify (factory) 6e400006-0000-0000-006e-696e65626f74

UUID structure

The UUID suffix 006e-696e65626f74 decodes to \x00ninebot in ASCII. The internal codename for this service is "Hospitality".

Characteristic 0004, not 0003

The notify characteristic is 0004, not 0003. Characteristic 0003 is the RCTP Write channel (a secondary write path), not the notification channel. This is a common source of confusion.

Nordic UART (compatibility)

Older devices and some accessories use the standard Nordic UART Service.

Role UUID
Service 6e400001-b5a3-f393-e0a9-e50e24dcca9e
Write (TX) 6e400002-b5a3-f393-e0a9-e50e24dcca9e
Notify (RX) 6e400003-b5a3-f393-e0a9-e50e24dcca9e

HMSoft (legacy)

Very old devices may use the HMSoft BLE module profile.

Role UUID
Service 0000ffe0-0000-1000-8000-00805f9b34fb
Notify 0000ffe1-0000-1000-8000-00805f9b34fb

Characteristic roles

Characteristic Property Usage
0002 (RTP Write) Write App → device commands
0003 (RCTP Write) Write App → device (secondary channel)
0004 (RTCP Notify) Notify Device → app responses
0005 (Test Write) Write Factory/diagnostic writes
0006 (Test Notify) Notify Factory/diagnostic reads

The app writes encrypted frames to characteristic 0002 and subscribes to notifications on characteristic 0004.


MTU & fragmentation

BLE packets are fragmented at the GATT MTU boundary:

  • Default MTU: 20 bytes (BLE 4.0)
  • Negotiated MTU: Up to 512 bytes (BLE 4.2+)
  • Modern devices support MTU negotiation (mtu_negotiation: true in device config)

The protocol layer handles reassembly across multiple GATT writes/notifications using the frame sync bytes (0x5A 0xA5) and the length field. A state machine scans incoming bytes for the sync pattern, reads the length, and accumulates bytes until a complete frame is received.

App must fragment writes too

Fragmentation is bidirectional. The device fragments its notifications automatically, but the app must also split outgoing frames that exceed the MTU payload size (MTU - 3 bytes). At the default MTU of 23, the payload limit is 20 bytes. For example:

  • PRE_COMM encrypted frame = 13 bytes — fits in one write
  • AUTH encrypted frame = 27 bytes — must be split into two writes (20 + 7)

If the app writes more than MTU - 3 bytes in a single GATT write, the BLE stack may silently truncate the data. The device receives an incomplete frame and never responds. Split large frames into MTU - 3 byte chunks with a small inter-fragment delay (~10 ms).

Frame reassembly state machine

IDLE ─── see 0x5A ───► HAVE_HEAD ─── see 0xA5 ───► HAVE_BEGIN ──► accumulate
                        │                                           │
                        └── other ──► IDLE              until LENGTH + 13 bytes
                                                              deliver frame

Connection flow

sequenceDiagram
    participant App
    participant Device

    App->>Device: BLE Scan (look for "Ninebot*" / "S1D*" names)
    App->>Device: Connect
    App->>Device: Discover services
    App->>Device: Find write (0002) + notify (0004) characteristics
    App->>Device: Subscribe to notifications (0004)
    App->>Device: Negotiate MTU
    Note over App,Device: Begin authentication handshake
    App->>Device: PRE_COMM (encrypted, non-SN mode)
    Device->>App: auth_param + serial number
    App->>Device: SET_PWD (encrypted, SN mode)
    Device->>App: accepted
    App->>Device: AUTH (encrypted, SN mode)
    Device->>App: authenticated
    Note over App,Device: Normal encrypted communication

iOS bonded device behaviour

iOS caches BLE state for bonded (previously connected) peripherals. This creates several pitfalls when reconnecting:

Stale CCCD state

iOS persists the Client Characteristic Configuration Descriptor (CCCD) across connections. If the app subscribed to notifications (0004) in a previous session, the device may still consider notifications enabled when the next connection starts — but the app hasn't set up its notification handler yet.

Workaround: Toggle CCCD off → wait 300 ms → on before starting the handshake:

await notify_char.set_notify(False)
await asyncio.sleep(0.3)
await notify_char.set_notify(True)

Stale notification draining

After re-subscribing, the device may flush cached notifications from the previous session. These arrive before the app sends its first command and will confuse the frame parser if consumed as real responses.

Workaround: Start the notification listener immediately (to consume stale data), but don't process frames until a short drain period (~200 ms) has elapsed. Then "activate" the transport for real communication.

Echo detection

On some iOS reconnections, the device enters a state where it echoes back whatever the app writes instead of processing commands. The PRE_COMM request comes back verbatim as a notification — the app receives its own encrypted frame instead of the device's response.

Detection: After sending PRE_COMM, check if the response bytes match the request bytes. If they do, the device is echoing.

Recovery: Disconnect, wait 1–2 seconds (escalating delay), and reconnect. The disconnect resets the device's BLE stack. Up to 3 retry attempts with increasing delays (1s, 2s) reliably recovers the connection.

Attempt 1: Connect → PRE_COMM → echo detected → disconnect, wait 1s
Attempt 2: Connect → PRE_COMM → echo detected → disconnect, wait 2s
Attempt 3: Connect → PRE_COMM → real response → continue handshake

System device discovery

On iOS, bonded peripherals don't appear in BLE scans. To find them, query the system's connected peripherals using the service UUIDs:

# iOS: find already-connected peripherals by service UUID
connected = FlutterBluePlus.systemDevices([
    Guid("6e400001-0000-0000-006e-696e65626f74"),  # Ninebot Custom
    Guid("6e400001-b5a3-f393-e0a9-e50e24dcca9e"),  # Nordic UART
])

These system devices connect instantly (no scan needed) but are more prone to the stale state issues above.


Device discovery

Segway-Ninebot vehicles advertise with BLE names that typically follow these patterns:

Pattern Example Device type
S1DXXXXXXXXXX S1DCA000000000 Mopeds (E-series)
NBxxxxxxxxxx NB-MK046J2345 Kick scooters (Max, F-series)
Ninebot-XXXX Ninebot-1234 Self-balancing vehicles

The BLE device name is significant — it is used as key material during the first two phases of the authentication handshake.