Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add bluetooth backend abstraction & support devices with multiple measurements #432

Open
wants to merge 92 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
92 commits
Select commit Hold shift + click to select a range
beb447f
feat: implement logging/logging package
pimlie Sep 12, 2024
a50ef6e
chore: extract some global configuration props
pimlie Sep 12, 2024
38c8aeb
feat: add bluetooth backend abstraction layers
pimlie Sep 12, 2024
ee1f931
feat: add support for bt devices that always return multiple measurem…
pimlie Sep 12, 2024
2670413
feat: implement new bluetooth backend abstraction layer
pimlie Sep 12, 2024
7f7c831
test: fix/update tests for new bluetooth logic
pimlie Sep 12, 2024
7f677f5
chore: remove some debug logs
pimlie Sep 12, 2024
9a8873b
chore: be consistent with Uuid naming
pimlie Sep 13, 2024
88fbcd5
test: add logging util tests
pimlie Sep 13, 2024
4ec6095
chore: abstract device conecct/disconnect logic as its implementation…
pimlie Sep 13, 2024
ccfc3c8
feat: add bluetooth information page
pimlie Sep 13, 2024
3f9ed1a
test: add test for ui/measurement_multiple.dart
pimlie Sep 13, 2024
080490a
chore: fix a code comment
pimlie Sep 13, 2024
7b35dcd
chore: improve bluetooth.md
pimlie Sep 13, 2024
b7870e8
chore: improve bluetooth.md
pimlie Sep 13, 2024
34194a3
chore: improve bluetooth.md
pimlie Sep 13, 2024
1b3b8ef
fix: log message if callback _was_ removed
pimlie Sep 13, 2024
467dc0c
chore: improve/fix code comments
pimlie Sep 13, 2024
30ff048
chore: extract ValueGraph as HomeScreen boolean & fix some code comments
pimlie Sep 13, 2024
1ffbf03
docs: add/change links to official bluetooth specs
pimlie Sep 13, 2024
0aa4418
chore: add blood pressure service specification to BLUETOOTH.md
pimlie Sep 16, 2024
9c16286
fix: apply suggestion
pimlie Sep 16, 2024
9b99c31
fix: improve code comment
pimlie Sep 16, 2024
b128949
chore: remove redundant code comment
pimlie Sep 16, 2024
6d6e7de
fix: cancel connection listener before listening (again)
pimlie Sep 16, 2024
3b895a6
fix: prefer using firstWhereOrNull
pimlie Sep 16, 2024
44e5990
chore: prefer inline if
pimlie Sep 16, 2024
fe14c58
chore: just use final var
pimlie Sep 16, 2024
83c1a22
chore: onDiscoveryError doesnt need to be protected anymore
pimlie Sep 16, 2024
847ee16
chore: cancel any existing discovery subscriptions
pimlie Sep 16, 2024
b849f7b
chore: update code comment
pimlie Sep 16, 2024
529f487
chore: add assert statement
pimlie Sep 16, 2024
cf03f8a
chore: remove todo as there is a ticket on github
pimlie Sep 16, 2024
c91457d
chore: remove automatic connection retry logic
pimlie Sep 16, 2024
bfee729
chore: remove unused class
pimlie Sep 16, 2024
29e2b2b
chore: prefer just using final
pimlie Sep 16, 2024
0e14180
chore: remove deprecated log methods & unneeded stacktrace parser
pimlie Sep 18, 2024
3d9e48f
chore: remove some config vars
pimlie Sep 18, 2024
5d35a69
chore: remove parts & fix test
pimlie Sep 18, 2024
05aaa14
chore: make discovered._devices a Set
pimlie Sep 19, 2024
5ed5054
Merge branch 'main' into feat-use-ble-package
derdilla Sep 20, 2024
ea74071
chore: refactor reading characateristic values + some fixes
pimlie Sep 20, 2024
06d9da5
chore: refactor BluetoothManager instantiation
derdilla Sep 20, 2024
7697c95
chore: migrate .transform to .map
derdilla Sep 20, 2024
c257878
chore(backend): revision code patterns, comments and formatting
derdilla Sep 20, 2024
2f767dc
chore: extract logging
derdilla Sep 20, 2024
aca47b7
chore(ui): revision code patterns, comments and formatting
derdilla Sep 20, 2024
92c8243
chore: remove excess nl localization
derdilla Sep 20, 2024
7cc448e
chore(logic): revision code patterns, comments and formatting
derdilla Sep 20, 2024
8a3d3e2
Update app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_de…
pimlie Sep 22, 2024
2a93308
fix: forceRefresh isnt async anymore
pimlie Sep 22, 2024
c34b551
fix: passthrough state arg
pimlie Sep 22, 2024
193ca76
chore: update code comment
pimlie Sep 22, 2024
e0e21a7
fix: use onClosed instead for measreuemnt_multiple state
pimlie Sep 22, 2024
9ad866a
chore: update lock file
pimlie Sep 23, 2024
692a022
test: fix arg
pimlie Sep 23, 2024
c322eda
Merge branch 'main' into feat-use-ble-package
pimlie Sep 23, 2024
3a672f1
fix: revert change that broke uuidcomparison
pimlie Sep 23, 2024
ce139ea
fix: requesting permissions on android is required
pimlie Sep 23, 2024
e8c4cce
fix: dont implement workflow in library method
pimlie Sep 26, 2024
2c97c7d
Merge branch 'main' into feat-use-ble-package
pimlie Sep 26, 2024
df24861
fix: resolve dependency issues
pimlie Sep 26, 2024
8af76ab
fix: try building android with flutter blue plus
pimlie Sep 26, 2024
e05e26f
fix: merge duplicate logic
pimlie Sep 26, 2024
1325c45
chore: always enable bluetooth when initiating bluetooth_cubit
pimlie Sep 26, 2024
d7e5e6e
chore: revert back to only enabling when not authorized
pimlie Sep 27, 2024
6ef22c7
fix: only resolve future when connect state changed
pimlie Sep 27, 2024
881072d
chore: debug with fbp
pimlie Sep 27, 2024
30c912b
chore: add error message to uuid length assert
pimlie Sep 27, 2024
caa1bb7
fix: fbp doesnt return a 128bit uuid for guid.tostring
pimlie Sep 27, 2024
948c025
chore: always build package for now
pimlie Sep 27, 2024
0a81e1a
chore: always build package for now (2)
pimlie Sep 27, 2024
ca7df73
chore: comment out condition
pimlie Sep 27, 2024
7204563
chore: set ref in code checkout to pr head
pimlie Sep 27, 2024
1a0cc7e
fix: use char uuid for fbo charecteristics
pimlie Sep 27, 2024
012845b
feat: better device state management & implement automatic retry afte…
pimlie Oct 1, 2024
4bff687
chore: increase max retry count
pimlie Oct 1, 2024
74b470e
chore: upgrade gradle to current version
pimlie Oct 16, 2024
0db33ff
chore: update code comments & lock file
pimlie Oct 16, 2024
fe86605
chore: add vscode files
pimlie Oct 16, 2024
c8cc50f
Merge branch 'main' into feat-use-ble-package
pimlie Oct 16, 2024
d9ea3ca
chore: upgrade gradle version
pimlie Oct 16, 2024
41d9aef
chore: return nullable boolean from manager.enable to prevent closed_…
pimlie Oct 16, 2024
3bb86de
revert: changes to pr workflow
pimlie Oct 17, 2024
0796b0e
chore: rework waiting for disconnecting state logic
pimlie Oct 17, 2024
48704eb
chore: reset state to disconnected after connect error
pimlie Oct 17, 2024
4ebcfa7
chore: typo
pimlie Oct 17, 2024
e9b1c73
chore: log error
pimlie Oct 17, 2024
1e35fbd
chore: remove debug code
pimlie Oct 17, 2024
d42883b
test: fix tests
pimlie Oct 17, 2024
89c7ada
test: ensure device comparison works also in tests
pimlie Oct 18, 2024
ebe7274
chore: add code comment about device comparison
pimlie Oct 18, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions BLUETOOTH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Supported Bluetooth devices

