main
  1import 'dart:async';
  2import 'dart:math';
  3
  4import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_connection.dart';
  5import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_manager.dart';
  6import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_service.dart';
  7import 'package:blood_pressure_app/logging.dart';
  8import 'package:collection/collection.dart';
  9import 'package:flutter/foundation.dart';
 10
 11/// Current state of the bluetooth device
 12enum BluetoothDeviceState {
 13  /// Started connecting to the device
 14  connecting,
 15  /// Started disconnecting the device
 16  disconnecting,
 17  /// Device is connected (f.e. it send a connected event)
 18  connected,
 19  /// Device is disconnected (f.e. it send a disconnected event)
 20  disconnected;
 21}
 22
 23/// Wrapper class for bluetooth implementations to generically expose required functionality
 24abstract class BluetoothDevice<
 25  BM extends BluetoothManager,
 26  BS extends BluetoothService,
 27  BC extends BluetoothCharacteristic,
 28  BackendDevice
 29> with TypeLogger {
 30  /// Create a new BluetoothDevice.
 31  ///
 32  /// * [manager] Manager the device belongs to
 33  /// * [source] Device implementation of the current backend
 34  BluetoothDevice(this.manager, this.source) {
 35    logger.finer('init device: $this');
 36  }
 37
 38  /// [BluetoothManager] this device belongs to
 39  final BM manager;
 40
 41  /// Original source device as returned by the backend
 42  final BackendDevice source;
 43
 44  BluetoothDeviceState _state = BluetoothDeviceState.disconnected;
 45
 46  /// (Unique?) id of the device
 47  String get deviceId;
 48
 49  /// Name of the device
 50  String get name;
 51
 52  /// Memoized service list for the device
 53  List<BS>? _services;
 54
 55  StreamSubscription<BluetoothConnectionState>? _connectionListener;
 56
 57  /// Whether the device is connected
 58  bool get isConnected => _state == BluetoothDeviceState.connected;
 59
 60  /// Stream to listen to for connection state changes after connecting to a device
 61  Stream<BluetoothConnectionState> get connectionStream;
 62
 63  /// Backend implementation to connect to the device
 64  Future<void> backendConnect();
 65  /// Backend implementation to disconnect to the device
 66  Future<void> backendDisconnect();
 67
 68  /// Require backends to implement a dispose method to cleanup any resources
 69  Future<void> dispose();
 70
 71  /// Array of disconnect callbacks
 72  ///
 73  /// Disconnect callbacks are processed in reverse order, i.e. the latest added callback is executed as first. Callbacks
 74  /// can return true to indicate they have fully handled the disconnect. This will then also stop executing any remaining
 75  /// callbacks.
 76  final List<bool Function()> disconnectCallbacks = [];
 77
 78  /// Wait for the device state to change to a different value then disconnecting
 79  ///
 80  /// [timeout] - How long to wait before timeout occurs. A value of -1 disables waiting, a value of 0 waits indefinitely
 81  Future<void> _waitForDisconnectingStateChange({ int timeout = 300000 }) async {
 82    if (timeout < 0) {
 83      return;
 84    }
 85
 86    // Futures within an any still always resolve, it's just that the results
 87    // are disregard for futures that do not finish first. Use this bool to
 88    // keep track whether the futures are already completed or not
 89    bool futuresCompleted = false;
 90
 91    /// Waits and calls itself recursively as long as the current device [_state] equals [BluetoothDeviceState.disconnecting]
 92    Future<void> checkDeviceState() async {
 93      while (!futuresCompleted && _state == BluetoothDeviceState.disconnecting) {
 94        logger.finest('Waiting because device is still disconnecting');
 95        await Future.delayed(const Duration(milliseconds: 10));
 96      }
 97    }
 98
 99    final futures = [checkDeviceState()];
100    if (timeout > 0) {
101      futures.add(
102        Future.delayed(Duration(milliseconds: min(timeout, 300000))).then((_) {
103          if (!futuresCompleted) {
104            logger.finest('connect: Wait for state change timed out after $timeout ms');
105          }
106        })
107      );
108    }
109
110    await Future.any(futures);
111    futuresCompleted = true;
112  }
113
114  /// Connect to the device
115  ///
116  /// Always call [disconnect] when ready after calling connect
117  /// [onConnect] Called after device is connected
118  /// [onDisconnect] Called after device is disconnected, see [disconnectCallbacks]
119  /// [onError] Called when an error occurs
120  /// [waitForDisconnectingStateChangeTimeout] If connect is called while the device is still disconnecting, wait
121  ///   for the device to change it's state. A value of -1 means don't ever wait, a value of 0 means wait indefinitely
122  ///   Setting this timeout ensures correct state management of the device so users only have to call disconnect()/connect() 
123  Future<bool> connect({
124    VoidCallback? onConnect,
125    bool Function()? onDisconnect,
126    ValueSetter<Object>? onError,
127    int waitForDisconnectingStateChangeTimeout = 3000
128  }) async {
129    if (_state == BluetoothDeviceState.disconnecting) {
130      await _waitForDisconnectingStateChange(timeout: waitForDisconnectingStateChangeTimeout);
131    }
132
133    if (_state != BluetoothDeviceState.disconnected) {
134      return false;
135    }
136
137    _state = BluetoothDeviceState.connecting;
138
139    final completer = Completer<bool>();
140    logger.finer('connect: start connecting');
141
142    if (onDisconnect != null) {
143      disconnectCallbacks.add(onDisconnect);
144    }
145
146    await _connectionListener?.cancel();
147    _connectionListener = connectionStream.listen((BluetoothConnectionState state) {
148      logger.finest('connectionStream.listen[_state: $_state]: $state');
149
150      // Note: in this abstraction we want the device state to be singular. Unfortunately
151      // not all libraries on all platforms send only a single connection state event. F.e.
152      // flutter_blue_plus can send 3 disconnect events the very first time you try to connect
153      // with a device. These multiple similar events for the same device will break our logic
154      // so we need to filter the states.
155      switch (state) {
156        case BluetoothConnectionState.connected:
157          if (_state != BluetoothDeviceState.connecting) {
158            // Ignore status update if the current device state was not connecting. Cause then
159            // the library probably send multiple state update events.
160            logger.finest('Ignoring state update because device was not connecting: $_state');
161            return;
162          }
163
164          onConnect?.call();
165          if (!completer.isCompleted) completer.complete(true);
166          _state = BluetoothDeviceState.connected;
167          return;
168        case BluetoothConnectionState.disconnected:
169          if ([BluetoothDeviceState.connecting, BluetoothDeviceState.disconnected].any((s) => s == _state)) {
170            // Ignore status update if the state was connecting or already disconnected
171            logger.finest('Ignoring state update because device was already disconnected: $_state');
172            return;
173          }
174
175          for (final fn in disconnectCallbacks.reversed) {
176            if (fn()) {
177              // ignore other disconnect callbacks
178              break;
179            }
180          }
181
182          disconnectCallbacks.clear();
183          if (!completer.isCompleted) completer.complete(false);
184          _state = BluetoothDeviceState.disconnected;
185      }
186    }, onError: onError);
187
188    try {
189      await backendConnect();
190    } catch (e) {
191      logger.severe('Failed to connect to device', e);
192      if (!completer.isCompleted) completer.complete(false);
193      _state = BluetoothDeviceState.disconnected;
194    }
195
196    return completer.future.then((res) {
197      logger.finer('connect: completer.resolved($res)');
198      return res;
199    });
200  }
201
202  /// Disconnect & dispose the device
203  ///
204  /// Always call [disconnect] after calling [connect] to ensure all resources are disposed
205  /// Optionally specify [waitForStateChangeTimeout] in milliseconds to indicate how long we
206  /// should wait for the device to send a disconnect event. Specifying a value of -1 disables
207  /// waiting for the state change, a value of 0 means wait indefinitely.
208  Future<bool> disconnect({ int waitForStateChangeTimeout = 3000 }) async {
209    _state = BluetoothDeviceState.disconnecting;
210    await backendDisconnect();
211
212    if (waitForStateChangeTimeout > -1) {
213      await _waitForDisconnectingStateChange(timeout: waitForStateChangeTimeout);
214
215      assert(
216        _state == BluetoothDeviceState.disconnecting || _state == BluetoothDeviceState.disconnected,
217        'Expected state either to be disconnecting (due to timeout) or disconnected. Got $_state instead'
218      );
219    }
220
221    await _connectionListener?.cancel();
222    await dispose();
223    return true;
224  }
225
226  /// Discover all available services on the device
227  ///
228  /// It's recommended to use [getServices] instead
229  Future<List<BS>?> discoverServices();
230
231  /// Return all available services for this device
232  ///
233  /// Difference with [discoverServices] is that [getServices] memoizes the results
234  Future<List<BS>?> getServices() async {
235    final logServices = _services == null; // only log services on the first call
236    _services ??= await discoverServices();
237    if (_services == null) {
238      logger.finer('Failed to discoverServices on: $this');
239    }
240
241    if (logServices) {
242      logger.finest(_services
243        ?.map((s) => 'Found services\n$s:\n  - ${s.characteristics.join('\n  - ')}]')
244        .join('\n'));
245    }
246
247    return _services;
248  }
249
250  /// Returns the service with requested [uuid] or null if requested service is not available
251  Future<BS?> getServiceByUuid(BluetoothUuid uuid) async {
252    final services = await getServices();
253    return services?.firstWhereOrNull((service) => service.uuid == uuid);
254  }
255
256  /// Retrieves the value of [characteristic] from the device and calls [onValue] for all received values
257  /// 
258  /// This method provides a generic implementation for async reading of data, regardless whether the
259  /// characteristic can be read directly or through a notification or indication. In case the value
260  /// is being read using an indication, then the [onValue] callback receives a second argument [complete] with
261  /// which you can stop reading the data.
262  ///
263  /// Note that a [characteristic] could have multiple values, so [onValue] can be called more then once.
264  /// TODO: implement reading values for characteristics with [canNotify]
265  Future<bool> getCharacteristicValue(BC characteristic, void Function(Uint8List value, [void Function(bool success)? complete]) onValue);
266
267  @override
268  String toString() => 'BluetoothDevice{name: $name, deviceId: $deviceId}';
269
270  @override
271  /// Compare devices, only checking hashCode is not sufficient during tests as hashCode
272  /// of mocked classes seems to be always 0 hence why also comparing by deviceId
273  /// TODO: Understand why the mocked devices in the device_scan_cubit_test have the same hashCode=0 and are therefore not all added to the set
274  bool operator == (Object other) => other is BluetoothDevice && hashCode == other.hashCode && deviceId == other.deviceId;
275
276  @override
277  int get hashCode => deviceId.hashCode ^ name.hashCode;
278}
279
280/// Generic logic to implement an indication stream to read characteristic values
281mixin CharacteristicValueListener<
282  BM extends BluetoothManager,
283  BS extends BluetoothService,
284  BC extends BluetoothCharacteristic,
285  BackendDevice
286> on BluetoothDevice<BM, BS, BC, BackendDevice> {
287  /// List of read characteristic completers, used for cleanup on device disconnect
288  final List<Completer<bool>> _readCharacteristicCompleters = [];
289  /// List of read characteristic subscriptions, used for cleanup on device disconnect
290  final List<StreamSubscription<Uint8List?>> _readCharacteristicListeners = [];
291
292  /// Dispose of all resources used to read characteristics
293  ///
294  /// Internal method, should not be used by users
295  @protected
296  Future<void> disposeCharacteristics() async {
297    for (final completer in _readCharacteristicCompleters) {
298      // completing the completer also cancels the listener
299      completer.complete(false);
300    }
301
302    _readCharacteristicCompleters.clear();
303    _readCharacteristicListeners.clear();
304  }
305
306  /// Trigger notifications or indications for the [characteristic]
307  @protected
308  Future<bool> triggerCharacteristicValue(BC characteristic, [bool state = true]);
309
310  /// Read characteristic values from a stream
311  ///
312  /// It's not recommended to use this method directly, use [BluetoothDevice.getCharacteristicValue] instead
313  @protected
314  Future<bool> listenCharacteristicValue(
315    BC characteristic,
316    Stream<Uint8List?> characteristicValueStream,
317    void Function(Uint8List value, [void Function(bool success)? complete]) onValue
318  ) async {
319    if (!characteristic.canIndicate) {
320      return false;
321    }
322
323    final completer = Completer<bool>();
324    bool receivedSomeData = false;
325
326    bool disconnectCallback() {
327      logger.finer('listenCharacteristicValue(receivedSomeData: $receivedSomeData): onDisconnect called');
328      if (!receivedSomeData) {
329        return false;
330      }
331
332      completer.complete(true);
333      return true;
334    }
335
336    disconnectCallbacks.add(disconnectCallback);
337
338    final listener = characteristicValueStream.listen(
339      (value) {
340        if (value == null) {
341          // ignore null values
342          return;
343        }
344
345        logger.finer('listenCharacteristicValue[${value.length}] $value');
346
347        receivedSomeData = true;
348        onValue(value, completer.complete);
349      },
350      cancelOnError: true,
351      onDone: () {
352        logger.finer('listenCharacteristicValue: onDone called');
353        completer.complete(receivedSomeData);
354      },
355      onError: (Object err) {
356        logger.shout('listenCharacteristicValue: Error while reading characteristic', err);
357        completer.complete(false);
358      }
359    );
360
361    // track completer & listener so we can clean them up
362    // when the device unexpectedly disconnects (ie before
363    // any data has been received yet)
364    _readCharacteristicCompleters.add(completer);
365    _readCharacteristicListeners.add(listener);
366
367    try {
368      logger.finest('listenCharacteristicValue: triggering characteristic value');
369      final bool triggerSuccess = await triggerCharacteristicValue(characteristic);
370      if (!triggerSuccess) {
371        logger.warning('listenCharacteristicValue: triggerCharacteristicValue returned $triggerSuccess');
372      }
373    } catch (e) {
374      logger.severe('Error occured while triggering characteristic', e);
375    }
376
377    return completer.future.then((res) {
378      // Ensure listener is always cancelled when completer resolves
379      listener.cancel().then((_) => _readCharacteristicListeners.remove(listener));
380
381      // Remove stored completer reference
382      _readCharacteristicCompleters.remove(completer);
383
384      // Remove disconnect callback in case the connection was not automatically disconnected
385      if (disconnectCallbacks.remove(disconnectCallback)) {
386        logger.finer('listenCharacteristicValue: device was not automatically disconnected after completer finished, removing disconnect callback');
387      }
388
389      return res;
390    });
391  }
392}