import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; import '../models/scooter_device.dart'; import 'protocol_log.dart'; enum BleConnectionState { disconnected, connected } enum BleAdapterState { unknown, unsupported, off, on, unauthorized } /// Thin boundary between OpenScooter and the underlying BLE plugin. /// /// OpenScooter v1 supports EXACTLY ONE active scooter connection at a time. /// That is why [subscribe], [write] and [discoverServices] take no device /// identifier: they always act on the device passed to the last [connect]. /// /// Protocol code never imports the BLE plugin directly, so protocol tests run /// with `flutter test` and a fake subclass, without hardware. abstract class BleClient { /// Current Bluetooth adapter state, emitting on change. Fakes default to on. Stream get adapterState => Stream.value(BleAdapterState.on); /// Asks the OS to enable Bluetooth. Only Android supports this; elsewhere it /// is a no-op and the UI must direct the user to system settings. Future turnOn() async {} /// Streams the current set of visible devices. Scanning starts on listen /// and stops when the subscription is cancelled. Stream> scan(); Future stopScan(); /// Connects to the device with the given platform identifier and makes it /// the single active device. Future connect(String deviceId); Future disconnect(); /// Emits connection changes for the active device, INCLUDING unexpected /// disconnects. Scooter implementations must reset on `disconnected`. Stream get connectionState; /// Runs GATT discovery on the active device. Returns a map of lowercase /// 128-bit service UUID to the lowercase characteristic UUIDs it contains. Future>> discoverServices(); /// Enables notifications on a characteristic and returns its value stream. Future> subscribe({ required String serviceUuid, required String characteristicUuid, }); Future write({ required String serviceUuid, required String characteristicUuid, required Uint8List value, }); /// Normalises any UUID spelling to lowercase 128-bit form for comparisons. static String normalizeUuid(String uuid) => fbp.Guid(uuid).str128.toLowerCase(); } /// Production [BleClient] backed by flutter_blue_plus. class FlutterBleClient extends BleClient { fbp.BluetoothDevice? _device; List _services = const []; StreamSubscription? _connSub; final _connState = StreamController.broadcast(); @override Stream get connectionState => _connState.stream; @override Stream get adapterState => fbp.FlutterBluePlus.adapterState.map((s) => switch (s) { fbp.BluetoothAdapterState.on => BleAdapterState.on, fbp.BluetoothAdapterState.off || fbp.BluetoothAdapterState.turningOff || fbp.BluetoothAdapterState.turningOn => BleAdapterState.off, fbp.BluetoothAdapterState.unauthorized => BleAdapterState.unauthorized, fbp.BluetoothAdapterState.unavailable => BleAdapterState.unsupported, fbp.BluetoothAdapterState.unknown => BleAdapterState.unknown, }); @override Future turnOn() async { if (defaultTargetPlatform == TargetPlatform.android) { _log('ADAPTER turnOn requested'); await fbp.FlutterBluePlus.turnOn(); } } @override Stream> scan() { late StreamController> controller; StreamSubscription>? sub; Future start() async { try { await fbp.FlutterBluePlus.adapterState .where((s) => s == fbp.BluetoothAdapterState.on) .first .timeout(const Duration(seconds: 10)); sub = fbp.FlutterBluePlus.scanResults.listen((results) { controller.add(results.map(_toDevice).toList()); }, onError: controller.addError); _log('SCAN START'); await fbp.FlutterBluePlus.startScan( continuousUpdates: true, continuousDivisor: 2, removeIfGone: const Duration(seconds: 6), ); } catch (e) { controller.addError(e); } } controller = StreamController>( onListen: start, onCancel: () async { await sub?.cancel(); await stopScan(); }, ); return controller.stream; } ScooterDevice _toDevice(fbp.ScanResult r) { final name = r.advertisementData.advName.isNotEmpty ? r.advertisementData.advName : r.device.platformName; return ScooterDevice( id: r.device.remoteId.str, name: name, rssi: r.rssi, advertisedServiceUuids: r.advertisementData.serviceUuids .map((g) => g.str128.toLowerCase()) .toSet(), ); } @override Future stopScan() async { if (fbp.FlutterBluePlus.isScanningNow) { _log('SCAN STOP'); await fbp.FlutterBluePlus.stopScan(); } } @override Future connect(String deviceId) async { await disconnect(); final device = fbp.BluetoothDevice.fromId(deviceId); _device = device; _log('BLE CONNECT $deviceId'); _connSub = device.connectionState.skip(1).listen((s) { _log('CONNECTION STATE $s'); if (s == fbp.BluetoothConnectionState.disconnected) { _log('BLE DISCONNECTED $deviceId'); _services = const []; _connState.add(BleConnectionState.disconnected); } else if (s == fbp.BluetoothConnectionState.connected) { _connState.add(BleConnectionState.connected); } }); try { // FlutterBluePlus 2.x requires callers to declare which license they // operate under. OpenScooter is a non-commercial open source project. await device.connect(license: fbp.License.nonprofit); _log('CONNECTED mtu=${device.mtuNow}'); } catch (e) { _log('CONNECT FAILED $e'); await _connSub?.cancel(); _connSub = null; _device = null; rethrow; } } @override Future disconnect() async { final device = _device; _device = null; _services = const []; await _connSub?.cancel(); _connSub = null; if (device != null) { _log('BLE DISCONNECT ${device.remoteId.str}'); try { await device.disconnect(); } catch (_) { // Already gone. Nothing to do. } _connState.add(BleConnectionState.disconnected); } } @override Future>> discoverServices() async { final device = _requireDevice(); _services = await device.discoverServices(); final map = >{}; for (final s in _services) { map[s.uuid.str128.toLowerCase()] = s.characteristics.map((c) => c.uuid.str128.toLowerCase()).toSet(); for (final c in s.characteristics) { final p = c.properties; final props = [ if (p.read) 'read', if (p.write) 'write', if (p.writeWithoutResponse) 'writeNoRsp', if (p.notify) 'notify', if (p.indicate) 'indicate', ].join(','); _log('DISCOVERED ${_short(s.uuid.str128)}/${_short(c.uuid.str128)} [$props]'); } } _log('DISCOVERED ${map.length} services'); return map; } @override Future> subscribe({ required String serviceUuid, required String characteristicUuid, }) async { final c = _characteristic(serviceUuid, characteristicUuid); _log('SUBSCRIBE ${_short(characteristicUuid)}'); final ok = await c.setNotifyValue(true); _log('SUBSCRIBE ${_short(characteristicUuid)} result=$ok'); return c.onValueReceived.map((v) => Uint8List.fromList(v)); } @override Future write({ required String serviceUuid, required String characteristicUuid, required Uint8List value, }) async { final c = _characteristic(serviceUuid, characteristicUuid); final withoutResponse = !c.properties.write && c.properties.writeWithoutResponse; _log('WRITE ${_short(characteristicUuid)} ${value.length} bytes withoutResponse=$withoutResponse'); await c.write(value, withoutResponse: withoutResponse); } fbp.BluetoothDevice _requireDevice() { final d = _device; if (d == null) throw StateError('No active BLE device'); return d; } fbp.BluetoothCharacteristic _characteristic(String service, String char) { _requireDevice(); if (_services.isEmpty) { throw StateError('discoverServices() must run before subscribe/write'); } final s = BleClient.normalizeUuid(service); final c = BleClient.normalizeUuid(char); for (final svc in _services) { if (svc.uuid.str128.toLowerCase() != s) continue; for (final ch in svc.characteristics) { if (ch.uuid.str128.toLowerCase() == c) return ch; } } throw StateError('Characteristic ${_short(char)} not found'); } static String _short(String uuid) => uuid.length >= 8 ? uuid.substring(4, 8).toUpperCase() : uuid; void _log(String message) => ProtocolLog.instance.log('BLE', message); }