feat: working connection, conn setting, and gear ratio setting for universal shifters

This commit is contained in:
2026-02-22 23:05:12 +01:00
parent f92d6d04f5
commit dcb1e6596e
93 changed files with 10538 additions and 668 deletions

View File

@ -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);
}
}