359 lines
11 KiB
Dart
359 lines
11 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:anyhow/anyhow.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
|
|
hide ConnectionStatus, Result, Logger;
|
|
import 'package:logging/logging.dart';
|
|
import 'package:rxdart/rxdart.dart';
|
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
|
|
part 'bluetooth.g.dart';
|
|
|
|
final log = Logger('BluetoothController');
|
|
|
|
@Riverpod(keepAlive: true)
|
|
FlutterReactiveBle reactiveBle(Ref ref) {
|
|
ref.keepAlive();
|
|
return FlutterReactiveBle();
|
|
}
|
|
|
|
@Riverpod(keepAlive: true)
|
|
Future<BluetoothController> bluetooth(Ref ref) async {
|
|
ref.keepAlive();
|
|
final controller = BluetoothController(ref.read(reactiveBleProvider));
|
|
await controller.init();
|
|
return controller;
|
|
}
|
|
|
|
@Riverpod(keepAlive: true)
|
|
Stream<(ConnectionStatus, String?)> connectionStatus(Ref ref) {
|
|
final asyncController = ref.watch(bluetoothProvider);
|
|
return asyncController.when(
|
|
data: (controller) => controller.connectionStateStream,
|
|
loading: () => Stream.value((ConnectionStatus.disconnected, null)),
|
|
error: (_, __) => Stream.value((ConnectionStatus.disconnected, null)),
|
|
);
|
|
}
|
|
|
|
/// Represents the connection status of the Bluetooth device.
|
|
enum ConnectionStatus { disconnected, connecting, connected, disconnecting }
|
|
|
|
class BluetoothController {
|
|
BluetoothController(this._ble);
|
|
|
|
static const int defaultMtu = 64;
|
|
|
|
final FlutterReactiveBle _ble;
|
|
|
|
StreamSubscription<BleStatus>? _bleStatusSubscription;
|
|
StreamSubscription<DiscoveredDevice>? _scanResultsSubscription;
|
|
Timer? _scanTimeout;
|
|
final Map<String, DiscoveredDevice> _scanResultsById = {};
|
|
final _scanResultsSubject =
|
|
BehaviorSubject<List<DiscoveredDevice>>.seeded(const []);
|
|
final _isScanningSubject = BehaviorSubject<bool>.seeded(false);
|
|
|
|
String? _connectedDeviceId;
|
|
StreamSubscription<ConnectionStateUpdate>? _connectionStateSubscription;
|
|
final _connectionStateSubject =
|
|
BehaviorSubject<(ConnectionStatus, String?)>.seeded(
|
|
(ConnectionStatus.disconnected, null));
|
|
|
|
Stream<(ConnectionStatus, String?)> get connectionStateStream =>
|
|
_connectionStateSubject.stream;
|
|
|
|
(ConnectionStatus, String?) get currentConnectionState =>
|
|
_connectionStateSubject.value;
|
|
|
|
Stream<List<DiscoveredDevice>> get scanResultsStream =>
|
|
_scanResultsSubject.stream;
|
|
|
|
Stream<bool> get isScanningStream => _isScanningSubject.stream;
|
|
|
|
List<DiscoveredDevice> get scanResults => _scanResultsSubject.value;
|
|
|
|
Future<Result<void>> init() async {
|
|
_bleStatusSubscription ??= _ble.statusStream.listen((status) {
|
|
log.info('BLE status: $status');
|
|
});
|
|
return Ok(null);
|
|
}
|
|
|
|
Future<Result<void>> startScan({
|
|
List<Uuid>? withServices,
|
|
Duration? timeout,
|
|
ScanMode scanMode = ScanMode.lowLatency,
|
|
bool requireLocationServicesEnabled = true,
|
|
}) async {
|
|
if (_isScanningSubject.value) {
|
|
return Ok(null);
|
|
}
|
|
|
|
try {
|
|
final status = _ble.status;
|
|
if (status != BleStatus.ready) {
|
|
await _ble.statusStream
|
|
.where((value) => value == BleStatus.ready)
|
|
.first;
|
|
}
|
|
|
|
_scanTimeout?.cancel();
|
|
_scanResultsById.clear();
|
|
_scanResultsSubject.add(const []);
|
|
_isScanningSubject.add(true);
|
|
|
|
_scanResultsSubscription = _ble
|
|
.scanForDevices(
|
|
withServices: withServices ?? const [],
|
|
scanMode: scanMode,
|
|
requireLocationServicesEnabled: requireLocationServicesEnabled,
|
|
)
|
|
.listen((device) {
|
|
_scanResultsById[device.id] = device;
|
|
_scanResultsSubject
|
|
.add(_scanResultsById.values.toList(growable: false));
|
|
}, onError: (Object error, StackTrace st) {
|
|
log.severe('Scan error: $error', error, st);
|
|
_isScanningSubject.add(false);
|
|
});
|
|
|
|
if (timeout != null) {
|
|
_scanTimeout = Timer(timeout, () {
|
|
unawaited(stopScan());
|
|
});
|
|
}
|
|
|
|
return Ok(null);
|
|
} catch (e) {
|
|
_isScanningSubject.add(false);
|
|
return bail('Failed to start Bluetooth scan: $e');
|
|
}
|
|
}
|
|
|
|
Future<Result<void>> stopScan() async {
|
|
try {
|
|
_scanTimeout?.cancel();
|
|
_scanTimeout = null;
|
|
await _scanResultsSubscription?.cancel();
|
|
_scanResultsSubscription = null;
|
|
_isScanningSubject.add(false);
|
|
return Ok(null);
|
|
} catch (e) {
|
|
_isScanningSubject.add(false);
|
|
return bail('Failed to stop Bluetooth scan: $e');
|
|
}
|
|
}
|
|
|
|
Future<Result<void>> waitForScanToComplete() async {
|
|
try {
|
|
await isScanningStream.where((val) => val == false).first;
|
|
return Ok(null);
|
|
} catch (e) {
|
|
return bail('Error waiting for scan to complete: $e');
|
|
}
|
|
}
|
|
|
|
Future<bool> get isScanning async => isScanningStream.first;
|
|
|
|
Future<Result<void>> connect(DiscoveredDevice device,
|
|
{Duration? timeout}) async {
|
|
return connectById(device.id, timeout: timeout ?? Duration(seconds: 10));
|
|
}
|
|
|
|
Future<Result<void>> connectById(
|
|
String deviceId, {
|
|
Duration timeout = const Duration(seconds: 10),
|
|
Map<Uuid, List<Uuid>>? servicesWithCharacteristicsToDiscover,
|
|
}) async {
|
|
final currentState = currentConnectionState;
|
|
final currentDeviceId = currentState.$2;
|
|
|
|
if (deviceId == currentDeviceId &&
|
|
(currentState.$1 == ConnectionStatus.connected ||
|
|
currentState.$1 == ConnectionStatus.connecting)) {
|
|
log.info('Already connected or connecting to $deviceId.');
|
|
if (currentState.$1 == ConnectionStatus.connected) {
|
|
unawaited(_requestMtuOnConnect(deviceId));
|
|
}
|
|
return Ok(null);
|
|
}
|
|
|
|
if (currentDeviceId != null && deviceId != currentDeviceId) {
|
|
final disconnectResult = await disconnect();
|
|
if (disconnectResult.isErr()) {
|
|
return disconnectResult
|
|
.context('Failed to disconnect from previous device');
|
|
}
|
|
await Future.delayed(const Duration(milliseconds: 300));
|
|
}
|
|
|
|
try {
|
|
await _connectionStateSubscription?.cancel();
|
|
_updateConnectionState(ConnectionStatus.connecting, deviceId);
|
|
|
|
_connectionStateSubscription = _ble
|
|
.connectToDevice(
|
|
id: deviceId,
|
|
connectionTimeout: timeout,
|
|
servicesWithCharacteristicsToDiscover:
|
|
servicesWithCharacteristicsToDiscover,
|
|
)
|
|
.listen((update) {
|
|
switch (update.connectionState) {
|
|
case DeviceConnectionState.connected:
|
|
_connectedDeviceId = deviceId;
|
|
_updateConnectionState(ConnectionStatus.connected, deviceId);
|
|
unawaited(_requestMtuOnConnect(deviceId));
|
|
break;
|
|
case DeviceConnectionState.connecting:
|
|
_updateConnectionState(ConnectionStatus.connecting, deviceId);
|
|
break;
|
|
case DeviceConnectionState.disconnecting:
|
|
_updateConnectionState(ConnectionStatus.disconnecting, deviceId);
|
|
break;
|
|
case DeviceConnectionState.disconnected:
|
|
_cleanUpConnection();
|
|
break;
|
|
}
|
|
}, onError: (Object error, StackTrace st) {
|
|
log.severe('Failed to connect to $deviceId: $error', error, st);
|
|
_cleanUpConnection();
|
|
});
|
|
|
|
return Ok(null);
|
|
} catch (e) {
|
|
_cleanUpConnection();
|
|
return bail('Failed to connect to $deviceId: $e');
|
|
}
|
|
}
|
|
|
|
Future<Result<void>> disconnect() async {
|
|
final deviceIdToDisconnect =
|
|
_connectedDeviceId ?? _connectionStateSubject.value.$2;
|
|
if (deviceIdToDisconnect == null) {
|
|
_cleanUpConnection();
|
|
return Ok(null);
|
|
}
|
|
|
|
_updateConnectionState(
|
|
ConnectionStatus.disconnecting, deviceIdToDisconnect);
|
|
try {
|
|
await _connectionStateSubscription?.cancel();
|
|
_connectionStateSubscription = null;
|
|
_cleanUpConnection();
|
|
return Ok(null);
|
|
} catch (e) {
|
|
_cleanUpConnection();
|
|
return bail('Failed to disconnect from $deviceIdToDisconnect: $e');
|
|
}
|
|
}
|
|
|
|
Future<Result<List<int>>> readCharacteristic(
|
|
String deviceId,
|
|
String serviceUuid,
|
|
String characteristicUuid,
|
|
) async {
|
|
try {
|
|
final characteristic = QualifiedCharacteristic(
|
|
serviceId: Uuid.parse(serviceUuid),
|
|
characteristicId: Uuid.parse(characteristicUuid),
|
|
deviceId: deviceId,
|
|
);
|
|
final value = await _ble.readCharacteristic(characteristic);
|
|
return Ok(value);
|
|
} catch (e) {
|
|
return bail('Error reading characteristic: $e');
|
|
}
|
|
}
|
|
|
|
Future<Result<void>> writeCharacteristic(
|
|
String deviceId,
|
|
String serviceUuid,
|
|
String characteristicUuid,
|
|
List<int> value, {
|
|
bool withResponse = true,
|
|
}) async {
|
|
try {
|
|
final characteristic = QualifiedCharacteristic(
|
|
serviceId: Uuid.parse(serviceUuid),
|
|
characteristicId: Uuid.parse(characteristicUuid),
|
|
deviceId: deviceId,
|
|
);
|
|
if (withResponse) {
|
|
await _ble.writeCharacteristicWithResponse(
|
|
characteristic,
|
|
value: value,
|
|
);
|
|
} else {
|
|
await _ble.writeCharacteristicWithoutResponse(
|
|
characteristic,
|
|
value: value,
|
|
);
|
|
}
|
|
return Ok(null);
|
|
} catch (e) {
|
|
return bail('Error writing characteristic: $e');
|
|
}
|
|
}
|
|
|
|
Future<Result<void>> requestMtu(String deviceId,
|
|
{int mtu = defaultMtu}) async {
|
|
try {
|
|
final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu);
|
|
log.info(
|
|
'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu');
|
|
return Ok(null);
|
|
} catch (e) {
|
|
return bail('Error requesting MTU $mtu for $deviceId: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _requestMtuOnConnect(String deviceId) async {
|
|
final mtuResult = await requestMtu(deviceId, mtu: defaultMtu);
|
|
if (mtuResult.isErr()) {
|
|
log.warning(
|
|
'MTU request after connect failed for $deviceId: ${mtuResult.unwrapErr()}');
|
|
}
|
|
}
|
|
|
|
Stream<List<int>> subscribeToCharacteristic(
|
|
String deviceId,
|
|
String serviceUuid,
|
|
String characteristicUuid,
|
|
) {
|
|
final characteristic = QualifiedCharacteristic(
|
|
serviceId: Uuid.parse(serviceUuid),
|
|
characteristicId: Uuid.parse(characteristicUuid),
|
|
deviceId: deviceId,
|
|
);
|
|
return _ble.subscribeToCharacteristic(characteristic);
|
|
}
|
|
|
|
void _updateConnectionState(ConnectionStatus status, String? deviceId) {
|
|
if (_connectionStateSubject.value.$1 == status &&
|
|
_connectionStateSubject.value.$2 == deviceId) {
|
|
return;
|
|
}
|
|
_connectionStateSubject.add((status, deviceId));
|
|
log.fine(
|
|
'Connection state updated: $status, device: ${deviceId ?? 'none'}');
|
|
}
|
|
|
|
void _cleanUpConnection() {
|
|
_connectedDeviceId = null;
|
|
_updateConnectionState(ConnectionStatus.disconnected, null);
|
|
}
|
|
|
|
Future<Result<void>> dispose() async {
|
|
_scanTimeout?.cancel();
|
|
await _scanResultsSubscription?.cancel();
|
|
await _bleStatusSubscription?.cancel();
|
|
await disconnect();
|
|
await _scanResultsSubject.close();
|
|
await _isScanningSubject.close();
|
|
await _connectionStateSubject.close();
|
|
return Ok(null);
|
|
}
|
|
}
|