In general any device that supports [`Blood Pressure Service (0x1810)`](https://bitbucket.org/bluetooth-SIG/public/src/main/assigned_numbers/uuids/service_uuids.yaml#lines-77:79) could be used. The blood pressure measurement values are stored in the characteristic [`Blood Pressure Measurement (0x2A35)`](https://bitbucket.org/bluetooth-SIG/public/src/main/assigned_numbers/uuids/characteristic_uuids.yaml#lines-161:163)

## Reading caveats

There are some difference in how devices report their measurements.

Most devices provide 2 ways to retrieve measurements over bluetooth, but there are also difference in how those operate:

1. Immediately after taking a measurement
1. and returns all measurements stored in memory
2. and only returns the latest measurement
2. As a download mode
1. and automatically remove all locally stored measurements after a succesful download
2. and leave measurements untouched, i.e. the user needs to remove the stored measurements themselves

> :warning: At the moment situation 2.i is not well supported. Do not use this unless you are ok with loosing previously stored measurements

## Known working devices

> If your device is not listed please edit this page and add it! :bow:

|Device|Bluetooth name|Read after measurement|Download mode|Automatically disconnects after reading|
pimlie marked this conversation as resolved.
Show resolved Hide resolved
|---|---| :---: | :---: | :---: |
|HealthForYou by Silvercrest (Type SBM 69)|SBM69| :1234: | :white_check_mark: | :white_check_mark: |
|Omron X4 Smart|X4 Smart| :one: | :white_check_mark::wastebasket: | :white_check_mark: |

#### Legenda

|Icon|Description|
| :---: | --- |
| :no_entry_sign: |Not supported / No|
| :white_check_mark: |Supported / Yes|
| :one: | Returns latest measurement|
| :1234: | Returns all measurements|
| :white_check_mark::wastebasket: |Supported and removes all locally stored measurements|

## Specifications

- Blood Pressure Service: https://www.bluetooth.com/specifications/specs/blood-pressure-service-1-1-1/
- Assigned Numbers (f.e. service & characteristic UUID's): https://www.bluetooth.com/specifications/assigned-numbers/
- GATT Specification Supplement (f.e. data structures): https://www.bluetooth.com/specifications/gss/
- Current Time Service: https://www.bluetooth.com/specifications/specs/current-time-service-1-1/
10 changes: 10 additions & 0 deletions app/lib/config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import 'dart:io';

/// Whether bluetooth is supported on this platform by this app
final isPlatformSupportedBluetooth = Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux;

/// Whether we are running in a test environment
final isTestingEnvironment = Platform.environment['FLUTTER_TEST'] == 'true';

/// Whether the value graph should be shown as home screen in landscape mode
final showValueGraphAsHomeScreenInLandscapeMode = isTestingEnvironment || !Platform.isLinux;
4 changes: 2 additions & 2 deletions app/lib/data_util/entry_context.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ extension EntryUtils on BuildContext {
}
}
} on ProviderNotFoundException {
Log.err('createEntry($initial) was called from a context without Provider.');
log.severe('[extension.EntryUtils] createEntry($initial) was called from a context without Provider.');
} catch (e, stack) {
await ErrorReporting.reportCriticalError('Error opening add measurement dialoge', '$e\n$stack',);
}
Expand Down Expand Up @@ -93,7 +93,7 @@ extension EntryUtils on BuildContext {
),);
}
} on ProviderNotFoundException {
Log.err('deleteEntry($entry) was called from a context without Provider.');
log.severe('[extension.EntryUtils] deleteEntry($entry) was called from a context without Provider.');
}
}
}
6 changes: 6 additions & 0 deletions app/lib/features/bluetooth/backend/bluetooth_backend.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// Utility import that only exposes the bluetooth backend services that should be used.
library;

