Files
abawo-bt-app/lib/controller/bluetooth.old.dart

628 lines
22 KiB
Dart

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: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)
Future<BluetoothController> bluetooth(Ref ref) async {
ref.keepAlive();
final controller = BluetoothController();
log.info(await controller.init());
return controller;
}
@Riverpod(keepAlive: true)
Stream<(ConnectionStatus, BluetoothDevice?)> connectionStatus(Ref ref) {
// Get the (potentially still loading) BluetoothController
final asyncController = ref.watch(bluetoothProvider);
// If the controller is ready, return its stream. Otherwise, return an empty stream.
// The provider will automatically update when the controller becomes ready.
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 = [];
StreamSubscription<void>? _servicesResetSubscription;
final Map<String, Map<Guid, BluetoothService>> _servicesByDevice = {};
final Map<String, Map<String, BluetoothCharacteristic>>
_characteristicsByDevice = {};
// Connection State
BluetoothDevice? _connectedDevice;
StreamSubscription<BluetoothConnectionState>? _connectionStateSubscription;
final _connectionStateSubject =
BehaviorSubject<(ConnectionStatus, BluetoothDevice?)>.seeded(
(ConnectionStatus.disconnected, null));
/// Stream providing the current connection status and the connected device (if any).
Stream<(ConnectionStatus, BluetoothDevice?)> get connectionStateStream =>
_connectionStateSubject.stream;
/// Gets the latest connection status and device.
(ConnectionStatus, BluetoothDevice?) get currentConnectionState =>
_connectionStateSubject.value;
Future<Result<void>> init() async {
log.severe("CALLED FBPON!");
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
}
});
if (!kIsWeb && Platform.isAndroid) {
await FlutterBluePlus.turnOn();
}
connectionStateStream.listen((state) {
log.info('Connection state changed: $state');
});
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,
Duration? timeout,
}) async {
try {
// Wait for Bluetooth to be enabled
await FlutterBluePlus.adapterState
.where((val) => val == BluetoothAdapterState.on)
.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');
},
);
// Clean up subscription when scanning completes
FlutterBluePlus.cancelWhenScanComplete(_scanResultsSubscription!);
// Start scanning with optional parameters
await FlutterBluePlus.startScan(
withServices: withServices ?? [],
withNames: withNames ?? [],
timeout: timeout,
);
return Ok(null);
} catch (e) {
return bail('Failed to start Bluetooth scan: $e');
}
}
/// Stop an ongoing Bluetooth scan
Future<Result<void>> stopScan() async {
try {
await FlutterBluePlus.stopScan();
return Ok(null);
} catch (e) {
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;
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;
}
/// Connects to a specific Bluetooth device.
///
/// Ensures that only one device is connected at a time. If another device
/// is already connected or connecting, it will be disconnected first.
Future<Result<void>> connect(BluetoothDevice device,
{Duration? timeout}) async {
final currentState = currentConnectionState;
final currentDevice = currentState.$2;
// Prevent connecting if already connected/connecting to the *same* device
if (device.remoteId == currentDevice?.remoteId &&
(currentState.$1 == ConnectionStatus.connected ||
currentState.$1 == ConnectionStatus.connecting)) {
log.info('Currently connected device: ${currentState.$2}');
log.info('Already connected or connecting to ${device.remoteId}.');
return Ok(null); // Or potentially an error/different status?
}
log.info('Attempting to connect to ${device.remoteId}...');
// If connecting or connected to a *different* device, disconnect it first.
if (currentDevice != null && device.remoteId != currentDevice.remoteId) {
log.info(
'Disconnecting from previous device ${currentDevice.remoteId} first.');
final disconnectResult = await disconnect();
if (disconnectResult.isErr()) {
return disconnectResult
.context('Failed to disconnect from previous device');
}
// Wait a moment for the disconnection to fully process
await Future.delayed(const Duration(milliseconds: 500));
}
try {
// Cancel any previous connection state listener before starting a new one
await _connectionStateSubscription?.cancel();
_connectionStateSubscription =
device.connectionState.listen((BluetoothConnectionState state) async {
log.info('[${device.remoteId}] Connection state changed: $state');
switch (state) {
case BluetoothConnectionState.connected:
_connectedDevice = device;
_updateConnectionState(ConnectionStatus.connected, device);
// IMPORTANT: Discover services after connecting
try {
_attachServicesResetListener(device);
final servicesResult =
await _discoverAndCacheServices(device, force: true);
if (servicesResult.isErr()) {
throw servicesResult.unwrapErr();
}
log.info(
'[${device.remoteId}] Services discovered: \n${servicesResult.unwrap().map((e) => e.uuid.toString()).join('\n')}');
} catch (e) {
log.severe(
'[${device.remoteId}] Error discovering services: $e. Disconnecting.');
// Disconnect if service discovery fails, as the connection might be unusable
await disconnect();
}
break;
case BluetoothConnectionState.disconnected:
if (_connectionStateSubject.value.$1 !=
ConnectionStatus.connected) {
log.warning(
'[${device.remoteId}] Disconnected WITHOUT being connected! Reason: ${device.disconnectReason?.code} ${device.disconnectReason?.description}\nDoing nothing');
break;
} else {
log.warning(
'[${device.remoteId}] Disconnected. Reason: ${device.disconnectReason?.code} ${device.disconnectReason?.description}');
// Only clean up if this is the device we were connected/connecting to
if (_connectionStateSubject.value.$2?.remoteId ==
device.remoteId) {
// Clean up connection state, handling disconnection.
// In general, reconnection is better, but this is how it's handled here.
// App behavior would be to go back to the homepage on disconnection
_cleanUpConnection();
} else {
log.info(
'[${device.remoteId}] Received disconnect for a device we were not tracking.');
}
break;
}
case BluetoothConnectionState.connecting:
case BluetoothConnectionState.disconnecting:
// deprecated states
log.warning(
'Received unexpected connection state: ${device.connectionState}. This should not happen.');
break;
}
});
await device.connect(
license: License.free,
timeout: timeout ?? const Duration(seconds: 15),
mtu: 512,
);
// Note: Success is primarily handled by the connectionState listener
log.info(
'Connection initiated for ${device.remoteId}. Waiting for state change.');
_connectionStateSubject.add((ConnectionStatus.connected, device));
return Ok(null);
} catch (e) {
log.severe('Failed to connect to ${device.remoteId}: $e');
_cleanUpConnection(); // Clean up state on connection failure
return bail('Failed to connect to ${device.remoteId}: $e');
}
}
/// Connects to a device using its remote ID string with a specific timeout.
Future<Result<void>> connectById(String remoteId,
{Duration timeout = const Duration(seconds: 10)}) async {
log.info('Attempting to connect by ID: $remoteId with timeout: $timeout');
try {
// Get the BluetoothDevice object from the ID
final device = BluetoothDevice.fromId(remoteId);
// Call the existing connect method, passing the device and timeout
// Assumes the 'connect' method below is modified to accept the timeout.
return await connect(device, timeout: timeout); // Pass timeout here
} catch (e, st) {
// Catch potential errors from fromId or during connection setup before connect() is called
log.severe('Error connecting by ID $remoteId: $e');
_cleanUpConnection(); // Ensure state is cleaned up
return bail('Failed to initiate connection for ID $remoteId: $e', st);
}
}
/// Disconnects from the currently connected device.
Future<Result<void>> disconnect() async {
final deviceToDisconnect =
_connectedDevice ?? _connectionStateSubject.value.$2;
if (deviceToDisconnect == null) {
log.info('No device is currently connected or connecting.');
// Ensure state is definitely disconnected if called unnecessarily
_cleanUpConnection();
return Ok(null);
}
log.info('Disconnecting from ${deviceToDisconnect.remoteId}...');
_updateConnectionState(ConnectionStatus.disconnecting, deviceToDisconnect);
try {
await deviceToDisconnect.disconnect();
log.info('Disconnect command sent to ${deviceToDisconnect.remoteId}.');
// State update to disconnected is handled by the connectionState listener
// but we call cleanup here as a safety measure in case the listener fails
_cleanUpConnection();
return Ok(null);
} catch (e) {
log.severe(
'Failed to disconnect from ${deviceToDisconnect.remoteId}: $e');
// Even on error, try to clean up the state
_cleanUpConnection();
return bail(
'Failed to disconnect from ${deviceToDisconnect.remoteId}: $e');
}
}
void _updateConnectionState(
ConnectionStatus status, BluetoothDevice? device) {
// Avoid emitting redundant states
if (_connectionStateSubject.value.$1 == status &&
_connectionStateSubject.value.$2?.remoteId == device?.remoteId) {
return;
}
_connectionStateSubject.add((status, device));
log.fine(
'Connection state updated: $status, Device: ${device?.remoteId ?? 'none'}');
}
Future<Result<List<BluetoothService>>> discoverServices(
BluetoothDevice device, {
bool force = false,
}) async {
return _discoverAndCacheServices(device, force: force);
}
Future<Result<void>> writeCharacteristic(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid,
List<int> value, {
bool withoutResponse = false,
bool allowLongWrite = false,
int timeout = 15,
}) async {
final serviceGuid = Guid(serviceUuid);
final characteristicGuid = Guid(characteristicUuid);
final chrResult =
await _getCharacteristic(device, serviceGuid, characteristicGuid);
if (chrResult.isErr()) {
return chrResult.context('Failed to resolve characteristic for write');
}
try {
await chrResult.unwrap().write(
value,
withoutResponse: withoutResponse,
allowLongWrite: allowLongWrite,
timeout: timeout,
);
return Ok(null);
} catch (e) {
return bail('Error writing characteristic $characteristicUuid: $e');
}
}
Future<Result<StreamSubscription<List<int>>>> subscribeToNotifications(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid, {
void Function(List<int>)? onValue,
bool useLastValueStream = false,
int timeout = 15,
}) async {
return _subscribeToCharacteristic(
device,
serviceUuid,
characteristicUuid,
useLastValueStream: useLastValueStream,
timeout: timeout,
forceIndications: false,
onValue: onValue,
);
}
Future<Result<StreamSubscription<List<int>>>> subscribeToIndications(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid, {
void Function(List<int>)? onValue,
bool useLastValueStream = false,
int timeout = 15,
}) async {
return _subscribeToCharacteristic(
device,
serviceUuid,
characteristicUuid,
useLastValueStream: useLastValueStream,
timeout: timeout,
forceIndications: true,
onValue: onValue,
);
}
Future<Result<void>> unsubscribeFromCharacteristic(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid, {
int timeout = 15,
}) async {
final serviceGuid = Guid(serviceUuid);
final characteristicGuid = Guid(characteristicUuid);
final chrResult =
await _getCharacteristic(device, serviceGuid, characteristicGuid);
if (chrResult.isErr()) {
return chrResult
.context('Failed to resolve characteristic to unsubscribe');
}
try {
await chrResult.unwrap().setNotifyValue(false, timeout: timeout);
return Ok(null);
} catch (e) {
return bail('Error disabling notifications for $characteristicUuid: $e');
}
}
/// Helper function to clean up connection resources and state.
Future<void> _cleanUpConnection() async {
log.fine('Cleaning up connection state and subscriptions.');
_connectedDevice = null;
await _servicesResetSubscription?.cancel();
_servicesResetSubscription = null;
_servicesByDevice.clear();
_characteristicsByDevice.clear();
await _connectionStateSubscription?.cancel();
_connectionStateSubscription = null;
_updateConnectionState(ConnectionStatus.disconnected, null);
}
Future<Result<void>> dispose() async {
await _scanResultsSubscription?.cancel();
await _btStateSubscription?.cancel();
await disconnect(); // Ensure disconnection on dispose
await _connectionStateSubject.close();
return Ok(null);
}
Future<Result<List<int>>> readCharacteristic(
BluetoothDevice device, String svcUuid, String characteristic) async {
// Implement reading characteristic logic here
// This is a placeholder implementation
log.info(
'Reading characteristic from device: $device, characteristic: $characteristic');
final serviceUUID = Guid(svcUuid);
final characteristicUUID = Guid(characteristic);
if (!device.servicesList.map((e) => e.uuid).contains(serviceUUID)) {
return bail('Service $svcUuid not found on device ${device.remoteId}');
}
final BluetoothService service =
(device.servicesList).firstWhere((s) => s.uuid == serviceUUID);
if (service.characteristics.isEmpty ||
!service.characteristics
.map((c) => c.uuid)
.contains(characteristicUUID)) {
return bail(
'Characteristic $characteristic not found on device ${device.remoteId}');
}
try {
final val = await service.characteristics
.firstWhere((c) => c.uuid == characteristicUUID)
.read();
return Ok(val);
} catch (e) {
return bail('Error reading characteristic: $e');
}
}
String _deviceKey(BluetoothDevice device) => device.remoteId.str;
String _characteristicKey(Guid serviceUuid, Guid characteristicUuid) =>
'${serviceUuid.toString()}|${characteristicUuid.toString()}';
void _cacheServices(BluetoothDevice device, List<BluetoothService> services) {
final serviceMap = <Guid, BluetoothService>{};
final characteristicMap = <String, BluetoothCharacteristic>{};
for (final service in services) {
serviceMap[service.uuid] = service;
for (final chr in service.characteristics) {
characteristicMap[_characteristicKey(service.uuid, chr.uuid)] = chr;
}
}
_servicesByDevice[_deviceKey(device)] = serviceMap;
_characteristicsByDevice[_deviceKey(device)] = characteristicMap;
}
void _attachServicesResetListener(BluetoothDevice device) {
_servicesResetSubscription?.cancel();
_servicesResetSubscription = device.onServicesReset.listen((_) async {
log.info('[${device.remoteId}] Services reset. Re-discovering.');
final res = await _discoverAndCacheServices(device, force: true);
if (res.isErr()) {
log.severe(
'[${device.remoteId}] Failed to re-discover services: ${res.unwrapErr()}');
}
});
device.cancelWhenDisconnected(_servicesResetSubscription!);
}
Future<Result<List<BluetoothService>>> _discoverAndCacheServices(
BluetoothDevice device, {
bool force = false,
}) async {
try {
if (!force) {
final cached = _servicesByDevice[_deviceKey(device)];
if (cached != null && cached.isNotEmpty) {
return Ok(cached.values.toList());
}
}
if (!force && device.servicesList.isNotEmpty) {
_cacheServices(device, device.servicesList);
return Ok(device.servicesList);
}
final services = await device.discoverServices();
_cacheServices(device, services);
return Ok(services);
} catch (e) {
return bail('Failed to discover services for ${device.remoteId}: $e');
}
}
Future<Result<BluetoothCharacteristic>> _getCharacteristic(
BluetoothDevice device,
Guid serviceUuid,
Guid characteristicUuid,
) async {
final deviceKey = _deviceKey(device);
final cached = _characteristicsByDevice[deviceKey]
?[_characteristicKey(serviceUuid, characteristicUuid)];
if (cached != null) {
return Ok(cached);
}
final discoverResult = await _discoverAndCacheServices(device);
if (discoverResult.isErr()) {
return bail(discoverResult.unwrapErr().toString());
}
final refreshed = _characteristicsByDevice[deviceKey]
?[_characteristicKey(serviceUuid, characteristicUuid)];
if (refreshed == null) {
return bail(
'Characteristic $characteristicUuid not found on service $serviceUuid for device ${device.remoteId}');
}
return Ok(refreshed);
}
Future<Result<StreamSubscription<List<int>>>> _subscribeToCharacteristic(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid, {
required bool forceIndications,
required bool useLastValueStream,
required int timeout,
void Function(List<int>)? onValue,
}) async {
final serviceGuid = Guid(serviceUuid);
final characteristicGuid = Guid(characteristicUuid);
final chrResult =
await _getCharacteristic(device, serviceGuid, characteristicGuid);
if (chrResult.isErr()) {
return bail('Failed to resolve characteristic subscription: '
'${chrResult.unwrapErr()}');
}
final characteristic = chrResult.unwrap();
final properties = characteristic.properties;
if (forceIndications && !properties.indicate) {
return bail(
'Characteristic $characteristicUuid does not support indications');
}
if (!forceIndications && !properties.notify && !properties.indicate) {
return bail(
'Characteristic $characteristicUuid does not support notifications');
}
if (forceIndications && !kIsWeb && !Platform.isAndroid) {
return bail('Indications can only be forced on Android.');
}
try {
final stream = useLastValueStream
? characteristic.lastValueStream
: characteristic.onValueReceived;
final subscription = stream.listen(onValue ?? (_) {});
device.cancelWhenDisconnected(subscription);
await characteristic.setNotifyValue(
true,
timeout: timeout,
forceIndications: forceIndications,
);
return Ok(subscription);
} catch (e) {
return bail(
'Error subscribing to characteristic $characteristicUuid: $e');
}
}
}