Files
OpenMotion/lib/services/ble_client.dart
T
2026-09-21 18:03:21 -04:00

254 lines
8.2 KiB
Dart

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 }
/// 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 {
/// Streams the current set of visible devices. Scanning starts on listen
/// and stops when the subscription is cancelled.
Stream<List<ScooterDevice>> scan();
Future<void> stopScan();
/// Connects to the device with the given platform identifier and makes it
/// the single active device.
Future<void> connect(String deviceId);
Future<void> disconnect();
/// Emits connection changes for the active device, INCLUDING unexpected
/// disconnects. Scooter implementations must reset on `disconnected`.
Stream<BleConnectionState> 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<Map<String, Set<String>>> discoverServices();
/// Enables notifications on a characteristic and returns its value stream.
Future<Stream<Uint8List>> subscribe({
required String serviceUuid,
required String characteristicUuid,
});
Future<void> 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<fbp.BluetoothService> _services = const [];
StreamSubscription<fbp.BluetoothConnectionState>? _connSub;
final _connState = StreamController<BleConnectionState>.broadcast();
@override
Stream<BleConnectionState> get connectionState => _connState.stream;
@override
Stream<List<ScooterDevice>> scan() {
late StreamController<List<ScooterDevice>> controller;
StreamSubscription<List<fbp.ScanResult>>? sub;
Future<void> start() async {
try {
var adapter = await fbp.FlutterBluePlus.adapterState.first;
if (adapter != fbp.BluetoothAdapterState.on &&
defaultTargetPlatform == TargetPlatform.android) {
await fbp.FlutterBluePlus.turnOn();
}
adapter = 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<List<ScooterDevice>>(
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<void> stopScan() async {
if (fbp.FlutterBluePlus.isScanningNow) {
_log('SCAN STOP');
await fbp.FlutterBluePlus.stopScan();
}
}
@override
Future<void> 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<void> 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<Map<String, Set<String>>> discoverServices() async {
final device = _requireDevice();
_services = await device.discoverServices();
final map = <String, Set<String>>{};
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<Stream<Uint8List>> 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<void> 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);
}