export 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart' show BluetoothDevice;
export 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_manager.dart' show BluetoothManager;
export 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart' show BluetoothAdapterState;
11 changes: 11 additions & 0 deletions app/lib/features/bluetooth/backend/bluetooth_connection.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@



/// State of the bluetooth connection of a device
enum BluetoothConnectionState {
pimlie marked this conversation as resolved.
Show resolved Hide resolved
/// Device is connected
connected,
/// Device is disconnect
disconnected;
}

287 changes: 287 additions & 0 deletions app/lib/features/bluetooth/backend/bluetooth_device.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@

import 'dart:async';

import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_connection.dart';
import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_manager.dart';
import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_service.dart';
import 'package:blood_pressure_app/logging.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';

/// Wrapper class for bluetooth implementations to generically expose required functionality
abstract class BluetoothDevice<
BM extends BluetoothManager,
BS extends BluetoothService,
BC extends BluetoothCharacteristic,
BackendDevice
> with TypeLogger {
/// Create a new BluetoothLowEnergyDevice
///
/// [manager] Manager the device belongs to
/// [source] Device implementation of the current backend
BluetoothDevice(this.manager, this.source) {
logger.finer('init device: $this');
}

/// BluetoothManager this device belongs to
final BM manager;

/// Original source device as returned by the backend
final BackendDevice source;

/// (Unique?) id of the device
String get deviceId;

/// Name of the device
String get name;

/// Memoized service list for the device
List<BS>? _services;

bool _isConnected = false;
derdilla marked this conversation as resolved.
Show resolved Hide resolved
StreamSubscription<BluetoothConnectionState>? _connectionListener;

/// Whether the device is connected
bool get isConnected => _isConnected;

/// Stream to listen to for connection state changes after connecting to a device
Stream<BluetoothConnectionState> get connectionStream;

/// Backend implementation to connect to the device
Future<void> backendConnect();
/// Backend implementation to disconnect to the device
Future<void> backendDisconnect();

/// Require backends to implement a dispose method to cleanup any resources
Future<void> dispose();

/// Array of disconnect callbacks
///
/// Disconnect callbacks are processed in reverse order, i.e. the latest added callback is executed as first. Callbacks
pimlie marked this conversation as resolved.
Show resolved Hide resolved
/// can return true to indicate they have fully handled the disconnect. This will then also stop executing any remaining
/// callbacks.
final List<bool Function()> disconnectCallbacks = [];

/// Connect to the device
///
/// Always call disconnect when ready after calling connect
Future<bool> connect({ VoidCallback? onConnect, bool Function()? onDisconnect, ValueSetter<Object>? onError, int maxTries = 5 }) {
final completer = Completer<bool>();
logger.finer('connect: Init');

if (onDisconnect != null) {
disconnectCallbacks.add(onDisconnect);
}

_connectionListener?.cancel();
_connectionListener = connectionStream.listen((BluetoothConnectionState state) {
pimlie marked this conversation as resolved.
Show resolved Hide resolved
logger.finest('connectionStream.listen[isConnected: $_isConnected]: $state');

switch (state) {
case BluetoothConnectionState.connected:
onConnect?.call();
if (!completer.isCompleted) completer.complete(true);
_isConnected = true;
return;
case BluetoothConnectionState.disconnected:
for (final fn in disconnectCallbacks.reversed) {
if (fn()) {
// ignore other disconnect callbacks
break;
}
}

disconnectCallbacks.clear();
pimlie marked this conversation as resolved.
Show resolved Hide resolved
if (!completer.isCompleted) completer.complete(false);
_isConnected = false;
}
}, onError: onError);

backendConnect();
return completer.future.then((res) {
logger.finer('connect: completer.resolved($res)');
return res;
});
}

/// Disconnect & dispose the device
///
/// Always call [disconnect] after calling [connect] to ensure all resources are disposed
Future<bool> disconnect() async {
await _connectionListener?.cancel();
await backendDisconnect();
await dispose();
return true;
}

/// Discover all available services on the device
///
/// It's recommended to use [getServices] instead
Future<List<BS>?> discoverServices();

/// Return all available services for this device
///
/// Difference with [discoverServices] is that [getServices] memoizes the results
Future<List<BS>?> getServices() async {
final logServices = _services == null; // only log services on the first call
_services ??= await discoverServices();
if (_services == null) {
logger.finer('Failed to discoverServices on: $this');
derdilla marked this conversation as resolved.
Show resolved Hide resolved
}

if (logServices) {
final services = _services ?? [];
for (final service in services) {
logger.finest('$service');
if (service.characteristics.isEmpty) {
logger.finest(' [no characteristics]');
}
for (final characteristic in service.characteristics) {
logger.finest(' $characteristic');
}
}
}

return _services;
}

/// Returns the service with requested [uuid] or null if requested service is not available
Future<BS?> getServiceByUuid(BluetoothUuid uuid) async {
final services = await getServices();
return services?.firstWhereOrNull((service) => service.uuid == uuid);
}

/// Retrieves the value of [characteristic] from the device and calls [onValue] for all received values
///
/// This method provides a generic implementation for async reading of data, regardless whether the
/// characteristic can be read directly or through a notification or indication. In case the value
/// is being read using an indication, then the [onValue] callback receives a second argument [complete] with
/// which you can stop reading the data.
///
/// Note that a [characteristic] could have multiple values, so [onValue] can be called more then once.
/// TODO: implement reading values for characteristics with [canNotify]
Future<bool> getCharacteristicValue(BC characteristic, void Function(Uint8List value, [void Function(bool success)? complete]) onValue);
derdilla marked this conversation as resolved.
Show resolved Hide resolved

@override
String toString() => 'BluetoothDevice{name: $name, deviceId: $deviceId}';

@override
bool operator == (Object other) {
if (other is BluetoothDevice) {
return hashCode == other.hashCode;
}

return false;
}

@override
int get hashCode => Object.hash(deviceId, name);
}

