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}