feat: working connection, conn setting, and gear ratio setting for universal shifters
This commit is contained in:
@ -1,131 +1,358 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:anyhow/anyhow.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.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
|
||||
@Riverpod(keepAlive: true)
|
||||
FlutterReactiveBle reactiveBle(Ref ref) {
|
||||
ref.keepAlive();
|
||||
return FlutterReactiveBle();
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<BluetoothController> bluetooth(Ref ref) async {
|
||||
final controller = BluetoothController();
|
||||
log.info(await controller.init());
|
||||
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 {
|
||||
StreamSubscription<BluetoothAdapterState>? _btStateSubscription;
|
||||
StreamSubscription<List<ScanResult>>? _scanResultsSubscription;
|
||||
List<ScanResult> _latestScanResults = [];
|
||||
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 {
|
||||
if (await FlutterBluePlus.isSupported == false) {
|
||||
log.severe("Bluetooth is not supported on this device!");
|
||||
return bail("Bluetooth is not supported on this device!");
|
||||
}
|
||||
|
||||
_btStateSubscription =
|
||||
FlutterBluePlus.adapterState.listen((BluetoothAdapterState state) {
|
||||
if (state == BluetoothAdapterState.on) {
|
||||
log.info("Bluetooth is on!");
|
||||
// usually start scanning, connecting, etc
|
||||
} else {
|
||||
log.info("Bluetooth is off!");
|
||||
// show an error to the user, etc
|
||||
}
|
||||
_bleStatusSubscription ??= _ble.statusStream.listen((status) {
|
||||
log.info('BLE status: $status');
|
||||
});
|
||||
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
await FlutterBluePlus.turnOn();
|
||||
}
|
||||
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
/// Start scanning for Bluetooth devices
|
||||
///
|
||||
/// [withServices] - Optional list of service UUIDs to filter devices by
|
||||
/// [withNames] - Optional list of device names to filter by
|
||||
/// [timeout] - Optional duration after which scanning will automatically stop
|
||||
Future<Result<void>> startScan({
|
||||
List<Guid>? withServices,
|
||||
List<String>? withNames,
|
||||
List<Uuid>? withServices,
|
||||
Duration? timeout,
|
||||
ScanMode scanMode = ScanMode.lowLatency,
|
||||
bool requireLocationServicesEnabled = true,
|
||||
}) async {
|
||||
if (_isScanningSubject.value) {
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait for Bluetooth to be enabled
|
||||
await FlutterBluePlus.adapterState
|
||||
.where((val) => val == BluetoothAdapterState.on)
|
||||
.first;
|
||||
final status = _ble.status;
|
||||
if (status != BleStatus.ready) {
|
||||
await _ble.statusStream
|
||||
.where((value) => value == BleStatus.ready)
|
||||
.first;
|
||||
}
|
||||
|
||||
// Set up scan results listener
|
||||
_scanResultsSubscription = FlutterBluePlus.onScanResults.listen(
|
||||
(results) {
|
||||
if (results.isNotEmpty) {
|
||||
_latestScanResults = results;
|
||||
ScanResult latestResult = results.last;
|
||||
log.info(
|
||||
'${latestResult.device.remoteId}: "${latestResult.advertisementData.advName}" found!');
|
||||
}
|
||||
},
|
||||
onError: (e) {
|
||||
log.severe('Scan error: $e');
|
||||
},
|
||||
);
|
||||
_scanTimeout?.cancel();
|
||||
_scanResultsById.clear();
|
||||
_scanResultsSubject.add(const []);
|
||||
_isScanningSubject.add(true);
|
||||
|
||||
// Clean up subscription when scanning completes
|
||||
FlutterBluePlus.cancelWhenScanComplete(_scanResultsSubscription!);
|
||||
_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);
|
||||
});
|
||||
|
||||
// Start scanning with optional parameters
|
||||
await FlutterBluePlus.startScan(
|
||||
withServices: withServices ?? [],
|
||||
withNames: withNames ?? [],
|
||||
timeout: timeout,
|
||||
);
|
||||
if (timeout != null) {
|
||||
_scanTimeout = Timer(timeout, () {
|
||||
unawaited(stopScan());
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(null);
|
||||
} catch (e) {
|
||||
_isScanningSubject.add(false);
|
||||
return bail('Failed to start Bluetooth scan: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop an ongoing Bluetooth scan
|
||||
Future<Result<void>> stopScan() async {
|
||||
try {
|
||||
await FlutterBluePlus.stopScan();
|
||||
_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');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the latest scan results
|
||||
List<ScanResult> get scanResults => _latestScanResults;
|
||||
|
||||
/// Wait for the current scan to complete
|
||||
Future<Result<void>> waitForScanToComplete() async {
|
||||
try {
|
||||
await FlutterBluePlus.isScanning.where((val) => val == false).first;
|
||||
await isScanningStream.where((val) => val == false).first;
|
||||
return Ok(null);
|
||||
} catch (e) {
|
||||
return bail('Error waiting for scan to complete: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if currently scanning
|
||||
Future<bool> get isScanning async {
|
||||
return await FlutterBluePlus.isScanning.first;
|
||||
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 _btStateSubscription?.cancel();
|
||||
await _bleStatusSubscription?.cancel();
|
||||
await disconnect();
|
||||
await _scanResultsSubject.close();
|
||||
await _isScanningSubject.close();
|
||||
await _connectionStateSubject.close();
|
||||
return Ok(null);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user