254 lines
8.2 KiB
Dart
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);
|
|
}
|