Implementing E-scooter Unlock via BLE/QR in Mobile App
Scooter unlock in sharing—several seconds of user flow that either work invisibly or break experience completely. User scans QR, app must: verify token on server, get unlock command, deliver to scooter controller via BLE—all in 1-2 seconds. Any delay perceived as bug.
QR Code: From Scan to BLE Command
QR on scooter encodes device identifier: string like https://ride.example.com/unlock?id=SC-00234 or just SC-00234. Scan via ML Kit Barcode Scanning (Android/iOS) or AVFoundation AVCaptureMetadataOutput. ML Kit preferable—works offline, faster in poor light.
After ID received—server request: POST /api/v1/unlock with {scooterId, userId, sessionToken}. Server checks balance/subscription, reserves scooter, returns {bleUnlockToken, bleDeviceAddress, lockServiceUUID, lockCharacteristicUUID, expiresAt}. Token one-time, lives 30-60 seconds—if BLE connection didn't succeed, user gets error and initiates new request.
// iOS: QR scanning via AVFoundation
class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
func metadataOutput(_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection) {
guard let readableObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let stringValue = readableObject.stringValue,
let scooterId = parseScooterId(from: stringValue) else { return }
captureSession.stopRunning()
Task { await viewModel.initiateUnlock(scooterId: scooterId) }
}
}
BLE Unlock: Implementation Details
Sharing scooters use BLE modules on Nordic nRF52 or ESP32 with custom GATT profile. Scheme simple: write token to WriteCharacteristic, controller verifies HMAC signature of token (shared secret hardcoded in firmware), on match—releases electromagnetic lock and enables motor.
// Android: write unlock token to BLE characteristic
class ScooterUnlockManager(private val context: Context) {
private var gatt: BluetoothGatt? = null
suspend fun unlock(address: String, token: UnlockToken): UnlockResult {
return suspendCancellableCoroutine { cont ->
val device = bluetoothAdapter.getRemoteDevice(address)
gatt = device.connectGatt(context, false, object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, state: Int) {
if (state == BluetoothProfile.STATE_CONNECTED) g.discoverServices()
else if (status != BluetoothGatt.GATT_SUCCESS) {
cont.resume(UnlockResult.BleConnectionFailed(status))
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
val characteristic = g
.getService(token.serviceUUID)
?.getCharacteristic(token.characteristicUUID)
?: run { cont.resume(UnlockResult.ServiceNotFound); return }
characteristic.value = token.payload.toByteArray()
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
g.writeCharacteristic(characteristic)
}
override fun onCharacteristicWrite(g: BluetoothGatt,
characteristic: BluetoothGattCharacteristic, status: Int) {
val result = if (status == BluetoothGatt.GATT_SUCCESS)
UnlockResult.Success else UnlockResult.WriteFailed(status)
cont.resume(result)
g.disconnect()
}
}, BluetoothDevice.TRANSPORT_LE)
cont.invokeOnCancellation { gatt?.disconnect() }
}
}
}
Critical detail: TRANSPORT_LE in connectGatt flag—without it, Android may attempt Classic Bluetooth connection, giving status=133 on BLE-only devices.
Real-World Problems
Poor BLE signal. Scooter in underground parking, 20 other scooters nearby with same GATT services. Don't scan all devices; scan targeted: bluetoothAdapter.getRemoteDevice(address) by MAC from server response. Faster than environment scan.
Token expired. User scanned QR, then distracted 2 minutes. Token expired. After GATT_SUCCESS on write, but scooter didn't unlock—need Notify Characteristic for controller response. Controller writes to response characteristic: 0x01 (OK), 0x02 (token expired), 0x03 (already locked by another session).
Android 12+ Bluetooth permissions. BLUETOOTH_SCAN and BLUETOOTH_CONNECT—now runtime permissions. If user rejected with "Don't ask again", shouldShowRequestPermissionRationale returns false—lead to Settings via Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).
UX Details That Make a Difference
Parallel server and BLE: as soon as server response with device address received, start BLE connection without waiting for final UI animation. User sees spinner, BLE handshake already happening. Saves ~300-500 ms.
Developing QR scan + BLE unlock module for sharing platform (iOS + Android): 3-5 weeks. With full fleet backend (reservation, billing, geofencing): 3-5 months. Cost individually quoted.