/// Generic logic to implement an indication stream to read characteristic values
mixin CharacteristicValueListener<
BM extends BluetoothManager,
BS extends BluetoothService,
BC extends BluetoothCharacteristic,
BackendDevice
> on BluetoothDevice<BM, BS, BC, BackendDevice> {
/// List of read characteristic completers, used for cleanup on device disconnect
final List<Completer<bool>> _readCharacteristicCompleters = [];
/// List of read characteristic subscriptions, used for cleanup on device disconnect
final List<StreamSubscription<Uint8List?>> _readCharacteristicListeners = [];

/// Dispose of all resources used to read characteristics
///
/// Internal method, should not be used by users
@protected
Future<void> disposeCharacteristics() async {
for (final completer in _readCharacteristicCompleters) {
// completing the completer also cancels the listener
completer.complete(false);
}

_readCharacteristicCompleters.clear();
_readCharacteristicListeners.clear();
}

/// Trigger notifications or indications for the [characteristic]
@protected
Future<bool> triggerCharacteristicValue(BC characteristic, [bool state = true]);

/// Read characteristic values from a stream
///
/// It's not recommended to use this method directly, use [BluetoothDevice.getCharacteristicValue] instead
@protected
Future<bool> listenCharacteristicValue(
BC characteristic,
Stream<Uint8List?> characteristicValueStream,
void Function(Uint8List value, [void Function(bool success)? complete]) onValue
) async {
if (!characteristic.canIndicate) {
return false;
}

final completer = Completer<bool>();
bool receivedSomeData = false;

bool disconnectCallback() {
logger.finer('listenCharacteristicValue(receivedSomeData: $receivedSomeData): onDisconnect called');
if (!receivedSomeData) {
return false;
}

completer.complete(true);
return true;
}

disconnectCallbacks.add(disconnectCallback);

final listener = characteristicValueStream.listen((value) {
if (value == null) {
// ignore null values
return;
}

logger.finer('listenCharacteristicValue[${value.length}] $value');

receivedSomeData = true;
onValue(value, completer.complete);
},
cancelOnError: true,
onDone: () {
logger.finer('listenCharacteristicValue: onDone called');
completer.complete(receivedSomeData);
},
onError: (Object err) {
logger.shout('listenCharacteristicValue: Error while reading characteristic', err);
completer.complete(false);
}
);

// track completer & listener so we can clean them up
// when the device unexpectedly disconnects (ie before
// any data has been received yet)
_readCharacteristicCompleters.add(completer);
_readCharacteristicListeners.add(listener);

final bool triggerSuccess = await triggerCharacteristicValue(characteristic);
if (!triggerSuccess) {
logger.warning('listenCharacteristicValue: triggerCharacteristicValue returned $triggerSuccess');
}

return completer.future.then((res) {
// Ensure listener is always cancelled when completer resolves
listener.cancel().then((_) => _readCharacteristicListeners.remove(listener));

// Remove stored completer reference
_readCharacteristicCompleters.remove(completer);

// Remove disconnect callback in case the connection was not automatically disconnected
if (disconnectCallbacks.remove(disconnectCallback)) {
logger.finer('listenCharacteristicValue: device was not automatically disconnected after completer finished, removing disconnect callback');
}

return res;
});
}
}
Loading