Commit d296182

Pim <pimlie@hotmail.com>
2024-11-11 19:42:54
feat: add bluetooth backend abstraction & support devices with multiple measurements (#432)
* feat: implement logging/logging package * chore: extract some global configuration props * feat: add bluetooth backend abstraction layers * feat: add support for bt devices that always return multiple measurements * feat: implement new bluetooth backend abstraction layer * test: fix/update tests for new bluetooth logic * chore: remove some debug logs * chore: be consistent with Uuid naming * test: add logging util tests * chore: abstract device conecct/disconnect logic as its implementation was 99% similar across backends * feat: add bluetooth information page * test: add test for ui/measurement_multiple.dart * chore: fix a code comment * chore: improve bluetooth.md * chore: improve bluetooth.md * chore: improve bluetooth.md * fix: log message if callback _was_ removed * chore: improve/fix code comments * chore: extract ValueGraph as HomeScreen boolean & fix some code comments * docs: add/change links to official bluetooth specs * chore: add blood pressure service specification to BLUETOOTH.md * fix: apply suggestion Co-authored-by: derdilla <derdilla06@gmail.com> * fix: improve code comment Co-authored-by: derdilla <derdilla06@gmail.com> * chore: remove redundant code comment * fix: cancel connection listener before listening (again) * fix: prefer using firstWhereOrNull * chore: prefer inline if * chore: just use final var * chore: onDiscoveryError doesnt need to be protected anymore * chore: cancel any existing discovery subscriptions * chore: update code comment * chore: add assert statement * chore: remove todo as there is a ticket on github Co-authored-by: derdilla <derdilla06@gmail.com> * chore: remove automatic connection retry logic * chore: remove unused class * chore: prefer just using final * chore: remove deprecated log methods & unneeded stacktrace parser * chore: remove some config vars * chore: remove parts & fix test * chore: make discovered._devices a Set * chore: refactor reading characateristic values + some fixes * chore: refactor BluetoothManager instantiation * chore: migrate .transform to .map * chore(backend): revision code patterns, comments and formatting * chore: extract logging * chore(ui): revision code patterns, comments and formatting * chore: remove excess nl localization * chore(logic): revision code patterns, comments and formatting * Update app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_device.dart Co-authored-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com> * fix: forceRefresh isnt async anymore * fix: passthrough state arg * chore: update code comment * fix: use onClosed instead for measreuemnt_multiple state * chore: update lock file * test: fix arg * fix: revert change that broke uuidcomparison * fix: requesting permissions on android is required * fix: dont implement workflow in library method * fix: resolve dependency issues * fix: try building android with flutter blue plus * fix: merge duplicate logic * chore: always enable bluetooth when initiating bluetooth_cubit * chore: revert back to only enabling when not authorized * fix: only resolve future when connect state changed * chore: debug with fbp * chore: add error message to uuid length assert * fix: fbp doesnt return a 128bit uuid for guid.tostring * chore: always build package for now * chore: always build package for now (2) * chore: comment out condition * chore: set ref in code checkout to pr head * fix: use char uuid for fbo charecteristics * feat: better device state management & implement automatic retry after disconnect * chore: increase max retry count * chore: upgrade gradle to current version build fails otherwise * chore: update code comments & lock file * chore: add vscode files * chore: upgrade gradle version * chore: return nullable boolean from manager.enable to prevent closed_bluetooth_input from openening appsettings on non android devices chore: improve some logs/formatting * revert: changes to pr workflow * chore: rework waiting for disconnecting state logic * chore: reset state to disconnected after connect error Co-authored-by: derdilla <82763757+derdilla@users.noreply.github.com> * chore: typo Co-authored-by: derdilla <82763757+derdilla@users.noreply.github.com> * chore: log error Co-authored-by: derdilla <82763757+derdilla@users.noreply.github.com> * chore: remove debug code * test: fix tests * test: ensure device comparison works also in tests * chore: add code comment about device comparison --------- Co-authored-by: derdilla <derdilla06@gmail.com> Co-authored-by: derdilla <82763757+NobodyForNothing@users.noreply.github.com> Co-authored-by: derdilla <82763757+derdilla@users.noreply.github.com>
1 parent b827043
Changed files (71)
.vscode
app
android
lib
macos
test
windows
.vscode/extensions.json
@@ -0,0 +1,6 @@
+{
+    "recommendations": [
+        "dart-code.flutter",
+        "dart-code.dart-code"
+    ]
+}
\ No newline at end of file
.vscode/launch.json
@@ -0,0 +1,18 @@
+{
+    // Use IntelliSense to learn about possible attributes.
+    // Hover to view descriptions of existing attributes.
+    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "name": "blood-pressure-monitor",
+            "type": "dart",
+            "request": "launch",
+            "cwd": "${workspaceFolder}/app",
+            "program": "lib/main.dart",
+            "args": [
+                "--flavor", "github"
+            ]
+        }
+    ]
+}
\ No newline at end of file
app/android/gradle/wrapper/gradle-wrapper.properties
@@ -3,4 +3,4 @@ distributionPath=wrapper/dists
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
 distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
-distributionSha256Sum=2ab88d6de2c23e6adae7363ae6e29cbdd2a709e992929b48b6530fd0c7133bd6
+distributionSha256Sum=2ab88d6de2c23e6adae7363ae6e29cbdd2a709e992929b48b6530fd0c7133bd6
\ No newline at end of file
app/lib/data_util/entry_context.dart
@@ -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',);
     }
@@ -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.');
     }
   }
 }
app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_device.dart
@@ -0,0 +1,124 @@
+import 'dart:async';
+import 'dart:typed_data';
+
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_connection.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_manager.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_service.dart';
+import 'package:bluetooth_low_energy/bluetooth_low_energy.dart' show CentralManager, ConnectionState, DiscoveredEventArgs, PeripheralConnectionStateChangedEventArgs;
+
+/// BluetoothDevice implementation for the 'bluetooth_low_energy' package
+final class BluetoothLowEnergyDevice
+  extends BluetoothDevice<
+    BluetoothLowEnergyManager,
+    BluetoothLowEnergyService,
+    BluetoothLowEnergyCharacteristic,
+    DiscoveredEventArgs
+  > with CharacteristicValueListener<
+    BluetoothLowEnergyManager,
+    BluetoothLowEnergyService,
+    BluetoothLowEnergyCharacteristic,
+    DiscoveredEventArgs
+  >
+{
+  /// Init BluetoothDevice implementation for the 'bluetooth_low_energy' package
+  BluetoothLowEnergyDevice(super.manager, super.source);
+
+  @override
+  String get deviceId => source.peripheral.uuid.toString();
+
+  @override
+  String get name => source.advertisement.name ?? deviceId;
+
+  CentralManager get _cm => manager.backend;
+
+  @override
+  Stream<BluetoothConnectionState> get connectionStream => _cm.connectionStateChanged
+    .map((PeripheralConnectionStateChangedEventArgs rawState) => switch (rawState.state) {
+      ConnectionState.connected => BluetoothConnectionState.connected,
+      ConnectionState.disconnected => BluetoothConnectionState.disconnected,
+    });
+
+  @override
+  Future<void> backendConnect() => _cm.connect(source.peripheral);
+
+  @override
+  Future<void> backendDisconnect() => _cm.disconnect(source.peripheral);
+
+  @override
+  Future<void> dispose() => disposeCharacteristics();
+
+  @override
+  Future<List<BluetoothLowEnergyService>?> discoverServices() async {
+    if (!isConnected) {
+      logger.finer('discoverServices: device not connect. Call device.connect() first');
+      return null;
+    }
+
+    // Query actual services supported by the device. While they must be
+    // rediscovered when a disconnect happens, this object is also recreated.
+    try {
+      final rawServices = await _cm.discoverGATT(source.peripheral);
+      logger.finer('discoverServices: $rawServices');
+      return rawServices.map(BluetoothLowEnergyService.fromSource).toList();
+    } catch (e) {
+      logger.shout('discoverServices: error:', [source.peripheral, e]);
+      return null;
+    }
+  }
+  
+  @override
+  Future<bool> triggerCharacteristicValue(BluetoothLowEnergyCharacteristic characteristic, [bool state = true]) async {
+    await _cm.setCharacteristicNotifyState(source.peripheral, characteristic.source, state: state);
+    return true;
+  }
+
+  @override
+  Future<bool> getCharacteristicValue(
+    BluetoothLowEnergyCharacteristic characteristic,
+    void Function(Uint8List value, [void Function(bool success)? complete]) onValue,
+  ) async {
+    if (!isConnected) {
+      assert(false, 'getCharacteristicValue: device not connected. Call device.connect() first');
+      logger.finer('getCharacteristicValue: device not connected.');
+      return false;
+    }
+
+    if (characteristic.canRead) { // Read characteristic value if supported
+      try {
+        final data = await _cm.readCharacteristic(
+          source.peripheral,
+          characteristic.source,
+        );
+
+        onValue(data);
+        return true;
+      } catch (err) {
+        logger.warning('getCharacteristicValue: ble readCharacteristic failed', err);
+        return false;
+      }
+    }
+
+    else if (characteristic.canIndicate) { // Listen for characteristic value and trigger the device to send it
+      return listenCharacteristicValue(
+        characteristic,
+        _cm.characteristicNotified.map((eventArgs) {
+          if (characteristic.source != eventArgs.characteristic) {
+            /// characteristicNotified is a generic stream which ble does not
+            /// pre-filter for just the current requested characteristic
+            logger.finer('    data is for a different characteristic');
+            logger.finest('    ${eventArgs.characteristic.uuid} == ${characteristic.source.uuid} => ${eventArgs.characteristic == characteristic.source}');
+            logger.finest('    ${eventArgs.characteristic} == ${characteristic.source} => ${eventArgs.characteristic == characteristic.source}');
+            return null;
+          }
+
+          return eventArgs.value;
+        }),
+        onValue
+      );
+    }
+
+    logger.severe("Can't read or indicate characteristic: $characteristic");
+    return false;
+  }
+}
app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_discovery.dart
@@ -0,0 +1,31 @@
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_discovery.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_manager.dart';
+import 'package:bluetooth_low_energy/bluetooth_low_energy.dart' show UUID;
+
+/// BluetoothDeviceDiscovery implementation for the 'bluetooth_low_energy' package
+final class BluetoothLowEnergyDiscovery extends BluetoothDeviceDiscovery<BluetoothLowEnergyManager> {
+  /// Construct BluetoothDeviceDiscovery implementation for the 'bluetooth_low_energy' package
+  BluetoothLowEnergyDiscovery(super.manager);
+
+  @override
+  Stream<List<BluetoothDevice>> get discoverStream => manager.backend.discovered.map(
+    (device) => [manager.createDevice(device)]
+  );
+
+  @override
+  Future<void> backendStart(String serviceUuid) async {
+    try {
+      await manager.backend.startDiscovery(
+        // no timeout, the user knows best how long scanning is needed
+        serviceUUIDs: [UUID.fromString(serviceUuid)],
+        // Not all devices might be found using this configuration
+      );
+    } catch (e) {
+      onDiscoveryError(e);
+    }
+  }
+
+  @override
+  Future<void> backendStop() => manager.backend.stopDiscovery();
+}
app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_manager.dart
@@ -0,0 +1,64 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_device.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_discovery.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_service.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_state.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_manager.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart';
+import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
+
+/// Bluetooth manager for the 'bluetooth_low_energy' package
+final class BluetoothLowEnergyManager extends BluetoothManager<DiscoveredEventArgs, UUID, GATTService, GATTCharacteristic> {
+  /// constructor
+  BluetoothLowEnergyManager() {
+    logger.fine('init');
+
+    // Sync current adapter state
+    _adapterStateParser.parseAndCache(BluetoothLowEnergyStateChangedEventArgs(backend.state));
+  }
+
+  @override
+  Future<bool?> enable() async {
+    if (!Platform.isAndroid) {
+      return null;
+    }
+
+    return backend.authorize();
+  }
+
+  /// The actual backend implementation
+  final CentralManager backend = CentralManager();
+
+  final BluetoothLowEnergyStateParser _adapterStateParser = BluetoothLowEnergyStateParser();
+
+  @override
+  BluetoothAdapterState get lastKnownAdapterState => _adapterStateParser.lastKnownState;
+
+  @override
+  Stream<BluetoothAdapterState> get stateStream => backend.stateChanged.map(_adapterStateParser.parse);
+
+  BluetoothLowEnergyDiscovery? _discovery;
+
+  @override
+  BluetoothLowEnergyDiscovery get discovery {
+    _discovery ??= BluetoothLowEnergyDiscovery(this);
+    return _discovery!;
+  }
+
+  @override
+  BluetoothLowEnergyDevice createDevice(DiscoveredEventArgs device) => BluetoothLowEnergyDevice(this, device);
+
+  @override
+  BluetoothLowEnergyUUID createUuid(UUID uuid) => BluetoothLowEnergyUUID(uuid);
+
+  @override
+  BluetoothLowEnergyUUID createUuidFromString(String uuid) => BluetoothLowEnergyUUID.fromString(uuid);
+
+  @override
+  BluetoothLowEnergyService createService(GATTService service) => BluetoothLowEnergyService.fromSource(service);
+
+  @override
+  BluetoothLowEnergyCharacteristic createCharacteristic(GATTCharacteristic characteristic) => BluetoothLowEnergyCharacteristic.fromSource(characteristic);
+}
app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_service.dart
@@ -0,0 +1,45 @@
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_service.dart';
+import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
+
+/// UUID wrapper for BluetoothLowEnergy
+final class BluetoothLowEnergyUUID extends BluetoothUuid<UUID> {
+  /// Create a [BluetoothLowEnergyUUID] from a [UUID]
+  BluetoothLowEnergyUUID(UUID uuid): super(uuid: uuid.toString(), source: uuid);
+
+  /// Create a [BluetoothLowEnergyUUID] from a [String]
+  factory BluetoothLowEnergyUUID.fromString(String uuid) => BluetoothLowEnergyUUID(UUID.fromString(uuid));
+}
+
+/// Wrapper class with generic interface for a [GATTService]
+final class BluetoothLowEnergyService
+    extends BluetoothService<GATTService, BluetoothLowEnergyCharacteristic> {
+
+   /// Create a [BluetoothLowEnergyService] from a [GATTService]
+  BluetoothLowEnergyService.fromSource(GATTService service):
+    super(uuid: BluetoothLowEnergyUUID(service.uuid), source: service);
+
+  @override
+  List<BluetoothLowEnergyCharacteristic> get characteristics => source.characteristics.map(BluetoothLowEnergyCharacteristic.fromSource).toList();
+}
+
+/// Wrapper class with generic interface for a [GATTCharacteristic]
+final class BluetoothLowEnergyCharacteristic extends BluetoothCharacteristic<GATTCharacteristic> {
+  /// Create a [BluetoothLowEnergyCharacteristic] from the backend specific source
+  BluetoothLowEnergyCharacteristic.fromSource(GATTCharacteristic source):
+    super(uuid: BluetoothLowEnergyUUID(source.uuid), source: source);
+
+  @override
+  bool get canRead => source.properties.contains(GATTCharacteristicProperty.read);
+
+  @override
+  bool get canWrite => source.properties.contains(GATTCharacteristicProperty.write);
+
+  @override
+  bool get canWriteWithoutResponse => source.properties.contains(GATTCharacteristicProperty.writeWithoutResponse);
+
+  @override
+  bool get canNotify => source.properties.contains(GATTCharacteristicProperty.notify);
+
+  @override
+  bool get canIndicate => source.properties.contains(GATTCharacteristicProperty.indicate);
+}
app/lib/features/bluetooth/backend/bluetooth_low_energy/ble_state.dart
@@ -0,0 +1,17 @@
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart';
+import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
+
+/// Bluetooth adapter state parser for the 'bluetooth_low_energy' package
+final class BluetoothLowEnergyStateParser extends BluetoothAdapterStateParser<BluetoothLowEnergyStateChangedEventArgs> {
+  @override
+  BluetoothAdapterState parse(BluetoothLowEnergyStateChangedEventArgs rawState) => switch (rawState.state) {
+    BluetoothLowEnergyState.unsupported => BluetoothAdapterState.unfeasible,
+    // Bluetooth permissions should always be granted on normal android
+    // devices. Users on non-standard android devices will know how to
+    // enable them. If this is not the case there will be bug reports.
+    BluetoothLowEnergyState.unauthorized => BluetoothAdapterState.unauthorized,
+    BluetoothLowEnergyState.poweredOn => BluetoothAdapterState.ready,
+    BluetoothLowEnergyState.poweredOff => BluetoothAdapterState.disabled,
+    BluetoothLowEnergyState.unknown => BluetoothAdapterState.initial,
+  };
+}
app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_device.dart
@@ -0,0 +1,109 @@
+import 'dart:async';
+
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_connection.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_manager.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_service.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp;
+
+/// BluetoothDevice implementation for the 'flutter_blue_plus' package
+final class FlutterBluePlusDevice
+  extends BluetoothDevice<
+    FlutterBluePlusManager,
+    FlutterBluePlusService,
+    FlutterBluePlusCharacteristic,
+    fbp.ScanResult
+  >
+  with CharacteristicValueListener<
+    FlutterBluePlusManager,
+    FlutterBluePlusService,
+    FlutterBluePlusCharacteristic,
+    fbp.ScanResult
+  >
+{
+  /// Initialize BluetoothDevice implementation for the 'flutter_blue_plus' package
+  FlutterBluePlusDevice(super.manager, super.source);
+
+  @override
+  String get deviceId => source.device.remoteId.str;
+
+  @override
+  String get name => source.device.platformName;
+
+  @override
+  Stream<BluetoothConnectionState> get connectionStream => source.device.connectionState
+    .map((fbp.BluetoothConnectionState rawState) => switch (rawState) {
+      fbp.BluetoothConnectionState.connected => BluetoothConnectionState.connected,
+      fbp.BluetoothConnectionState.disconnected => BluetoothConnectionState.disconnected,
+      // code should never reach here
+      fbp.BluetoothConnectionState.connecting || fbp.BluetoothConnectionState.disconnecting
+        => throw ErrorDescription('Unsupported connection state: $rawState'),
+    });
+
+  @override
+  Future<void> backendConnect() => source.device.connect();
+
+  @override
+  Future<void> backendDisconnect() => source.device.disconnect();
+
+  @override
+  Future<void> dispose() => disposeCharacteristics();
+
+  @override
+  Future<List<FlutterBluePlusService>?> discoverServices() async {
+    if (!isConnected) {
+      logger.finer('Device not connected, cannot discover services');
+      return null;
+    }
+
+    // Query actual services supported by the device. While they must be
+    // rediscovered when a disconnect happens, this object is also recreated.
+    try {
+      final allServices = await source.device.discoverServices();
+      logger.finer('fbp.discoverServices: $allServices');
+
+      return allServices.map(FlutterBluePlusService.fromSource).toList();
+    } catch (e) {
+      logger.shout('Error on service discovery', [source.device, e]);
+      return null;
+    }
+  }
+
+  @override
+  Future<bool> triggerCharacteristicValue(FlutterBluePlusCharacteristic characteristic, [bool state = true]) => characteristic.source.setNotifyValue(state);
+
+  @override
+  Future<bool> getCharacteristicValue(
+    FlutterBluePlusCharacteristic characteristic,
+    void Function(Uint8List value, [void Function(bool success)? complete]) onValue
+  ) async {
+    if (!isConnected) {
+      assert(false, 'getCharacteristicValue: device not connected. Call device.connect() first');
+      logger.finer('getCharacteristicValue: device not connected.');
+      return false;
+    }
+
+    if (characteristic.canRead) { // Read characteristic value if supported
+      try {
+        final data = await characteristic.source.read();
+        onValue(Uint8List.fromList(data));
+        return true;
+      } catch (err) {
+        logger.severe('getCharacteristicValue(read error)', err);
+        return false;
+      }
+    }
+
+    if (characteristic.canIndicate) { // Listen for characteristic value and trigger the device to send it
+      return listenCharacteristicValue(
+        characteristic,
+        characteristic.source.onValueReceived.map(Uint8List.fromList),
+        onValue
+      );
+    }
+
+    logger.severe("Can't read or indicate characteristic: $characteristic");
+    return false;
+  }
+}
app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_discovery.dart
@@ -0,0 +1,31 @@
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_discovery.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_manager.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart' show Guid;
+
+/// BluetoothDeviceDiscovery implementation for the 'flutter_blue_plus' package
+final class FlutterBluePlusDiscovery extends BluetoothDeviceDiscovery<FlutterBluePlusManager> {
+  /// constructor
+  FlutterBluePlusDiscovery(super.manager);
+
+  @override
+  Stream<List<BluetoothDevice>> get discoverStream => manager.backend.scanResults.map(
+    (devices) => devices.map(manager.createDevice).toList()
+  );
+
+  @override
+  Future<void> backendStart(String serviceUuid) async {
+    try {
+      await manager.backend.startScan(
+        // no timeout, the user knows best how long scanning is needed
+        withServices: [ Guid(serviceUuid) ],
+        // Not all devices are found using this configuration (https://pub.dev/packages/flutter_blue_plus#scanning-does-not-find-my-device).
+      );
+    } catch (e) {
+      onDiscoveryError(e);
+    }
+  }
+
+  @override
+  Future<void> backendStop() => manager.backend.stopScan();
+}
app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_manager.dart
@@ -0,0 +1,68 @@
+
+
+import 'dart:io';
+
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_manager.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_device.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_discovery.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_service.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_state.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/flutter_blue_plus_mockable.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp;
+import 'package:flutter_blue_plus/flutter_blue_plus.dart' show Guid, ScanResult;
+
+/// Bluetooth manager for the 'flutter_blue_plus' package
+class FlutterBluePlusManager extends BluetoothManager<ScanResult, Guid, fbp.BluetoothService, fbp.BluetoothCharacteristic> {
+  /// constructor
+  FlutterBluePlusManager([FlutterBluePlusMockable? backend]): backend = (backend ?? FlutterBluePlusMockable()) {
+    logger.finer('init');
+  }
+
+  /// backend implementation
+  final FlutterBluePlusMockable backend;
+
+  @override
+  Future<bool?> enable() async {
+    if (Platform.isAndroid) {
+      await backend.turnOn();
+      return true;
+    }
+    return null;
+  }
+
+  @override
+  BluetoothAdapterState get lastKnownAdapterState {
+    // Check whether our lastKnownState is the same as FlutterBluePlus's
+    assert(_adapterStateParser.parse(backend.adapterStateNow) == _adapterStateParser.lastKnownState);
+    return _adapterStateParser.lastKnownState;
+  }
+
+  final FlutterBluePlusStateParser _adapterStateParser = FlutterBluePlusStateParser();
+
+  @override
+  Stream<BluetoothAdapterState> get stateStream => backend.adapterState.map(_adapterStateParser.parse);
+
+  FlutterBluePlusDiscovery? _discovery;
+
+  @override
+  FlutterBluePlusDiscovery get discovery {
+    _discovery ??= FlutterBluePlusDiscovery(this);
+    return _discovery!;
+  }
+
+  @override
+  FlutterBluePlusDevice createDevice(ScanResult device) => FlutterBluePlusDevice(this, device);
+
+  @override
+  FlutterBluePlusUUID createUuid(Guid uuid) => FlutterBluePlusUUID(uuid);
+
+  @override
+  FlutterBluePlusUUID createUuidFromString(String uuid) => FlutterBluePlusUUID.fromString(uuid);
+
+  @override
+  FlutterBluePlusService createService(fbp.BluetoothService service) => FlutterBluePlusService.fromSource(service);
+
+  @override
+  FlutterBluePlusCharacteristic createCharacteristic(fbp.BluetoothCharacteristic characteristic) => FlutterBluePlusCharacteristic.fromSource(characteristic);
+}
app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_service.dart
@@ -0,0 +1,50 @@
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_service.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp;
+import 'package:flutter_blue_plus/flutter_blue_plus.dart' show Guid;
+
+/// UUID wrapper for FlutterBluePlus
+final class FlutterBluePlusUUID extends BluetoothUuid<Guid> {
+  /// Create a [FlutterBluePlusUUID] from a [Guid]
+  FlutterBluePlusUUID(Guid uuid): super(uuid: uuid.str128, source: uuid);
+
+  /// Create a [FlutterBluePlusUUID] from a [String]
+  factory FlutterBluePlusUUID.fromString(String uuid) => FlutterBluePlusUUID(Guid(uuid));
+}
+
+/// [BluetoothService] implementation wrapping [fbp.BluetoothService]
+final class FlutterBluePlusService extends BluetoothService<fbp.BluetoothService, FlutterBluePlusCharacteristic> {
+  /// Create [BluetoothService] implementation wrapping [fbp.BluetoothService]
+  FlutterBluePlusService({ required super.uuid, required super.source });
+
+  /// Create a [FlutterBluePlusService] from a [fbp.BluetoothService]
+  factory FlutterBluePlusService.fromSource(fbp.BluetoothService service) {
+    final uuid = FlutterBluePlusUUID(service.serviceUuid);
+    return FlutterBluePlusService(uuid: uuid, source: service);
+  }
+
+  @override
+  List<FlutterBluePlusCharacteristic> get characteristics => source.characteristics
+    .map(FlutterBluePlusCharacteristic.fromSource).toList();
+}
+
+/// Wrapper class with generic interface for a [fbp.BluetoothCharacteristic]
+final class FlutterBluePlusCharacteristic extends BluetoothCharacteristic<fbp.BluetoothCharacteristic> {
+  /// Initialize a [FlutterBluePlusCharacteristic] from the backend specific source
+  FlutterBluePlusCharacteristic.fromSource(fbp.BluetoothCharacteristic source):
+    super(uuid: FlutterBluePlusUUID(source.characteristicUuid), source: source);
+
+  @override
+  bool get canRead => source.properties.read;
+  
+  @override
+  bool get canWrite => source.properties.write;
+  
+  @override
+  bool get canWriteWithoutResponse => source.properties.writeWithoutResponse;
+  
+  @override
+  bool get canNotify => source.properties.notify;
+  
+  @override
+  bool get canIndicate => source.properties.indicate;
+}
app/lib/features/bluetooth/backend/flutter_blue_plus/fbp_state.dart
@@ -0,0 +1,19 @@
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp;
+
+/// Bluetooth adapter state parser for the 'flutter_blue_plus' package
+final class FlutterBluePlusStateParser extends BluetoothAdapterStateParser<fbp.BluetoothAdapterState> {
+  @override
+  BluetoothAdapterState parse(fbp.BluetoothAdapterState rawState) => switch (rawState) {
+    fbp.BluetoothAdapterState.unavailable => BluetoothAdapterState.unfeasible,
+    // Bluetooth permissions should always be granted on normal android
+    // devices. Users on non-standard android devices will know how to
+    // enable them. If this is not the case there will be bug reports.
+    fbp.BluetoothAdapterState.unauthorized => BluetoothAdapterState.unauthorized,
+    fbp.BluetoothAdapterState.on => BluetoothAdapterState.ready,
+    fbp.BluetoothAdapterState.off
+      || fbp.BluetoothAdapterState.turningOn
+      || fbp.BluetoothAdapterState.turningOff => BluetoothAdapterState.disabled,
+    fbp.BluetoothAdapterState.unknown => BluetoothAdapterState.initial,
+  };
+}
app/lib/features/bluetooth/logic/flutter_blue_plus_mockable.dart โ†’ app/lib/features/bluetooth/backend/flutter_blue_plus/flutter_blue_plus_mockable.dart
@@ -2,8 +2,7 @@ import 'dart:async';
 
 import 'package:flutter_blue_plus/flutter_blue_plus.dart';
 
-/// Wrapper for FlutterBluePlus in order to easily mock it
-/// Wraps all calls for testing purposes
+/// Wrapper for FlutterBluePlus in allowing easy mocking during testing
 class FlutterBluePlusMockable {
   LogLevel get logLevel => FlutterBluePlus.logLevel;
 
@@ -136,4 +135,4 @@ class FlutterBluePlusMockable {
 
   /// Request Bluetooth PHY support
   Future<PhySupport> getPhySupport() => FlutterBluePlus.getPhySupport();
-}
\ No newline at end of file
+}
app/lib/features/bluetooth/backend/mock/mock_device.dart
@@ -0,0 +1,38 @@
+import 'dart:typed_data';
+
+import 'package:blood_pressure_app/config.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_connection.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_manager.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_service.dart';
+
+/// Placeholder [BluetoothDevice] implementation that can f.e. be used for testing
+final class MockBluetoothDevice extends BluetoothDevice<BluetoothManager, BluetoothService, BluetoothCharacteristic, String> {
+  /// Initialize Placeholder [BluetoothDevice] implementation that can f.e. be used for testing
+  MockBluetoothDevice(super.manager, super.source): assert(isTestingEnvironment, 'consider whether a blanket implementation is appropriate');
+
+  @override
+  String get deviceId => super.source;
+
+  @override
+  String get name => super.source;
+
+  @override
+  Stream<BluetoothConnectionState> get connectionStream => const Stream<BluetoothConnectionState>.empty();
+
+  @override
+  Future<void> backendConnect() async {}
+
+  @override
+  Future<void> backendDisconnect() async {}
+
+  @override
+  Future<void> dispose() async {}
+
+  @override
+  Future<List<BluetoothService<dynamic, BluetoothCharacteristic>>?> discoverServices() =>
+    Future.value(<BluetoothService<dynamic, BluetoothCharacteristic>>[]);
+
+  @override
+  Future<bool> getCharacteristicValue(BluetoothCharacteristic characteristic, void Function(Uint8List value) onValue) async => true;
+}
app/lib/features/bluetooth/backend/mock/mock_discovery.dart
@@ -0,0 +1,23 @@
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_discovery.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/mock/mock_manager.dart';
+
+/// Placeholder [BluetoothDeviceDiscovery] implementation that can f.e. be used for testing
+final class MockBluetoothDiscovery extends BluetoothDeviceDiscovery<MockBluetoothManager> {
+  /// constructor
+  MockBluetoothDiscovery(super.manager);
+  
+  @override
+  Future<void> backendStart(String serviceUuid) {
+    throw UnimplementedError();
+  }
+  
+  @override
+  Future<void> backendStop() {
+    throw UnimplementedError();
+  }
+  
+  @override
+  Stream<List<BluetoothDevice>> get discoverStream => throw UnimplementedError();
+
+}
app/lib/features/bluetooth/backend/mock/mock_manager.dart
@@ -0,0 +1,46 @@
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_discovery.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/features/bluetooth/backend/bluetooth_state.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/mock/mock_service.dart';
+
+/// Placeholder [BluetoothManager] implementation that can f.e. be used for testing
+final class MockBluetoothManager extends BluetoothManager<String, String, MockedService, MockedCharacteristic> {
+  @override
+  BluetoothDeviceDiscovery<BluetoothManager> get discovery => throw UnimplementedError();
+
+  @override
+  Future<bool?> enable() async => null;
+  
+  @override
+  BluetoothAdapterState get lastKnownAdapterState => BluetoothAdapterState.initial;
+  
+  @override
+  Stream<BluetoothAdapterState> get stateStream => const Stream.empty();
+
+  @override
+  BluetoothUuid createUuid(String uuid) {
+    throw UnimplementedError();
+  }
+
+  @override
+  BluetoothUuid createUuidFromString(String uuid) {
+    throw UnimplementedError();
+  }
+
+  @override
+  BluetoothDevice<BluetoothManager, BluetoothService<dynamic, BluetoothCharacteristic>, BluetoothCharacteristic, dynamic> createDevice(String device) {
+    throw UnimplementedError();
+  }
+
+  @override
+  BluetoothService<dynamic, BluetoothCharacteristic> createService(MockedService service) {
+    throw UnimplementedError();
+  }
+
+  @override
+  BluetoothCharacteristic createCharacteristic(MockedCharacteristic characteristic) {
+    throw UnimplementedError();
+  }
+}
app/lib/features/bluetooth/backend/mock/mock_service.dart
@@ -0,0 +1,68 @@
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_service.dart';
+
+/// Backend implementation for MockBluetoothService
+final class MockedService {
+  /// constructor
+  MockedService({ required this.uuid, required this.characteristics });
+
+  String uuid;
+  List<MockedCharacteristic> characteristics;
+}
+
+/// Backend implementation for MockBluetoothCharacteristic
+final class MockedCharacteristic {
+  /// constructor
+  MockedCharacteristic({
+    required this.uuid,
+    this.canRead = false,
+    this.canWrite = false,
+    this.canWriteWithoutResponse = false,
+    this.canNotify = false,
+    this.canIndicate = false,
+  });
+
+  String uuid;
+  bool canRead;
+  bool canWrite;
+  bool canWriteWithoutResponse;
+  bool canNotify;
+  bool canIndicate;
+}
+
+/// String wrapper for Bluetooth
+final class MockBluetoothString extends BluetoothUuid<String> {
+  /// Create a BluetoothString from a String
+  MockBluetoothString(String uuid): super(uuid: uuid, source: uuid);
+  /// Create a BluetoothString from a string
+  MockBluetoothString.fromString(String uuid): super(uuid: uuid, source: uuid);
+}
+
+/// Wrapper class with generic interface for a [MockedService]
+final class MockBluetoothService extends BluetoothService<MockedService, BluetoothCharacteristic> {
+   /// Create a FlutterBlueService from a [MockedService]
+  MockBluetoothService.fromSource(MockedService service): super(uuid: MockBluetoothString(service.uuid), source: service);
+
+  @override
+  List<BluetoothCharacteristic> get characteristics => source.characteristics.map(MockBluetoothCharacteristic.fromSource).toList();
+}
+
+/// Wrapper class with generic interface for a [MockedCharacteristic]
+final class MockBluetoothCharacteristic extends BluetoothCharacteristic<MockedCharacteristic> {
+  /// Create a BluetoothCharacteristic from the backend specific source
+  MockBluetoothCharacteristic.fromSource(MockedCharacteristic source): super(uuid: MockBluetoothString(source.uuid), source: source);
+
+  @override
+  bool get canRead => source.canRead;
+
+  @override
+  bool get canWrite => source.canWrite;
+
+  @override
+  bool get canWriteWithoutResponse => source.canWriteWithoutResponse;
+
+  @override
+  bool get canNotify => source.canNotify;
+
+  @override
+  bool get canIndicate => source.canIndicate;
+}
app/lib/features/bluetooth/backend/bluetooth_backend.dart
@@ -0,0 +1,16 @@
+/// 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;
+
+/// All available bluetooth backends
+enum BluetoothBackend {
+  /// Bluetooth Low Energy backend
+  bluetoothLowEnergy,
+  /// Flutter Blue Plus backend
+  flutterBluePlus,
+  /// Mock backend
+  mock;
+}
app/lib/features/bluetooth/backend/bluetooth_connection.dart
@@ -0,0 +1,8 @@
+/// State of the bluetooth connection of a device
+enum BluetoothConnectionState {
+  /// Device is connected
+  connected,
+  /// Device is disconnect
+  disconnected;
+}
+
app/lib/features/bluetooth/backend/bluetooth_device.dart
@@ -0,0 +1,392 @@
+import 'dart:async';
+import 'dart:math';
+
+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';
+
+/// Current state of the bluetooth device
+enum BluetoothDeviceState {
+  /// Started connecting to the device
+  connecting,
+  /// Started disconnecting the device
+  disconnecting,
+  /// Device is connected (f.e. it send a connected event)
+  connected,
+  /// Device is disconnected (f.e. it send a disconnected event)
+  disconnected;
+}
+
+/// 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 BluetoothDevice.
+  ///
+  /// * [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;
+
+  BluetoothDeviceState _state = BluetoothDeviceState.disconnected;
+
+  /// (Unique?) id of the device
+  String get deviceId;
+
+  /// Name of the device
+  String get name;
+
+  /// Memoized service list for the device
+  List<BS>? _services;
+
+  StreamSubscription<BluetoothConnectionState>? _connectionListener;
+
+  /// Whether the device is connected
+  bool get isConnected => _state == BluetoothDeviceState.connected;
+
+  /// 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
+  /// 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 = [];
+
+  /// Wait for the device state to change to a different value then disconnecting
+  ///
+  /// [timeout] - How long to wait before timeout occurs. A value of -1 disables waiting, a value of 0 waits indefinitely
+  Future<void> _waitForDisconnectingStateChange({ int timeout = 300000 }) async {
+    if (timeout < 0) {
+      return;
+    }
+
+    // Futures within an any still always resolve, it's just that the results
+    // are disregard for futures that do not finish first. Use this bool to
+    // keep track whether the futures are already completed or not
+    bool futuresCompleted = false;
+
+    /// Waits and calls itself recursively as long as the current device [_state] equals [BluetoothDeviceState.disconnecting]
+    Future<void> checkDeviceState() async {
+      while (!futuresCompleted && _state == BluetoothDeviceState.disconnecting) {
+        logger.finest('Waiting because device is still disconnecting');
+        await Future.delayed(const Duration(milliseconds: 10));
+      }
+    }
+
+    final futures = [checkDeviceState()];
+    if (timeout > 0) {
+      futures.add(
+        Future.delayed(Duration(milliseconds: min(timeout, 300000))).then((_) {
+          if (!futuresCompleted) {
+            logger.finest('connect: Wait for state change timed out after $timeout ms');
+          }
+        })
+      );
+    }
+
+    await Future.any(futures);
+    futuresCompleted = true;
+  }
+
+  /// Connect to the device
+  ///
+  /// Always call [disconnect] when ready after calling connect
+  /// [onConnect] Called after device is connected
+  /// [onDisconnect] Called after device is disconnected, see [disconnectCallbacks]
+  /// [onError] Called when an error occurs
+  /// [waitForDisconnectingStateChangeTimeout] If connect is called while the device is still disconnecting, wait
+  ///   for the device to change it's state. A value of -1 means don't ever wait, a value of 0 means wait indefinitely
+  ///   Setting this timeout ensures correct state management of the device so users only have to call disconnect()/connect() 
+  Future<bool> connect({
+    VoidCallback? onConnect,
+    bool Function()? onDisconnect,
+    ValueSetter<Object>? onError,
+    int waitForDisconnectingStateChangeTimeout = 3000
+  }) async {
+    if (_state == BluetoothDeviceState.disconnecting) {
+      await _waitForDisconnectingStateChange(timeout: waitForDisconnectingStateChangeTimeout);
+    }
+
+    if (_state != BluetoothDeviceState.disconnected) {
+      return false;
+    }
+
+    _state = BluetoothDeviceState.connecting;
+
+    final completer = Completer<bool>();
+    logger.finer('connect: start connecting');
+
+    if (onDisconnect != null) {
+      disconnectCallbacks.add(onDisconnect);
+    }
+
+    await _connectionListener?.cancel();
+    _connectionListener = connectionStream.listen((BluetoothConnectionState state) {
+      logger.finest('connectionStream.listen[_state: $_state]: $state');
+
+      // Note: in this abstraction we want the device state to be singular. Unfortunately
+      // not all libraries on all platforms send only a single connection state event. F.e.
+      // flutter_blue_plus can send 3 disconnect events the very first time you try to connect
+      // with a device. These multiple similar events for the same device will break our logic
+      // so we need to filter the states.
+      switch (state) {
+        case BluetoothConnectionState.connected:
+          if (_state != BluetoothDeviceState.connecting) {
+            // Ignore status update if the current device state was not connecting. Cause then
+            // the library probably send multiple state update events.
+            logger.finest('Ignoring state update because device was not connecting: $_state');
+            return;
+          }
+
+          onConnect?.call();
+          if (!completer.isCompleted) completer.complete(true);
+          _state = BluetoothDeviceState.connected;
+          return;
+        case BluetoothConnectionState.disconnected:
+          if ([BluetoothDeviceState.connecting, BluetoothDeviceState.disconnected].any((s) => s == _state)) {
+            // Ignore status update if the state was connecting or already disconnected
+            logger.finest('Ignoring state update because device was already disconnected: $_state');
+            return;
+          }
+
+          for (final fn in disconnectCallbacks.reversed) {
+            if (fn()) {
+              // ignore other disconnect callbacks
+              break;
+            }
+          }
+
+          disconnectCallbacks.clear();
+          if (!completer.isCompleted) completer.complete(false);
+          _state = BluetoothDeviceState.disconnected;
+      }
+    }, onError: onError);
+
+    try {
+      await backendConnect();
+    } catch (e) {
+      logger.severe('Failed to connect to device', e);
+      if (!completer.isCompleted) completer.complete(false);
+      _state = BluetoothDeviceState.disconnected;
+    }
+
+    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
+  /// Optionally specify [waitForStateChangeTimeout] in milliseconds to indicate how long we
+  /// should wait for the device to send a disconnect event. Specifying a value of -1 disables
+  /// waiting for the state change, a value of 0 means wait indefinitely.
+  Future<bool> disconnect({ int waitForStateChangeTimeout = 3000 }) async {
+    _state = BluetoothDeviceState.disconnecting;
+    await backendDisconnect();
+
+    if (waitForStateChangeTimeout > -1) {
+      await _waitForDisconnectingStateChange(timeout: waitForStateChangeTimeout);
+
+      assert(
+        _state == BluetoothDeviceState.disconnecting || _state == BluetoothDeviceState.disconnected,
+        'Expected state either to be disconnecting (due to timeout) or disconnected. Got $_state instead'
+      );
+    }
+
+    await _connectionListener?.cancel();
+    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');
+    }
+
+    if (logServices) {
+      logger.finest(_services
+        ?.map((s) => 'Found services\n$s:\n  - ${s.characteristics.join('\n  - ')}]')
+        .join('\n'));
+    }
+
+    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);
+
+  @override
+  String toString() => 'BluetoothDevice{name: $name, deviceId: $deviceId}';
+
+  @override
+  /// Compare devices, only checking hashCode is not sufficient during tests as hashCode
+  /// of mocked classes seems to be always 0 hence why also comparing by deviceId
+  /// 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
+  bool operator == (Object other) => other is BluetoothDevice && hashCode == other.hashCode && deviceId == other.deviceId;
+
+  @override
+  int get hashCode => deviceId.hashCode ^ name.hashCode;
+}
+
+/// 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);
+
+    try {
+      logger.finest('listenCharacteristicValue: triggering characteristic value');
+      final bool triggerSuccess = await triggerCharacteristicValue(characteristic);
+      if (!triggerSuccess) {
+        logger.warning('listenCharacteristicValue: triggerCharacteristicValue returned $triggerSuccess');
+      }
+    } catch (e) {
+      logger.severe('Error occured while triggering characteristic', e);
+    }
+
+    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;
+    });
+  }
+}
app/lib/features/bluetooth/backend/bluetooth_discovery.dart
@@ -0,0 +1,99 @@
+import 'dart:async';
+
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_device.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_manager.dart';
+import 'package:blood_pressure_app/logging.dart';
+import 'package:flutter/foundation.dart';
+
+/// Base class for backend device discovery implementations
+abstract class BluetoothDeviceDiscovery<BM extends BluetoothManager> with TypeLogger {
+  /// Initialize base class for device discovery implementations.
+  BluetoothDeviceDiscovery(this.manager) {
+    logger.finer('init device discovery: $this');
+  }
+
+  /// Corresponding BluetoothManager
+  final BM manager;
+
+  /// List of discovered devices
+  final Set<BluetoothDevice> _devices = {};
+
+  /// A stream that returns the discovered devices when discovering
+  Stream<List<BluetoothDevice>> get discoverStream;
+
+  StreamSubscription<List<BluetoothDevice>>? _discoverSubscription;
+
+  /// Backend implementation to start discovering
+  @protected
+  Future<void> backendStart(String serviceUuid);
+
+  /// Backend implementation to stop discovering
+  @protected
+  Future<void> backendStop();
+
+  /// Whether device discovery is running or not
+  bool _discovering = false;
+
+  /// True when already discovering devices
+  bool get isDiscovering => _discovering;
+
+  /// Start discovering for new devices
+  ///
+  /// - [serviceUuid] The service uuid to filter on when discovering devices
+  /// - [onDevices] Callback for when devices have been discovered. The
+  ///   [onDevices] callback can be called multiple times, it is also always
+  ///   called with the list of all discovered devices from the start of discovering
+  Future<void> start(String serviceUuid, ValueSetter<List<BluetoothDevice>> onDevices) async {
+    if (_discovering) {
+      logger.warning('Already discovering, not starting discovery again');
+      return;
+    }
+
+    // Do not remove this if, otherwise the device_scan_cubit_test will 'hang'
+    // Not sure why, it seems during testing this would close the mocked stream
+    // immediately so when adding devices through mock.sink.add they never reach
+    // the device_scan_cubit component
+    // TODO: figure out why test fails without this if
+    if (_discoverSubscription != null) {
+      await _discoverSubscription?.cancel();
+    }
+
+    _discovering = true;
+    _devices.clear();
+
+    _discoverSubscription = discoverStream.listen((newDevices) {
+      logger.finest('New devices discovered: $newDevices');
+      assert(_discovering);
+
+      // Note that there are major differences in how backends return discovered devices,
+      // f.e. FlutterBluePlus batches results itself while BluetoothLowEnergy will always
+      // return one device per listen callback.
+      // The _devices type [Set] makes sure sure we are not adding duplicate devices.
+      _devices.addAll(newDevices);
+
+      onDevices(_devices.toList());
+    }, onError: onDiscoveryError);
+
+    logger.fine('Starting discovery for devices with service: $serviceUuid');
+    await backendStart(serviceUuid);
+  }
+
+  /// Called when an error occurs during discovery
+  void onDiscoveryError(Object error) {
+    logger.severe('Starting device scan failed', error);
+    _discovering = false;
+  }
+
+  /// Stop discovering for new devices
+  Future<void> stop() async {
+    if (!_discovering) {
+      return;
+    }
+
+    logger.finer('Stopping discovery');
+    await _discoverSubscription?.cancel();
+    await backendStop();
+    _devices.clear();
+    _discovering = false;
+  }
+}
app/lib/features/bluetooth/backend/bluetooth_manager.dart
@@ -0,0 +1,57 @@
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_discovery.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_low_energy/ble_manager.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_service.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_manager.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/mock/mock_manager.dart';
+import 'package:blood_pressure_app/logging.dart';
+
+/// Base class for a bluetooth manager
+abstract class BluetoothManager<BackendDevice, BackendUuid, BackendService, BackendCharacteristic> with TypeLogger {
+  /// Instantiate the correct [BluetoothManager] implementation.
+  static BluetoothManager create([BluetoothBackend? backend]) {
+    switch (backend) {
+      case BluetoothBackend.mock:
+        return MockBluetoothManager();
+      case BluetoothBackend.flutterBluePlus:
+        return FlutterBluePlusManager();
+      case BluetoothBackend.bluetoothLowEnergy:
+      default:
+        return BluetoothLowEnergyManager();
+    }
+  }
+
+  /// Trigger the device to request the user for bluetooth ermissions
+  ///
+  /// Returns null if no permissions were requested (ie because its not needed on a platform)
+  /// or true/false to indicate whether requesting permissions succeeded (not if it was granted)
+  Future<bool?> enable(); // TODO: use task specific plugin/native code
+
+  /// Last known adapter state
+  ///
+  /// For convenience [BluetoothAdapterStateParser] instances already track the last known state,
+  /// so that state only needs to be returned in a backend's manager implementation
+  BluetoothAdapterState get lastKnownAdapterState;
+
+  /// Getter for the state stream
+  Stream<BluetoothAdapterState> get stateStream;
+
+  /// Device discovery implementation
+  BluetoothDeviceDiscovery get discovery;
+
+  /// Convert a BackendDevice into a BluetoothDevice
+  BluetoothDevice createDevice(BackendDevice device);
+
+  /// Convert a BackendUuid into a BluetoothUuid
+  BluetoothUuid createUuid(BackendUuid uuid);
+
+  /// Create a BluetoothUuid from a String
+  BluetoothUuid createUuidFromString(String uuid);
+
+  /// Convert a BackendService into a BluetoothService
+  BluetoothService createService(BackendService service);
+
+  /// Convert a BackendCharacteristic into a BluetoothCharacteristic
+  BluetoothCharacteristic createCharacteristic(BackendCharacteristic characteristic);
+}
app/lib/features/bluetooth/backend/bluetooth_service.dart
@@ -0,0 +1,136 @@
+import 'package:collection/collection.dart';
+
+/// Bluetooth Base UUID from Bluetooth Core Spec
+///
+/// The full 128-bit value of a 16-bit or 32-bit UUID may be computed by a simple arithmetic
+/// operation.
+/// 128_bit_value = 16_bit_value ร— 296 + Bluetooth_Base_UUID
+/// 128_bit_value = 32_bit_value ร— 296 + Bluetooth_Base_UUID
+const bluetoothBaseUuid = '00000000-0000-1000-8000-00805F9B34FB';
+
+/// Generic BluetoothUuid representation
+abstract class BluetoothUuid<BackendUuid> {
+  /// constructor
+  BluetoothUuid({ required this.uuid, required this.source }): assert(uuid.length == 36, 'Expected uuid to have a length of 36, got uuid=$uuid');
+
+  /// Create a BluetoothUuid from a string
+  BluetoothUuid.fromString(this.uuid):
+    assert(uuid.isNotEmpty, 'This static method is abstract'),
+    source = bluetoothBaseUuid as BackendUuid // satisfy linter
+    {
+      throw AssertionError('This static method is abstract');
+    }
+
+  /// 128-bit string representation of uuid
+  final String uuid;
+
+  /// The backend specific uuid
+  final BackendUuid source;
+
+  /// Whether this uuid is an official bluetooth core spec uuid
+  bool get isBluetoothUuid => uuid.toUpperCase().endsWith(bluetoothBaseUuid.substring(8));
+
+  @override
+  String toString() => uuid;
+
+  /// Returns the 16 bit value of the UUID if uuid is from bluetooth core spec, otherwise full id
+  ///
+  /// The 16-bit Attribute UUID replaces the xโ€™s in the following:
+  ///   0000xxxx-0000-1000-8000-00805F9B34FB
+  String get shortId {
+    final uuid = toString();
+    assert(uuid.length == 36);
+
+    if (isBluetoothUuid) {
+      return '0x${uuid.substring(4, 8)}';
+    }
+
+    return uuid;
+  }
+
+  @override
+  bool operator == (Object other) {
+    if (other is BluetoothUuid) {
+      return toString() == other.toString();
+    }
+
+    if (other is BluetoothService) {
+      return toString() == other.uuid.toString();
+    }
+
+    if (other is BluetoothCharacteristic) {
+      return toString() == other.uuid.toString();
+    }
+
+    return false;
+  }
+
+  @override
+  int get hashCode => super.hashCode * 17;
+}
+
+/// Generic BluetoothService representation
+abstract class BluetoothService<BackendService, BC extends BluetoothCharacteristic> {
+  /// Initialize bluetooth service wrapper class
+  BluetoothService({ required this.uuid, required this.source });
+
+  /// UUID of the service
+  final BluetoothUuid uuid;
+  /// Backend source for the service
+  final BackendService source;
+
+  /// Get all characteristics for this service
+  List<BC> get characteristics;
+
+  /// Returns the characteristic with requested [uuid], returns null if
+  /// requested [uuid] was not found
+  Future<BC?> getCharacteristicByUuid(BluetoothUuid uuid) async => characteristics.firstWhereOrNull((service) => service.uuid == uuid);
+
+  @override
+  String toString() => 'BluetoothService{uuid: ${uuid.shortId}, source: ${source.runtimeType}}';
+
+  @override
+  bool operator ==(Object other) => (other is BluetoothService)
+    && toString() == other.toString();
+
+  @override
+  int get hashCode => super.hashCode * 17;
+}
+
+/// Characteristic representation
+abstract class BluetoothCharacteristic<BackendCharacteristic> {
+  /// Initialize bluetooth characteristic wrapper class
+  BluetoothCharacteristic({ required this.uuid, required this.source });
+
+  /// UUID of the characteristic
+  final BluetoothUuid uuid;
+  /// Backend source for the characteristic
+  final BackendCharacteristic source;
+
+  /// Whether the characteristic can be read
+  bool get canRead;
+
+  /// Whether the characteristic can be written to
+  bool get canWrite;
+
+  /// Whether the characteristic can be written to without a response
+  bool get canWriteWithoutResponse;
+
+  /// Whether the characteristic permits notifications for it's value, without a response to indicate receipt of the notification.
+  bool get canNotify;
+
+  /// Whether the characteristic permits notifications for it's value, with a response to indicate receipt of the notification
+  bool get canIndicate;
+
+  @override
+  String toString() => 'BluetoothCharacteristic{uuid: ${uuid.shortId}, source: ${source.runtimeType}, '
+    'canRead: $canRead, canWrite: $canWrite, canWriteWithoutResponse: $canWriteWithoutResponse, '
+    'canNotify: $canNotify, canIndicate: $canIndicate}';
+
+  @override
+  bool operator ==(Object other) => (other is BluetoothCharacteristic)
+    && toString() == other.toString();
+
+  @override
+  int get hashCode => super.hashCode * 17;
+}
app/lib/features/bluetooth/backend/bluetooth_state.dart
@@ -0,0 +1,21 @@
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_utils.dart';
+
+/// Current state of bluetooth adapter/sensor
+enum BluetoothAdapterState {
+  /// Use of bluetooth adapter not authorized
+  unauthorized,
+  /// Use of bluetooth adapter not possible, f.e. because there is none
+  unfeasible,
+  /// Use of bluetooth adapter disabled
+  disabled,
+  /// Use of bluetooth adapter unknown
+  initial,
+  /// Bluetooth adapter ready to be used
+  ready;
+}
+
+/// Util to parse backend adapter states to [BluetoothAdapterState]
+abstract class BluetoothAdapterStateParser<BackendState> extends StreamDataParserCached<BackendState, BluetoothAdapterState> {
+  @override
+  BluetoothAdapterState get initialState => BluetoothAdapterState.initial;
+}
app/lib/features/bluetooth/backend/bluetooth_utils.dart
@@ -0,0 +1,117 @@
+import 'dart:async';
+
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_state.dart';
+import 'package:blood_pressure_app/logging.dart';
+
+/// Generic stream data parser base class
+abstract class StreamDataParser<StreamData, ParsedData> {
+  /// Method to be implemented by backend that converts the raw bluetooth adapter state to our BluetoothState
+  ParsedData parse(StreamData rawState);
+}
+
+/// Generic stream data parser base class that caches the last known state returned by the stream
+abstract class StreamDataParserCached<StreamData, ParsedData> extends StreamDataParser<StreamData, ParsedData> {
+  /// Initial state when it's unknown, f.e. when stream didn't return any data yet
+  ParsedData get initialState;
+  ParsedData? _lastKnownState;
+
+  /// The last known adapter state
+  ParsedData get lastKnownState => _lastKnownState ?? initialState;
+
+  /// Internal method to cache the last adapter state value, backends should only implement parse not this method
+  ParsedData parseAndCache(StreamData rawState) {
+    _lastKnownState = parse(rawState);
+    return lastKnownState;
+  }
+}
+
+/// Transforms the backend's bluetooth adapter state stream to emit [BluetoothAdapterState]'s
+///
+/// Can normally be used directly, backends should only inject a customized BluetoothStateParser
+class StreamDataParserTransformer<StreamData, ParsedData, SD extends StreamDataParser<StreamData, ParsedData>>
+  extends StreamDataTransformer<StreamData, ParsedData> {
+  /// Create a BluetoothAdapterStateStreamTransformer
+  ///
+  /// [stateParser] The BluetoothStateParser that provides the backend logic to convert BackendState to BluetoothAdapterState
+  StreamDataParserTransformer({ required SD stateParser, super.sync, super.cancelOnError }) {
+    _stateParser = stateParser;
+  }
+
+  late SD _stateParser;
+
+  @override
+  void onData(StreamData streamData) {
+    late ParsedData data;
+    if (_stateParser is StreamDataParserCached) {
+      data = (_stateParser as StreamDataParserCached).parseAndCache(streamData);
+    } else {
+      data = _stateParser.parse(streamData);
+    }
+
+    sendData(data);
+  }
+}
+
+
+/// Generic stream transformer util that should support cancelling & pausing etc
+///
+/// Implementations should only need to worry about transforming the data by overriding
+/// the onData method and sending the transformed data using sendData
+///
+/// TODO: move outside bluetooth logic
+abstract class StreamDataTransformer<S,T> with TypeLogger implements StreamTransformer<S,T> {
+  /// Create a BluetoothStreamTransformer
+  ///
+  /// - [sync] Passed to [StreamController]
+  /// - [cancelOnError] Passed to [Stream]
+  StreamDataTransformer({ bool sync = false, bool cancelOnError = false }) {
+    _cancelOnError = cancelOnError;
+
+    _controller = StreamController<T>(
+      onListen: _onListen,
+      onCancel: _onCancel,
+      onPause: () => _subscription?.pause(),
+      onResume: () => _subscription?.resume(),
+      sync: sync,
+    );
+  }
+
+  late StreamController<T> _controller;
+  StreamSubscription? _subscription;
+  Stream<S>? _stream;
+  bool _cancelOnError = false;
+
+  void _onListen() {
+    logger.finest('_onListen');
+    _subscription = _stream?.listen(
+      onData,
+      onError: _controller.addError,
+      onDone: _controller.close,
+      cancelOnError: _cancelOnError);
+  }
+
+  void _onCancel() {
+    logger.finest('_onCancel');
+    _subscription?.cancel();
+    _subscription = null;
+  }
+
+  /// Method that actually transforms the data being passed through this stream
+  void onData(S streamData);
+
+  /// Send data to the listening stream, should f.e. be called from within the
+  /// onData call to forward the transformed data
+  void sendData(T data) {
+    _controller.add(data);
+  }
+
+  @override
+  Stream<T> bind(Stream<S> stream) {
+    logger.finest('bind');
+    _stream = stream;
+    return _controller.stream;
+  }
+
+  @override
+  StreamTransformer<RS, RT> cast<RS, RT>() => StreamTransformer.castFrom(this);
+}
app/lib/features/bluetooth/logic/characteristics/ble_measurement_data.dart
@@ -1,30 +1,49 @@
-import 'package:blood_pressure_app/logging.dart';
+import 'dart:typed_data';
 
-import 'ble_date_time.dart';
-import 'ble_measurement_status.dart';
-import 'decoding_util.dart';
+import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_date_time.dart';
+import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_status.dart';
+import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/decoding_util.dart';
+import 'package:blood_pressure_app/logging.dart';
+import 'package:health_data_store/health_data_store.dart';
 
+/// Result of a single bp measurement as by ble spec.
+///
 /// https://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v4.x.x/doc/html/structble__bps__meas__s.html
 /// https://github.com/NordicSemiconductor/Kotlin-BLE-Library/blob/6b565e59de21dfa53ef80ff8351ac4a4550e8d58/profile/src/main/java/no/nordicsemi/android/kotlin/ble/profile/bps/BloodPressureMeasurementParser.kt
 class BleMeasurementData {
+  /// Initialize result of a single bp measurement.
   BleMeasurementData({
     required this.systolic,
     required this.diastolic,
     required this.meanArterialPressure,
     required this.isMMHG,
-    required this.pulse,
-    required this.userID,
-    required this.status,
-    required this.timestamp,
+    this.pulse,
+    this.userID,
+    this.status,
+    this.timestamp,
   });
 
-  static BleMeasurementData? decode(List<int> data, int offset) {
+  /// Return BleMeasurementData as a BloodPressureRecord
+  BloodPressureRecord asBloodPressureRecord() =>
+    BloodPressureRecord(
+      time: timestamp ?? DateTime.now(),
+      sys: isMMHG
+        ? Pressure.mmHg(systolic.toInt())
+        : Pressure.kPa(systolic),
+      dia: isMMHG
+        ? Pressure.mmHg(diastolic.toInt())
+        : Pressure.kPa(diastolic),
+      pul: pulse?.toInt(),
+    );
+
+  /// Decode bytes read from the characteristic into a [BleMeasurementData]
+  static BleMeasurementData? decode(Uint8List data, int offset) {
     // https://github.com/NordicSemiconductor/Kotlin-BLE-Library/blob/6b565e59de21dfa53ef80ff8351ac4a4550e8d58/profile/src/main/java/no/nordicsemi/android/kotlin/ble/profile/bps/BloodPressureMeasurementParser.kt
 
     // Reading specific bits: `(byte & (1 << bitIdx))`
 
     if (data.length < 7) {
-      Log.trace('BleMeasurementData decodeMeasurement: Not enough data, $data has less than 7 bytes.');
+      log.warning('BleMeasurementData decodeMeasurement: Not enough data, $data has less than 7 bytes.');
       return null;
     }
 
@@ -45,7 +64,7 @@ class BleMeasurementData {
       + (userIdPresent ? 1 : 0)
       + (measurementStatusPresent ? 2 : 0)
     )) {
-      Log.trace("BleMeasurementData decodeMeasurement: Flags don't match, $data has less bytes than expected.");
+      log.warning("BleMeasurementData decodeMeasurement: Flags don't match, $data has less bytes than expected.");
       return null;
     }
 
@@ -57,7 +76,7 @@ class BleMeasurementData {
     offset += 2;
 
     if (systolic == null || diastolic == null || meanArterialPressure == null) {
-      Log.trace('BleMeasurementData decodeMeasurement: Unable to decode required values sys, dia, and meanArterialPressure, $data.');
+      log.warning('BleMeasurementData decodeMeasurement: Unable to decode required values sys, dia, and meanArterialPressure, $data.');
       return null;
     }
 
@@ -96,13 +115,21 @@ class BleMeasurementData {
     );
   }
 
+  /// Systolic pressure
   final double systolic;
+  /// Diatolic pressure
   final double diastolic;
+  /// Mean arterial pressure
   final double meanArterialPressure;
+  /// True if pressure values are in mmHg, False if in kPa
   final bool isMMHG; // mmhg or kpa
+  /// Pulse rate (of heart)
   final double? pulse;
+  /// User id
   final int? userID;
+  /// [BleMeasurementStatus] status
   final BleMeasurementStatus? status;
+  /// Timestamp of measurement
   final DateTime? timestamp;
 
   @override
app/lib/features/bluetooth/logic/ble_read_cubit.dart
@@ -1,11 +1,10 @@
 import 'dart:async';
 
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart';
 import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_data.dart';
 import 'package:blood_pressure_app/logging.dart';
-import 'package:collection/collection.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
-import 'package:flutter_blue_plus/flutter_blue_plus.dart';
 
 part 'ble_read_state.dart';
 
@@ -25,162 +24,127 @@ part 'ble_read_state.dart';
 /// 3. If the searched characteristic is found read its value
 /// 4. If binary data is read decode it to object
 /// 5. Emit decoded object
-class BleReadCubit extends Cubit<BleReadState> {
+class BleReadCubit extends Cubit<BleReadState> with TypeLogger {
   /// Start reading a characteristic from a device.
   BleReadCubit(this._device, {
     required this.serviceUUID,
     required this.characteristicUUID,
   }) : super(BleReadInProgress())
   {
-    _subscription = _device.connectionState
-      .listen(_onConnectionStateChanged);
-    // timeout
+    takeMeasurement();
+
+    // start read timeout
     _timeoutTimer = Timer(const Duration(minutes: 2), () {
       if (state is BleReadInProgress) {
-        Log.trace('BleReadCubit timeout reached and still running');
-        emit(BleReadFailure());
+        logger.finer('BleReadCubit timeout reached and still running');
+        emit(BleReadFailure('Timed out after 2 minutes'));
       } else {
-        Log.trace('BleReadCubit timeout reached with state: $state, ${state is BleReadInProgress}');
+        logger.finer('BleReadCubit timeout reached with state: $state, ${state is BleReadInProgress}');
       }
     });
   }
 
-  /// UUID of the service to read.
-  final Guid serviceUUID;
-
-  /// UUID of the characteristic to read.
-  final Guid characteristicUUID;
-
   /// Bluetooth device to connect to.
   ///
-  /// Must have an active established connection and support the measurement
-  /// characteristic.
+  /// Must have an active established connection and support the measurement characteristic.
   final BluetoothDevice _device;
+
+  /// UUID of the service to read.
+  final String serviceUUID;
+
+  /// UUID of the characteristic to read.
+  final String characteristicUUID;
   
-  late final StreamSubscription<BluetoothConnectionState> _subscription;
   late final Timer _timeoutTimer;
-  StreamSubscription<List<int>>? _indicationListener;
 
-  @override
-  Future<void> close() async {
-    Log.trace('BleReadCubit close');
-    await _subscription.cancel();
-    _timeoutTimer.cancel();
+  int _retryCount = 0;
+  final int _maxRetries = 3;
 
-    if (_device.isConnected) {
-      try {
-        Log.trace('BleReadCubit close: Attempting disconnect from ${_device.advName}');
-        await _device.disconnect();
-        assert(_device.isDisconnected);
-      } catch (e) {
-        Log.err('unable to disconnect', [e, _device]);
+  /// Take a 'measurement', i.e. read the blood pressure values from the given characteristicUUID
+  /// TODO: make this generic by accepting a data decoder argument?
+  Future<void> takeMeasurement() async {
+    final success = await _device.connect(
+      onDisconnect: () {
+        if (_retryCount < _maxRetries) {
+          _retryCount++;
+          takeMeasurement();
+
+          logger.finer('BleReadCubit: retrying after device.onDisconnect called');
+          return true;
+        }
+
+        logger.finer('BleReadCubit: device.onDisconnect called');
+        emit(BleReadFailure('Device unexpectedly disconnected'));
+        return true;
+      },
+      onError: (Object err) => emit(BleReadFailure(err.toString()))
+    );
+    if (success) {
+      final uuidService = _device.manager.createUuidFromString(serviceUUID);
+      final service = await _device.getServiceByUuid(uuidService);
+      logger.finer('BleReadCubit: Found service: $service');
+      if (service == null) {
+        // TODO: add a BleReadUnsupported state
+        emit(BleReadFailure('Device does not provide the expected service with uuid $serviceUUID'));
+        return;
       }
-    }
 
-    await super.close();
-  }
+      final uuidCharacteristic = _device.manager.createUuidFromString(characteristicUUID);
+      final characteristic = await service.getCharacteristicByUuid(uuidCharacteristic);
+      logger.finer('BleReadCubit: Found characteristic: $characteristic');
+      if (characteristic == null) {
+        emit(BleReadFailure('Device does not provide the expected characteristic with uuid $characteristicUUID'));
+        return;
+      }
 
-  bool _ensureConnectionInProgress = false;
-  Future<void> _ensureConnection([int attemptCount = 0]) async {
-    Log.trace('BleReadCubit _ensureConnection');
-    if (_ensureConnectionInProgress) return;
-    _ensureConnectionInProgress = true;
-    
-    if (_device.isAutoConnectEnabled) {
-      Log.trace('BleReadCubit Waiting for auto connect...');
-      _ensureConnectionInProgress = false;
-      return;
-    }
-    
-    if (_device.isDisconnected) {
-      Log.trace('BleReadCubit _ensureConnection: Attempting to connect with ${_device.advName}');
-      try {
-        await _device.connect();
-      } on FlutterBluePlusException catch (e) {
-        Log.err('BleReadCubit _device.connect failed:', [_device, e]);
+      final List<Uint8List> data = [];
+      final success = await _device.getCharacteristicValue(characteristic, (Uint8List value, [_]) => data.add(value));
+
+      logger.finer('BleReadCubit(success: $success): Got data: $data');
+      if (!success) {
+        emit(BleReadFailure('Could not retrieve characteristic value'));
+        return;
       }
-      
-      if (_device.isDisconnected) {
-        Log.trace('BleReadCubit _ensureConnection: Device not connected');
-        _ensureConnectionInProgress = false;
-        if (attemptCount >= 5) {
-          emit(BleReadFailure());
+
+      final List<BleMeasurementData> measurements = [];
+
+      for (final item in data) {
+        final decodedData = BleMeasurementData.decode(item, 0);
+        if (decodedData == null) {
+          logger.severe('BleReadCubit decoding failed', item);
+          emit(BleReadFailure('Could not decode data'));
           return;
-        } else {
-          return _ensureConnection(attemptCount + 1);
         }
+
+        measurements.add(decodedData);
+      }
+
+      if (measurements.length > 1) {
+        logger.finer('BleReadMultiple decoded ${measurements.length} measurements');
+        emit(BleReadMultiple(measurements));
       } else {
-        Log.trace('BleReadCubit Connection successful');
+        logger.finer('BleReadCubit decoded: ${measurements.first}');
+        emit(BleReadSuccess(measurements.first));
       }
     }
-    assert(_device.isConnected);
-    _ensureConnectionInProgress = false;
   }
 
-  Future<void> _onConnectionStateChanged(BluetoothConnectionState state) async {
-    Log.trace('BleReadCubit _onConnectionStateChanged: $state');
-    if (super.state is BleReadSuccess) return;
-    if (state == BluetoothConnectionState.disconnected) {
-      Log.trace('BleReadCubit _onConnectionStateChanged disconnected: '
-        '${_device.disconnectReason} Attempting reconnect');
-      await _ensureConnection();
-      return;
-    }
-    assert(state == BluetoothConnectionState.connected, 'state should be '
-      'connected as connecting and disconnecting are not streamed by android');
-    assert(_device.isConnected);
-
-    // Query actual services supported by the device. While they must be
-    // rediscovered when a disconnect happens, this object is also recreated.
-    late final List<BluetoothService> allServices;
-    try {
-      allServices = await _device.discoverServices();
-      Log.trace('BleReadCubit allServices: $allServices');
-    } catch (e) {
-      Log.err('service discovery', [_device, e]);
-      emit(BleReadFailure());
-      return;
-    }
-
-    // [Guid.str] trims standard parts from the uuid. 0x1810 is the blood
-    // pressure uuid. https://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v4.x.x/doc/html/group___u_u_i_d___s_e_r_v_i_c_e_s.html
-    final BluetoothService? service = allServices
-      .firstWhereOrNull((BluetoothService s) => s.uuid == serviceUUID);
-    if (service == null) {
-      Log.err('unsupported service', [_device, allServices]);
-      emit(BleReadFailure());
-      return;
-    }
+  @override
+  Future<void> close() async {
+    logger.finer('BleReadCubit close');
+    _timeoutTimer.cancel();
 
-    // https://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v4.x.x/doc/html/group___u_u_i_d___c_h_a_r_a_c_t_e_r_i_s_t_i_c_s.html#ga95fc99c7a99cf9d991c81027e4866936
-    final List<BluetoothCharacteristic> allCharacteristics = service.characteristics;
-    Log.trace('BleReadCubit allCharacteristics: $allCharacteristics');
-    final BluetoothCharacteristic? characteristic = allCharacteristics
-      .firstWhereOrNull((c) => c.uuid == characteristicUUID,);
-    if (characteristic == null) {
-      Log.err('no characteristic', [_device, allServices, allCharacteristics]);
-      emit(BleReadFailure());
-      return;
+    if (_device.isConnected) {
+      await _device.disconnect();
     }
 
-    // This characteristic only supports indication so we need to listen to values.
-    await _indicationListener?.cancel();
-    _indicationListener = characteristic
-      .onValueReceived.listen((rawData) {
-        Log.trace('BleReadCubit data received: $rawData');
-        final decodedData = BleMeasurementData.decode(rawData, 0);
-        if (decodedData == null) {
-          Log.err('BleReadCubit decoding failed', [ rawData ]);
-          emit(BleReadFailure());
-        } else {
-          Log.trace('BleReadCubit decoded: $decodedData');
-          emit(BleReadSuccess(decodedData));
-        }
-        _indicationListener?.cancel();
-        _indicationListener = null;
-      });
+    await super.close();
+  }
 
-    final bool indicationsSet = await characteristic.setNotifyValue(true);
-    Log.trace('BleReadCubit indicationsSet: $indicationsSet');
+  /// Called after reading from a device returned multiple measurements and the
+  /// user chose which measurement they wanted to add.
+  Future<void> useMeasurement(BleMeasurementData data) async {
+    assert(state is! BleReadSuccess);
+    emit(BleReadSuccess(data));
   }
 }
app/lib/features/bluetooth/logic/ble_read_state.dart
@@ -8,7 +8,22 @@ sealed class BleReadState {}
 class BleReadInProgress extends BleReadState {}
 
 /// The reading failed unrecoverable for some reason.
-class BleReadFailure extends BleReadState {}
+class BleReadFailure extends BleReadState {
+  /// The reading failed unrecoverable for some reason.
+  BleReadFailure(this.reason);
+
+  /// The reason why the read failed
+  final String reason;
+}
+
+/// Data has been successfully read and returned multiple measurements
+class BleReadMultiple extends BleReadState {
+  /// Indicate a successful reading of a ble characteristic with multiple measurements.
+  BleReadMultiple(this.data);
+
+  /// List of measurements decoded from the device.
+  final List<BleMeasurementData> data;
+}
 
 /// Data has been successfully read.
 class BleReadSuccess extends BleReadState {
app/lib/features/bluetooth/logic/bluetooth_cubit.dart
@@ -1,69 +1,58 @@
 import 'dart:async';
-import 'dart:io';
 
-import 'package:blood_pressure_app/features/bluetooth/logic/flutter_blue_plus_mockable.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart';
+import 'package:blood_pressure_app/logging.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
-import 'package:flutter_blue_plus/flutter_blue_plus.dart';
 
 part 'bluetooth_state.dart';
 
 /// Availability of the devices bluetooth adapter.
 ///
-/// The only state that allows using the adapter is [BluetoothReady].
-class BluetoothCubit extends Cubit<BluetoothState> {
+/// The only state that allows using the adapter is [BluetoothStateReady].
+class BluetoothCubit extends Cubit<BluetoothState> with TypeLogger {
   /// Create a cubit connecting to the bluetooth module for availability.
   ///
-  /// [flutterBluePlus] may be provided for testing purposes.
-  BluetoothCubit({
-    FlutterBluePlusMockable? flutterBluePlus
-  }): _flutterBluePlus = flutterBluePlus ?? FlutterBluePlusMockable(),
-        super(BluetoothInitial()) {
-    _adapterStateStateSubscription = _flutterBluePlus.adapterState.listen(_onAdapterStateChanged);
-  }
-
-  final FlutterBluePlusMockable _flutterBluePlus;
+  /// [manager] manager to check availabilty of.
+  BluetoothCubit({ required this.manager }):
+        super(BluetoothState.fromAdapterState(manager.lastKnownAdapterState)) {
+    _adapterStateSubscription = manager.stateStream.listen(_onAdapterStateChanged);
 
-  BluetoothAdapterState _adapterState = BluetoothAdapterState.unknown;
+    _lastKnownState = manager.lastKnownAdapterState;
+    logger.finer('lastKnownState: $_lastKnownState');
+  }
 
-  late StreamSubscription<BluetoothAdapterState> _adapterStateStateSubscription;
+  /// Bluetooth manager
+  late final BluetoothManager manager;
+  late BluetoothAdapterState _lastKnownState;
+  late StreamSubscription<BluetoothAdapterState> _adapterStateSubscription;
 
   @override
   Future<void> close() async {
-    await _adapterStateStateSubscription.cancel();
+    await _adapterStateSubscription.cancel();
     await super.close();
   }
 
   void _onAdapterStateChanged(BluetoothAdapterState state) async {
-    _adapterState = state;
-    switch (_adapterState) {
-      case BluetoothAdapterState.unavailable:
-        emit(BluetoothUnfeasible());
-      case BluetoothAdapterState.unauthorized:
-        // Bluetooth permissions should always be granted on normal android
-        // devices. Users on non-standard android devices will know how to
-        // enable them. If this is not the case there will be bug reports.
-        emit(BluetoothUnauthorized());
-      case BluetoothAdapterState.on:
-        emit(BluetoothReady());
-      case BluetoothAdapterState.off:
-      case BluetoothAdapterState.turningOff:
-      case BluetoothAdapterState.turningOn:
-        emit(BluetoothDisabled());
-      case BluetoothAdapterState.unknown:
-        emit(BluetoothInitial());
+    if (state == BluetoothAdapterState.unauthorized) {
+      final success = await manager.enable();
+      if (success != true) {
+        logger.warning('Enabling bluetooth failed or not needed on this platform');
+      }
     }
+
+    _lastKnownState = state;
+    logger.finer('_onAdapterStateChanged(state: $state)');
+    emit(BluetoothState.fromAdapterState(state));
   }
 
   /// Request to enable bluetooth on the device
-  Future<bool> enableBluetooth() async {
-    assert(state is BluetoothDisabled, 'No need to enable bluetooth when '
+  Future<bool?> enableBluetooth() async {
+    assert(state is BluetoothStateDisabled, 'No need to enable bluetooth when '
         'already enabled or not known to be disabled.');
     try {
-      if (!Platform.isAndroid) return false;
-      await _flutterBluePlus.turnOn();
-      return true;
-    } on FlutterBluePlusException {
+      return manager.enable();
+    } on Exception {
       return false;
     }
   }
@@ -74,7 +63,5 @@ class BluetoothCubit extends Cubit<BluetoothState> {
   /// the app won't get notified about permission changes and such. In those
   /// instances the user should have the option to manually recheck the state to
   /// avoid getting stuck on a unauthorized state.
-  Future<void> forceRefresh() async {
-    _onAdapterStateChanged(_flutterBluePlus.adapterStateNow);
-  }
+  void forceRefresh() => _onAdapterStateChanged(_lastKnownState);
 }
app/lib/features/bluetooth/logic/bluetooth_state.dart
@@ -2,25 +2,40 @@ part of 'bluetooth_cubit.dart';
 
 /// State of the devices bluetooth module.
 @immutable
-sealed class BluetoothState {}
+sealed class BluetoothState {
+  /// Initialize state of the devices bluetooth module.
+  const BluetoothState();
+
+  /// Returns the [BluetoothState] instance for given [BluetoothAdapterState] enum state
+  factory BluetoothState.fromAdapterState(BluetoothAdapterState state) => switch(state) {
+    // Bluetooth permissions should always be granted on normal android
+    // devices. Users on non-standard android devices will know how to
+    // enable them. If this is not the case there will be bug reports.
+    BluetoothAdapterState.unauthorized => BluetoothStateUnauthorized(),
+    BluetoothAdapterState.unfeasible => BluetoothStateUnfeasible(),
+    BluetoothAdapterState.disabled => BluetoothStateDisabled(),
+    BluetoothAdapterState.initial => BluetoothStateInitial(),
+    BluetoothAdapterState.ready => BluetoothStateReady(),
+  };
+}
 
 /// No information on whether bluetooth is available.
 ///
 /// Users may show a loading indication but can not assume bluetooth is
 /// available.
-class BluetoothInitial extends BluetoothState {}
+class BluetoothStateInitial extends BluetoothState {}
 
 /// There is no way bluetooth will work (e.g. no sensor).
 ///
 /// Options relating to bluetooth should not be shown.
-class BluetoothUnfeasible extends BluetoothState {}
+class BluetoothStateUnfeasible extends BluetoothState {}
 
 /// There is a bluetooth sensor but the app has no permission.
-class BluetoothUnauthorized extends BluetoothState {}
+class BluetoothStateUnauthorized extends BluetoothState {}
 
 /// The device has Bluetooth and the app has permissions, but it is disabled in
 /// the device settings.
-class BluetoothDisabled extends BluetoothState {}
+class BluetoothStateDisabled extends BluetoothState {}
 
 /// Bluetooth is ready for use by the app.
-class BluetoothReady extends BluetoothState {}
+class BluetoothStateReady extends BluetoothState {}
app/lib/features/bluetooth/logic/device_scan_cubit.dart
@@ -1,13 +1,12 @@
 import 'dart:async';
 
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart';
 import 'package:blood_pressure_app/features/bluetooth/logic/bluetooth_cubit.dart';
-import 'package:blood_pressure_app/features/bluetooth/logic/flutter_blue_plus_mockable.dart';
 import 'package:blood_pressure_app/logging.dart';
 import 'package:blood_pressure_app/model/storage/settings_store.dart';
 import 'package:collection/collection.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
-import 'package:flutter_blue_plus/flutter_blue_plus.dart';
 
 part 'device_scan_state.dart';
 
@@ -18,69 +17,69 @@ part 'device_scan_state.dart';
 /// 
 /// A device counts as recognized, when the user connected with it at least 
 /// once. Recognized devices connect automatically.
-class DeviceScanCubit extends Cubit<DeviceScanState> {
+class DeviceScanCubit extends Cubit<DeviceScanState> with TypeLogger {
   /// Search for bluetooth devices that match the criteria or are known
   /// ([Settings.knownBleDev]).
   DeviceScanCubit({
-    FlutterBluePlusMockable? flutterBluePlus,
+    required BluetoothManager manager,
     required this.service,
     required this.settings,
-  }) : _flutterBluePlus = flutterBluePlus ?? FlutterBluePlusMockable(),
-        super(DeviceListLoading()) {
-    assert(!_flutterBluePlus.isScanningNow);
+  }): super(DeviceListLoading()) {
+    _manager = manager;
     _startScanning();
   }
 
   /// Storage for known devices.
-  final Settings settings;
+  late final Settings settings;
 
   /// Service required from bluetooth devices.
-  final Guid service;
+  late final String service;
 
-  final FlutterBluePlusMockable _flutterBluePlus;
-
-  late StreamSubscription<List<ScanResult>> _scanResultsSubscription;
+  late final BluetoothManager _manager;
 
   @override
   Future<void> close() async {
-    await _scanResultsSubscription.cancel();
-    try {
-      await _flutterBluePlus.stopScan();
-    } catch (e) {
-      Log.err('Failed to stop scanning', [e]);
-      return;
+    final stopped = await _stopScanning();
+    if (stopped) {
+      await super.close();
     }
-    await super.close();
   }
 
   Future<void> _startScanning() async {
-    _scanResultsSubscription = _flutterBluePlus.scanResults
-      .listen(_onScanResult,
-        onError: _onScanError,
-    );
     try {
-      await _flutterBluePlus.startScan(
-        // no timeout, the user knows best how long scanning is needed
-        withServices: [ service ],
-        // Not all devices are found using this configuration (https://pub.dev/packages/flutter_blue_plus#scanning-does-not-find-my-device).
-      );
+      // no timeout, the user knows best how long scanning is needed
+      // Not all devices are found using this configuration (https://pub.dev/packages/flutter_blue_plus#scanning-does-not-find-my-device).
+      await _manager.discovery.start(service, _onScanResult);
     } catch (e) {
       _onScanError(e);
     }
   }
 
-  void _onScanResult(List<ScanResult> devices) {
-    Log.trace('_onScanResult devices: $devices');
+  Future<bool> _stopScanning() async {
+    try {
+      await _manager.discovery.stop();
+    } catch (err) {
+      logger.severe('Failed to stop scanning', err);
+      return false;
+    }
+    return true;
+  }
+
+  void _onScanResult(List<BluetoothDevice> devices) {
+    logger.finer('_onScanResult devices: $devices');
 
-    assert(devices.isEmpty || _flutterBluePlus.isScanningNow);
     // No need to check whether the devices really support the searched
     // characteristic as users have to select their device anyways.
-    if(state is DeviceSelected) return;
+    if(state is DeviceSelected) {
+      return;
+    }
+
     final preferred = devices.firstWhereOrNull((dev) =>
-      settings.knownBleDev.contains(dev.device.platformName));
+      settings.knownBleDev.contains(dev.name));
+
     if (preferred != null) {
-      _flutterBluePlus.stopScan()
-        .then((_) => emit(DeviceSelected(preferred.device)));
+      _stopScanning()
+        .then((_) => emit(DeviceSelected(preferred)));
     } else if (devices.isEmpty) {
       emit(DeviceListLoading());
     } else if (devices.length == 1) {
@@ -91,22 +90,23 @@ class DeviceScanCubit extends Cubit<DeviceScanState> {
   }
 
   void _onScanError(Object error) {
-    Log.err('Starting device scan failed', [ error ]);
+    logger.severe('Error during device discovery', error);
   }
 
   /// Mark a new device as known and switch to selected device state asap.
   Future<void> acceptDevice(BluetoothDevice device) async {
     assert(state is! DeviceSelected);
     try {
-      await _flutterBluePlus.stopScan();
+      await _stopScanning();
     } catch (e) {
       _onScanError(e);
       return;
     }
-    assert(!_flutterBluePlus.isScanningNow);
+
+    assert(!_manager.discovery.isDiscovering);
     emit(DeviceSelected(device));
     final List<String> list = settings.knownBleDev.toList();
-    list.add(device.platformName);
+    list.add(device.name);
     settings.knownBleDev = list;
   }
 }
app/lib/features/bluetooth/logic/device_scan_state.dart
@@ -22,7 +22,7 @@ class DeviceListAvailable extends DeviceScanState {
   DeviceListAvailable(this.devices);
 
   /// All found devices.
-  final List<ScanResult> devices;
+  final List<BluetoothDevice> devices;
 }
 
 /// One unrecognized device has been found.
@@ -34,5 +34,5 @@ class SingleDeviceAvailable extends DeviceScanState {
   SingleDeviceAvailable(this.device);
 
   /// The only found device.
-  final ScanResult device;
+  final BluetoothDevice device;
 }
app/lib/features/bluetooth/ui/closed_bluetooth_input.dart
@@ -1,11 +1,12 @@
 import 'package:app_settings/app_settings.dart';
 import 'package:blood_pressure_app/features/bluetooth/logic/bluetooth_cubit.dart';
+import 'package:blood_pressure_app/logging.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 
 /// A closed ble input that shows the adapter state and allows to start the input.
-class ClosedBluetoothInput extends StatelessWidget {
+class ClosedBluetoothInput extends StatelessWidget with TypeLogger {
   /// Show adapter state and allow starting inputs
   const ClosedBluetoothInput({super.key,
     required this.bluetoothCubit,
@@ -43,31 +44,34 @@ class ClosedBluetoothInput extends StatelessWidget {
     final localizations = AppLocalizations.of(context)!;
     return BlocBuilder<BluetoothCubit, BluetoothState>(
       bloc: bluetoothCubit,
-      builder: (context, BluetoothState state) => switch(state) {
-        BluetoothInitial() => const SizedBox.shrink(),
-        BluetoothUnfeasible() => const SizedBox.shrink(),
-        BluetoothUnauthorized() => _buildTile(
-          text: localizations.errBleNoPerms,
-          icon: Icons.bluetooth_disabled,
-          onTap: () async {
-            await AppSettings.openAppSettings();
-            await bluetoothCubit.forceRefresh();
-          },
-        ),
-        BluetoothDisabled() => _buildTile(
-          text: localizations.bluetoothDisabled,
-          icon: Icons.bluetooth_disabled,
-          onTap: () async {
-            final bluetoothOn = await bluetoothCubit.enableBluetooth();
-            if (!bluetoothOn) await AppSettings.openAppSettings(type: AppSettingsType.bluetooth);
-            await bluetoothCubit.forceRefresh();
-          },
-        ),
-        BluetoothReady() => _buildTile(
-          text: localizations.bluetoothInput,
-          icon: Icons.bluetooth,
-          onTap: onStarted,
-        ),
+      builder: (context, BluetoothState state) {
+        logger.finer('Called with state: $state');
+        return switch(state) {
+          BluetoothStateInitial() => const SizedBox.shrink(),
+          BluetoothStateUnfeasible() => const SizedBox.shrink(),
+          BluetoothStateUnauthorized() => _buildTile(
+            text: localizations.errBleNoPerms,
+            icon: Icons.bluetooth_disabled,
+            onTap: () async {
+              await AppSettings.openAppSettings();
+              bluetoothCubit.forceRefresh();
+            },
+          ),
+          BluetoothStateDisabled() => _buildTile(
+            text: localizations.bluetoothDisabled,
+            icon: Icons.bluetooth_disabled,
+            onTap: () async {
+              final bluetoothOn = await bluetoothCubit.enableBluetooth();
+              if (bluetoothOn == false) await AppSettings.openAppSettings(type: AppSettingsType.bluetooth);
+              bluetoothCubit.forceRefresh();
+            },
+          ),
+          BluetoothStateReady() => _buildTile(
+            text: localizations.bluetoothInput,
+            icon: Icons.bluetooth,
+            onTap: onStarted,
+          )
+        };
       },
     );
   }
app/lib/features/bluetooth/ui/device_selection.dart
@@ -1,6 +1,6 @@
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart';
 import 'package:blood_pressure_app/features/bluetooth/ui/input_card.dart';
 import 'package:flutter/material.dart';
-import 'package:flutter_blue_plus/flutter_blue_plus.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 
 /// A pairing dialoge with a single bluetooth device.
@@ -12,18 +12,18 @@ class DeviceSelection extends StatelessWidget {
   });
 
   /// The name of the device trying to connect.
-  final List<ScanResult> scanResults;
+  final List<BluetoothDevice> scanResults;
 
   /// Called when the user accepts the device.
   final void Function(BluetoothDevice) onAccepted;
 
-  Widget _buildDeviceTile(BuildContext context, ScanResult dev) => ListTile(
-    title: Text(dev.device.platformName),
+  Widget _buildDeviceTile(BuildContext context, BluetoothDevice dev) => ListTile(
+    title: Text(dev.name),
     trailing: FilledButton(
-      onPressed: () => onAccepted(dev.device),
+      onPressed: () => onAccepted(dev),
       child: Text(AppLocalizations.of(context)!.connect),
     ),
-    onTap: () => onAccepted(dev.device),
+    onTap: () => onAccepted(dev),
   );
 
   @override
app/lib/features/bluetooth/ui/measurement_failure.dart
@@ -1,34 +1,39 @@
 import 'package:blood_pressure_app/features/bluetooth/ui/input_card.dart';
+import 'package:blood_pressure_app/logging.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 
 /// Indication of a failure while taking a bluetooth measurement.
-class MeasurementFailure extends StatelessWidget {
+class MeasurementFailure extends StatelessWidget with TypeLogger {
   /// Indicate a failure while taking a bluetooth measurement.
-  const MeasurementFailure({super.key, required this.onTap});
+  const MeasurementFailure({super.key, required this.onTap, required this.reason});
 
   /// Called when the user requests closing.
   final void Function() onTap;
-  
+  /// Likely reason why the measurement failed
+  final String reason;
+
   @override
-  Widget build(BuildContext context) => GestureDetector(
-    onTap: onTap,
-    child: InputCard(
-      onClosed: onTap,
-      child: Center(
-        child: Column(
-          mainAxisAlignment: MainAxisAlignment.center,
-          children: [
-            const Icon(Icons.error_outline, color: Colors.red),
-            const SizedBox(height: 8,),
-            Text(AppLocalizations.of(context)!.errMeasurementRead),
-            const SizedBox(height: 4,),
-            Text(AppLocalizations.of(context)!.tapToClose),
-            const SizedBox(height: 8,),
-          ],
+  Widget build(BuildContext context) {
+    logger.warning('MeasurementFailure reason: $reason');
+    return GestureDetector(
+      onTap: onTap,
+      child: InputCard(
+        onClosed: onTap,
+        child: Center(
+          child: Column(
+            mainAxisAlignment: MainAxisAlignment.center,
+            children: [
+              const Icon(Icons.error_outline, color: Colors.red),
+              const SizedBox(height: 8,),
+              Text(AppLocalizations.of(context)!.errMeasurementRead),
+              const SizedBox(height: 4,),
+              Text(AppLocalizations.of(context)!.tapToClose),
+              const SizedBox(height: 8,),
+            ],
+          ),
         ),
       ),
-    ),
-  );
-  
+    );
+  }
 }
app/lib/features/bluetooth/ui/measurement_multiple.dart
@@ -0,0 +1,84 @@
+import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_data.dart';
+import 'package:blood_pressure_app/features/bluetooth/ui/input_card.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// Indication of a successful bluetooth read that returned multiple measurements.
+///
+/// TODO: Some devices can store up to 100 measurements which could cause a very long ListView. Maybe optimize UI for that?
+class MeasurementMultiple extends StatelessWidget {
+  /// Indicate a successful read while taking a bluetooth measurement.
+  const MeasurementMultiple({super.key,
+    required this.onClosed,
+    required this.onSelect,
+    required this.measurements,
+  });
+
+  /// All measurements decoded from bluetooth.
+  final List<BleMeasurementData> measurements;
+
+  /// Called when the user requests closing.
+  final void Function() onClosed;
+
+  /// Called when user selects a measurement
+  final void Function(BleMeasurementData data) onSelect;
+  
+  Widget _buildMeasurementTile(BuildContext context, int index, BleMeasurementData data) {
+    final localizations = AppLocalizations.of(context)!;
+    return ListTile(
+      title: Text(data.timestamp?.toIso8601String() ?? localizations.measurementIndex(index + 1)),
+      subtitle: Text(() {
+        String str = '';
+        if (data.userID != null) {
+          str += '${localizations.userID}: ${data.userID}, ';
+        }
+        str += '${localizations.bloodPressure}: ${data.systolic.round()}/${data.diastolic.round()}';
+        if (data.pulse != null) {
+          str += ', ${localizations.pulLong}: ${data.pulse?.round()}';
+        }
+        return str;
+      }()),
+      trailing: FilledButton(
+        onPressed: () => onSelect(data),
+        child: Text(localizations.select),
+      ),
+      onTap: () => onSelect(data),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    // Sort measurements so latest measurement is on top of the list
+    measurements.sort((a, b) {
+      final aTimestamp = a.timestamp?.microsecondsSinceEpoch;
+      final bTimestamp = b.timestamp?.microsecondsSinceEpoch;
+
+      if (aTimestamp == bTimestamp) {
+        // don't sort when a & b are equal (either both null or equal value)
+        return 0;
+      }
+
+      if (aTimestamp == null) {
+        return 1;
+      }
+
+      if (bTimestamp == null) {
+        return -1;
+      }
+
+      return aTimestamp > bTimestamp ? -1 : 1;
+    });
+
+  return InputCard(
+      onClosed: onClosed,
+      title: Text(AppLocalizations.of(context)!.selectMeasurementTitle),
+      child: ListView(
+        shrinkWrap: true,
+        children: [
+          for (final (index, data) in measurements.indexed)
+            _buildMeasurementTile(context, index, data),
+        ]
+      ),
+    );
+  }
+}
app/lib/features/bluetooth/ui/measurement_success.dart
@@ -24,7 +24,7 @@ class MeasurementSuccess extends StatelessWidget {
       onClosed: onTap,
       child: Center(
         child: ListTileTheme(
-          data: ListTileThemeData(
+          data: const ListTileThemeData(
             iconColor: Colors.orange,
           ),
           child: Column(
@@ -47,32 +47,32 @@ class MeasurementSuccess extends StatelessWidget {
               if (data.status?.bodyMovementDetected ?? false)
                 ListTile(
                   title: Text(AppLocalizations.of(context)!.bodyMovementDetected),
-                  leading: Icon(Icons.directions_walk),
+                  leading: const Icon(Icons.directions_walk),
                 ),
               if (data.status?.cuffTooLose ?? false)
                 ListTile(
                   title: Text(AppLocalizations.of(context)!.cuffTooLoose),
-                  leading: Icon(Icons.space_bar),
+                  leading: const Icon(Icons.space_bar),
                 ),
               if (data.status?.improperMeasurementPosition ?? false)
                 ListTile(
                   title: Text(AppLocalizations.of(context)!.improperMeasurementPosition),
-                  leading: Icon(Icons.emoji_people),
+                  leading: const Icon(Icons.emoji_people),
                 ),
               if (data.status?.irregularPulseDetected ?? false)
                 ListTile(
                   title: Text(AppLocalizations.of(context)!.irregularPulseDetected),
-                  leading: Icon(Icons.heart_broken),
+                  leading: const Icon(Icons.heart_broken),
                 ),
               if (data.status?.pulseRateExceedsUpperLimit ?? false)
                 ListTile(
                   title: Text(AppLocalizations.of(context)!.pulseRateExceedsUpperLimit),
-                  leading: Icon(Icons.monitor_heart),
+                  leading: const Icon(Icons.monitor_heart),
                 ),
               if (data.status?.pulseRateIsLessThenLowerLimit ?? false)
                 ListTile(
                   title: Text(AppLocalizations.of(context)!.pulseRateLessThanLowerLimit),
-                  leading: Icon(Icons.monitor_heart),
+                  leading: const Icon(Icons.monitor_heart),
                 ),
             ],
           ),
app/lib/features/bluetooth/bluetooth_input.dart
@@ -1,5 +1,6 @@
 import 'dart:async';
 
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart';
 import 'package:blood_pressure_app/features/bluetooth/logic/ble_read_cubit.dart';
 import 'package:blood_pressure_app/features/bluetooth/logic/bluetooth_cubit.dart';
 import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_data.dart';
@@ -8,12 +9,12 @@ import 'package:blood_pressure_app/features/bluetooth/ui/closed_bluetooth_input.
 import 'package:blood_pressure_app/features/bluetooth/ui/device_selection.dart';
 import 'package:blood_pressure_app/features/bluetooth/ui/input_card.dart';
 import 'package:blood_pressure_app/features/bluetooth/ui/measurement_failure.dart';
+import 'package:blood_pressure_app/features/bluetooth/ui/measurement_multiple.dart';
 import 'package:blood_pressure_app/features/bluetooth/ui/measurement_success.dart';
 import 'package:blood_pressure_app/logging.dart';
 import 'package:blood_pressure_app/model/storage/storage.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
-import 'package:flutter_blue_plus/flutter_blue_plus.dart' show BluetoothDevice, Guid;
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 import 'package:health_data_store/health_data_store.dart';
 
@@ -22,11 +23,15 @@ class BluetoothInput extends StatefulWidget {
   /// Create a measurement input through bluetooth.
   const BluetoothInput({super.key,
     required this.onMeasurement,
+    required this.manager,
     this.bluetoothCubit,
     this.deviceScanCubit,
     this.bleReadCubit,
   });
 
+  /// Bluetooth Backend manager
+  final BluetoothManager manager;
+
   /// Called when a measurement was received through bluetooth.
   final void Function(BloodPressureRecord data) onMeasurement;
 
@@ -43,8 +48,14 @@ class BluetoothInput extends StatefulWidget {
   State<BluetoothInput> createState() => _BluetoothInputState();
 }
 
-class _BluetoothInputState extends State<BluetoothInput> {
-  /// Whether the user expanded bluetooth input
+/// Read bluetooth input happy workflow:
+/// - build is called and renders ClosedBluetoothInput with read bluetooth input button
+/// - User clicks button, toggles _isActive
+/// - _buildActive is called, waits for device_scan_state.DeviceSelected
+/// - _buildReadDevice is called, waits for ble_read_state.BleReadSuccess
+/// - onMeasurement callback triggered
+class _BluetoothInputState extends State<BluetoothInput> with TypeLogger {
+  /// Whether the user initiated reading bluetooth input
   bool _isActive = false;
 
   late final BluetoothCubit _bluetoothCubit;
@@ -61,7 +72,7 @@ class _BluetoothInputState extends State<BluetoothInput> {
   @override
   void initState() {
     super.initState();
-    _bluetoothCubit = widget.bluetoothCubit?.call() ?? BluetoothCubit();
+    _bluetoothCubit = widget.bluetoothCubit?.call() ?? BluetoothCubit(manager: widget.manager);
   }
 
   @override
@@ -83,31 +94,83 @@ class _BluetoothInputState extends State<BluetoothInput> {
     }
 
     await _deviceReadCubit?.close();
-    _deviceReadCubit = null;
     await _deviceScanCubit?.close();
-    _deviceScanCubit = null;
     await _bluetoothSubscription?.cancel();
+    _deviceReadCubit = null;
+    _deviceScanCubit = null;
     _bluetoothSubscription = null;
   }
 
+  // TODO(derdilla): extract logic from UI
+  @override
+  Widget build(BuildContext context) {
+    const SizeChangedLayoutNotification().dispatch(context);
+    logger.finer('build[_isActive: $_isActive, _finishedData: $_finishedData]');
+
+    if (_finishedData != null) {
+      return MeasurementSuccess(
+        onTap: _returnToIdle,
+        data: _finishedData!,
+      );
+    }
+
+    if (_isActive) {
+      return _buildActive(context);
+    }
+
+    return ClosedBluetoothInput(
+      bluetoothCubit: _bluetoothCubit,
+      onStarted: () async {
+        setState(() => _isActive = true);
+      },
+      inputInfo: () async {
+        logger.finer('build.inputInfo[mounted: ${context.mounted}]');
+        if (context.mounted) {
+          await showDialog(
+            context: context,
+            builder: (BuildContext context) => AlertDialog(
+              title: Text(AppLocalizations.of(context)!.bluetoothInput),
+              content: Text(AppLocalizations.of(context)!.aboutBleInput),
+              actions: <Widget>[
+                ElevatedButton(
+                  child: Text((AppLocalizations.of(context)!.btnConfirm)),
+                  onPressed: () => Navigator.of(context).pop(),
+                ),
+              ],
+            ),
+          );
+        }
+      },
+    );
+  }
+
+  /// Build widget for 'adapter ready & discovering devices from bluetooth' state
   Widget _buildActive(BuildContext context) {
-    final Guid serviceUUID = Guid('1810');
-    final Guid characteristicUUID = Guid('2A35');
+    /// blood pressure service, see https://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v4.x.x/doc/html/group___u_u_i_d___s_e_r_v_i_c_e_s.html
+    const String serviceUUID = '1810';
+    /// blood pressure characterisic, see https://developer.nordicsemi.com/nRF51_SDK/nRF51_SDK_v4.x.x/doc/html/group___u_u_i_d___c_h_a_r_a_c_t_e_r_i_s_t_i_c_s.html#ga95fc99c7a99cf9d991c81027e4866936
+    const String characteristicUUID = '2A35';
+
     _bluetoothSubscription = _bluetoothCubit.stream.listen((state) {
-      if (state is! BluetoothReady) {
-        Log.trace('_BluetoothInputState: _bluetoothSubscription state=$state, calling _returnToIdle');
+      if (state is BluetoothStateReady) {
+        logger.finest('_bluetoothSubscription.listen: state=$state');
+      } else {
+        logger.finer('_bluetoothSubscription.listen: state=$state, calling _returnToIdle');
         _returnToIdle();
       }
     });
+
     final settings = context.watch<Settings>();
     _deviceScanCubit ??= widget.deviceScanCubit?.call() ?? DeviceScanCubit(
+      manager: widget.manager,
       service: serviceUUID,
       settings: settings,
     );
+
     return BlocBuilder<DeviceScanCubit, DeviceScanState>(
       bloc: _deviceScanCubit,
       builder: (context, DeviceScanState state) {
-        Log.trace('BluetoothInput _BluetoothInputState _deviceScanCubit: $state');
+        logger.finer('DeviceScanCubit.builder deviceScanState: $state');
         const SizeChangedLayoutNotification().dispatch(context);
         return switch(state) {
           DeviceListLoading() => _buildMainCard(context,
@@ -122,88 +185,54 @@ class _BluetoothInputState extends State<BluetoothInput> {
             scanResults: [ state.device ],
             onAccepted: (dev) => _deviceScanCubit!.acceptDevice(dev),
           ),
-            // distinction
-          DeviceSelected() => BlocConsumer<BleReadCubit, BleReadState>(
-            bloc: () {
-              _deviceReadCubit = widget.bleReadCubit?.call(state.device) ?? BleReadCubit(
-                state.device,
-                characteristicUUID: characteristicUUID,
-                serviceUUID: serviceUUID,
-              );
-              return _deviceReadCubit;
-            }(),
-            listener: (BuildContext context, BleReadState state) {
-              if (state is BleReadSuccess) {
-                final BloodPressureRecord record = BloodPressureRecord(
-                  time: state.data.timestamp ?? DateTime.now(),
-                  sys: state.data.isMMHG
-                    ? Pressure.mmHg(state.data.systolic.toInt())
-                    : Pressure.kPa(state.data.systolic),
-                  dia: state.data.isMMHG
-                    ? Pressure.mmHg(state.data.diastolic.toInt())
-                    : Pressure.kPa(state.data.diastolic),
-                  pul: state.data.pulse?.toInt(),
-                );
-                widget.onMeasurement(record);
-                setState(() {
-                  _finishedData = state.data;
-                });
-              }
-            },
-            builder: (BuildContext context, BleReadState state) {
-              Log.trace('_BluetoothInputState BleReadCubit: $state');
-              const SizeChangedLayoutNotification().dispatch(context);
-              return switch (state) {
-                BleReadInProgress() => _buildMainCard(context,
-                  child: const CircularProgressIndicator(),
-                ),
-                BleReadFailure() => MeasurementFailure(
-                  onTap: _returnToIdle,
-                ),
-                BleReadSuccess() => MeasurementSuccess(
-                  onTap: _returnToIdle,
-                  data: state.data,
-                ),
-              };
-            },
-          ),
+          DeviceSelected() => _buildReadDevice(state, serviceUUID: serviceUUID, characteristicUUID: characteristicUUID)
         };
       },
     );
   }
 
-  @override
-  Widget build(BuildContext context) {
-    const SizeChangedLayoutNotification().dispatch(context);
-    if (_finishedData != null) {
-      return MeasurementSuccess(
-        onTap: _returnToIdle,
-        data: _finishedData!,
-      );
-    }
-    if (_isActive) return _buildActive(context);
-    return ClosedBluetoothInput(
-      bluetoothCubit: _bluetoothCubit,
-      onStarted: () async {
-        setState(() =>_isActive = true);
-      },
-      inputInfo: () async {
-        if (context.mounted) {
-          await showDialog(
-            context: context,
-            builder: (BuildContext context) => AlertDialog(
-              title: Text(AppLocalizations.of(context)!.bluetoothInput),
-              content: Text(AppLocalizations.of(context)!.aboutBleInput),
-                actions: <Widget>[
-                  ElevatedButton(
-                    child: Text((AppLocalizations.of(context)!.btnConfirm)),
-                    onPressed: () => Navigator.of(context).pop(),
-                  ),
-                ],
-            ),
-          );
+  /// Build widget for 'reading characteristic value from bluetooth' state
+  Widget _buildReadDevice(DeviceSelected state, { required String serviceUUID, required String characteristicUUID }) {
+    logger.finer('_buildReadDevice: state: $state');
+    return BlocConsumer<BleReadCubit, BleReadState>(
+      bloc: () {
+        _deviceReadCubit = widget.bleReadCubit?.call(state.device) ?? BleReadCubit(
+          state.device,
+          characteristicUUID: characteristicUUID,
+          serviceUUID: serviceUUID,
+        );
+        return _deviceReadCubit;
+      }(),
+      listener: (BuildContext context, BleReadState state) {
+        if (state is BleReadSuccess) {
+          final BloodPressureRecord record = state.data.asBloodPressureRecord();
+          widget.onMeasurement(record);
+          setState(() => _finishedData = state.data);
         }
       },
+      builder: (BuildContext context, BleReadState state) {
+        logger.finer('BleReadCubit.builder: $state');
+        const SizeChangedLayoutNotification().dispatch(context);
+
+        return switch (state) {
+          BleReadInProgress() => _buildMainCard(context,
+            child: const CircularProgressIndicator(),
+          ),
+          BleReadFailure() => MeasurementFailure(
+            onTap: _returnToIdle,
+            reason: state.reason,
+          ),
+          BleReadMultiple() => MeasurementMultiple(
+            onClosed: _returnToIdle,
+            onSelect: (data) => _deviceReadCubit!.useMeasurement(data),
+            measurements: state.data,
+          ),
+          BleReadSuccess() => MeasurementSuccess(
+            onTap: _returnToIdle,
+            data: state.data,
+          ),
+        };
+      },
     );
   }
 
app/lib/features/input/add_measurement_dialoge.dart
@@ -1,6 +1,9 @@
 import 'dart:async';
+import 'dart:io';
 
 import 'package:blood_pressure_app/components/fullscreen_dialoge.dart';
+import 'package:blood_pressure_app/config.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart';
 import 'package:blood_pressure_app/features/bluetooth/bluetooth_input.dart';
 import 'package:blood_pressure_app/features/input/add_bodyweight_dialoge.dart';
 import 'package:blood_pressure_app/features/input/forms/date_time_form.dart';
@@ -252,6 +255,13 @@ class _AddEntryDialogeState extends State<AddEntryDialoge> {
           children: [
             if (settings.bleInput)
               BluetoothInput(
+                manager: BluetoothManager.create(
+                  isTestingEnvironment
+                    ? BluetoothBackend.mock
+                    : Platform.isAndroid
+                    ? BluetoothBackend.flutterBluePlus
+                    : BluetoothBackend.bluetoothLowEnergy
+                ),
                 onMeasurement: (record) => setState(
                   () => _loadFields((record, Note(time: record.time, note: noteController.text, color: color?.value), [])),
                 ),
@@ -322,7 +332,7 @@ class _AddEntryDialogeState extends State<AddEntryDialoge> {
               ),
             ),
             InputDecorator(
-              decoration: InputDecoration(
+              decoration: const InputDecoration(
                 contentPadding: EdgeInsets.zero,
               ),
               child: ColorSelectionListTile(
app/lib/features/statistics/clock_bp_graph.dart
@@ -24,7 +24,7 @@ class ClockBpGraph extends StatelessWidget {
     return SizedBox.square(
       dimension: MediaQuery.of(context).size.width,
       child: Padding(
-        padding: EdgeInsets.all(24.0),
+        padding: const EdgeInsets.all(24.0),
         child: CustomPaint(
           painter: _RadarChartPainter(
             brightness: Theme.of(context).brightness,
app/lib/l10n/app_en.arb
@@ -523,6 +523,22 @@
   "@weight": {},
   "enterWeight": "Enter weight",
   "@enterWeight": {},
+  "selectMeasurementTitle": "Select the measurement to use",
+  "@selectMeasurementTitle": {},
+  "measurementIndex": "Measurement #{number}",
+  "@measurementIndex": {
+      "placeholders": {
+          "number": {
+              "type": "int"
+          }
+      }
+  },
+  "select": "Select",
+  "@select": {
+      "description": "Used when f.e. selecting a single measurement when the bluetooth device returned multiple"
+  },
+  "bloodPressure": "Blood pressure",
+  "@bloodPressure": {},
   "preferredWeightUnit": "Preferred weight unit",
   "@preferredWeightUnit": {
     "description": "Setting for the unit the app will use for displaying weight"
app/lib/model/blood_pressure/medicine/intake_history.dart
@@ -28,7 +28,7 @@ class IntakeHistory extends ChangeNotifier {
           try {
             return OldMedicineIntake.deserialize(e, availableMedicines);
           } on FormatException {
-            Log.err('OldMedicineIntake deserialization problem: "$e"');
+            log.severe('OldMedicineIntake deserialization problem: "$e"');
             return null;
           }
         })
app/lib/model/storage/settings_store.dart
@@ -1,13 +1,12 @@
 import 'dart:collection';
 import 'dart:convert';
-import 'dart:io';
 
+import 'package:blood_pressure_app/config.dart';
 import 'package:blood_pressure_app/model/blood_pressure/medicine/medicine.dart';
 import 'package:blood_pressure_app/model/blood_pressure/pressure_unit.dart';
 import 'package:blood_pressure_app/model/horizontal_graph_line.dart';
 import 'package:blood_pressure_app/model/storage/convert_util.dart';
 import 'package:blood_pressure_app/model/weight_unit.dart';
-import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 
 /// Stores settings that are directly controllable by the user through the
@@ -412,9 +411,7 @@ class Settings extends ChangeNotifier {
 
   bool _bleInput = true;
   /// Whether to show bluetooth input on add measurement page.
-  bool get bleInput => (Platform.isAndroid || Platform.isIOS || Platform.isMacOS
-    || (kDebugMode && Platform.environment['FLUTTER_TEST'] == 'true'))
-      && _bleInput;
+  bool get bleInput => isPlatformSupportedBluetooth && _bleInput;
   set bleInput(bool value) {
     _bleInput = value;
     notifyListeners();
app/lib/model/iso_lang_names.dart
@@ -9,6 +9,7 @@ String getDisplayLanguage(Locale l) => switch(l.toLanguageTag()) {
   'fr' => 'Franรงaise',
   'it' => 'Italiano',
   'nb' => 'Norsk bokmรฅl',
+  'nl' => 'Nederlands',
   'pt' => 'Portuguรชs',
   'pt-BR' => 'Portuguรชs (Brasil)',
   'ru' => 'ะ ัƒััะบะธะน',
app/lib/screens/home_screen.dart
@@ -1,3 +1,6 @@
+import 'dart:io';
+
+import 'package:blood_pressure_app/config.dart';
 import 'package:blood_pressure_app/data_util/entry_context.dart';
 import 'package:blood_pressure_app/data_util/full_entry_builder.dart';
 import 'package:blood_pressure_app/data_util/interval_picker.dart';
@@ -66,7 +69,9 @@ class AppHome extends StatelessWidget {
         _appStart++;
       }
 
-      if (orientation == Orientation.landscape) return Scaffold(body: _buildValueGraph(context));
+      if (showValueGraphAsHomeScreenInLandscapeMode && orientation == Orientation.landscape) {
+        return Scaffold(body: _buildValueGraph(context));
+      }
       return DefaultTabController(
         length: 2,
         child: Scaffold(
app/lib/screens/settings_screen.dart
@@ -3,6 +3,7 @@ import 'dart:typed_data';
 
 import 'package:archive/archive_io.dart';
 import 'package:blood_pressure_app/components/input_dialoge.dart';
+import 'package:blood_pressure_app/config.dart';
 import 'package:blood_pressure_app/data_util/consistent_future_builder.dart';
 import 'package:blood_pressure_app/features/settings/delete_data_screen.dart';
 import 'package:blood_pressure_app/features/settings/enter_timeformat_dialoge.dart';
@@ -161,14 +162,10 @@ class SettingsPage extends StatelessWidget {
               ),
               SwitchListTile(
                 value: settings.bleInput,
-                onChanged: (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)
-                  ? (value) { settings.bleInput = value; }
-                  : null,
+                onChanged: isPlatformSupportedBluetooth ? (value) { settings.bleInput = value; } : null,
                 secondary: const Icon(Icons.bluetooth),
                 title: Text(localizations.bluetoothInput),
-                subtitle: (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)
-                  ? null
-                  : Text(localizations.errFeatureNotSupported),
+                subtitle: isPlatformSupportedBluetooth ? null : Text(localizations.errFeatureNotSupported),
               ),
               SwitchListTile(
                 value: settings.allowManualTimeInput,
@@ -381,7 +378,7 @@ class SettingsPage extends StatelessWidget {
                         loader = await FileSettingsLoader.load(dir);
                       } on FormatException catch (e, stack) {
                         messenger.showSnackBar(SnackBar(content: Text(localizations.invalidZip)));
-                        Log.err('invalid zip', [e, stack]);
+                        log.severe('invalid zip', e, stack);
                         return;
                       }
                     } else {
app/lib/config.dart
@@ -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;
app/lib/logging.dart
@@ -1,25 +1,40 @@
-import 'dart:io';
-
+import 'package:blood_pressure_app/config.dart';
 import 'package:flutter/foundation.dart';
+import 'package:logging/logging.dart';
+
+/// Logger instance
+final log = Logger('BloodPressureMonitor');
+
+/// Mixin to provide logging instances within classes
+///
+/// Usage: extend your class with this mixin by adding 'with TypeLogger'
+/// to be able to call the logger property anywhere in your class.
+mixin TypeLogger {
+  /// log interface, returns a [Logger] instance from https://pub.dev/packages/logging
+  Logger get logger => Logger('BPM[${Log.withoutTypes('$runtimeType')}]');
+}
 
 /// Simple class for manually logging in debug builds.
+///
+/// Also contains some logging configuration logic
 class Log {
-  /// Log an error with stack trace in debug builds.
-  static void err(String message, [List<Object>? dumps]) {
-    if (kDebugMode && !(Platform.environment['FLUTTER_TEST'] == 'true')) {
-      debugPrint('-----------------------------');
-      debugPrint('ERROR $message:');
-      debugPrintStack();
-      for (final e in dumps ?? []) {
-        debugPrint(e.toString());
-      }
-    }
+  /// Whether logging is enabled
+  static final enabled = kDebugMode && !isTestingEnvironment;
+
+  /// Format a log record
+  static String format(LogRecord record) {
+    final loggerName = record.loggerName == 'BloodPressureMonitor' ? null : record.loggerName;
+    return '${record.level.name}: ${record.time}: ${loggerName != null ? '$loggerName: ' : ''}${record.message}';
   }
 
-  /// Log a message in debug more
-  static void trace(String message) {
-    if (kDebugMode && !(Platform.environment['FLUTTER_TEST'] == 'true')) {
-      debugPrint('TRACE: $message');
+  /// Strip types from definition, i.e. MyClass<SomeType> -> MyClass
+  static String withoutTypes(String type) => type.replaceAll(RegExp(r'<[^>]+>'), '');
+
+  /// Register the apps logging config with [Logger].
+  static void setup() {
+    if (Log.enabled) {
+      Logger.root.level = Level.ALL;
+      Logger.root.onRecord.listen((record) => debugPrint(Log.format(record)));
     }
   }
 }
app/lib/main.dart
@@ -1,5 +1,9 @@
 import 'package:blood_pressure_app/app.dart';
+import 'package:blood_pressure_app/logging.dart';
 import 'package:flutter/material.dart';
 
 /// Run the [App].
-void main() => runApp(const App());
+void main() {
+  Log.setup();
+  runApp(const App());
+}
app/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -5,6 +5,7 @@
 import FlutterMacOS
 import Foundation
 
+import bluetooth_low_energy_darwin
 import flutter_blue_plus
 import package_info_plus
 import shared_preferences_foundation
@@ -12,6 +13,7 @@ import sqflite_darwin
 import url_launcher_macos
 
 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
+  BluetoothLowEnergyDarwinPlugin.register(with: registry.registrar(forPlugin: "BluetoothLowEnergyDarwinPlugin"))
   FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
   FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
   SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
app/test/features/bluetooth/logic/characteristics/ble_measurement_data_test.dart
@@ -1,10 +1,12 @@
+import 'dart:typed_data';
+
 import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_data.dart';
 import 'package:flutter_test/flutter_test.dart';
 
 void main() {
   test('decodes sample data', () {
     // 22 => 0001 0110
-    final result = BleMeasurementData.decode([22, 124, 0, 86, 0, 97, 0, 232, 7, 6, 15, 17, 17, 27, 51, 0, 0, 0], 0);
+    final result = BleMeasurementData.decode(Uint8List.fromList([22, 124, 0, 86, 0, 97, 0, 232, 7, 6, 15, 17, 17, 27, 51, 0, 0, 0]), 0);
 
     expect(result, isNotNull);
     expect(result!.systolic, 124.0);
app/test/features/bluetooth/logic/bluetooth_cubit_test.dart
@@ -1,9 +1,10 @@
 import 'dart:async';
 
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_manager.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/flutter_blue_plus_mockable.dart';
 import 'package:blood_pressure_app/features/bluetooth/logic/bluetooth_cubit.dart';
-import 'package:blood_pressure_app/features/bluetooth/logic/flutter_blue_plus_mockable.dart';
 import 'package:flutter/widgets.dart';
-import 'package:flutter_blue_plus/flutter_blue_plus.dart';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp;
 import 'package:flutter_test/flutter_test.dart';
 import 'package:mockito/annotations.dart';
 import 'package:mockito/mockito.dart';
@@ -16,28 +17,29 @@ import 'bluetooth_cubit_test.mocks.dart';
 void main() {
   test('should translate adapter stream to state', () async {
     WidgetsFlutterBinding.ensureInitialized();
-    final bluePlus = MockFlutterBluePlusMockable();
-    when(bluePlus.adapterState).thenAnswer((_) =>
+    final flutterBluePlus = MockFlutterBluePlusMockable();
+    when(flutterBluePlus.adapterState).thenAnswer((_) =>
       Stream.fromIterable([
-        BluetoothAdapterState.unknown,
-        BluetoothAdapterState.unavailable,
-        BluetoothAdapterState.turningOff,
-        BluetoothAdapterState.off,
-        BluetoothAdapterState.unauthorized,
-        BluetoothAdapterState.turningOn,
-        BluetoothAdapterState.on,
+        fbp.BluetoothAdapterState.unknown,
+        fbp.BluetoothAdapterState.unavailable,
+        fbp.BluetoothAdapterState.turningOff,
+        fbp.BluetoothAdapterState.off,
+        fbp.BluetoothAdapterState.unauthorized,
+        fbp.BluetoothAdapterState.turningOn,
+        fbp.BluetoothAdapterState.on,
     ]));
-    final cubit = BluetoothCubit(flutterBluePlus: bluePlus);
-    expect(cubit.state, isA<BluetoothInitial>());
+    final manager = FlutterBluePlusManager(flutterBluePlus);
+    final cubit = BluetoothCubit(manager: manager);
+    expect(cubit.state, isA<BluetoothStateInitial>());
 
     await expectLater(cubit.stream, emitsInOrder([
-      isA<BluetoothInitial>(),
-      isA<BluetoothUnfeasible>(),
-      isA<BluetoothDisabled>(),
-      isA<BluetoothDisabled>(),
-      isA<BluetoothUnauthorized>(),
-      isA<BluetoothDisabled>(),
-      isA<BluetoothReady>(),
+      isA<BluetoothStateInitial>(),
+      isA<BluetoothStateUnfeasible>(),
+      isA<BluetoothStateDisabled>(),
+      isA<BluetoothStateDisabled>(),
+      isA<BluetoothStateUnauthorized>(),
+      isA<BluetoothStateDisabled>(),
+      isA<BluetoothStateReady>(),
     ]));
   });
 }
app/test/features/bluetooth/logic/device_scan_cubit_test.dart
@@ -1,7 +1,9 @@
 import 'dart:async';
 
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_device.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/fbp_manager.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/flutter_blue_plus_mockable.dart';
 import 'package:blood_pressure_app/features/bluetooth/logic/device_scan_cubit.dart';
-import 'package:blood_pressure_app/features/bluetooth/logic/flutter_blue_plus_mockable.dart';
 import 'package:blood_pressure_app/model/storage/settings_store.dart';
 import 'package:flutter_blue_plus/flutter_blue_plus.dart';
 import 'package:flutter_test/flutter_test.dart';
@@ -11,18 +13,30 @@ import 'package:mockito/mockito.dart';
 @GenerateNiceMocks([
   MockSpec<BluetoothDevice>(),
   MockSpec<BluetoothService>(),
-  MockSpec<AdvertisementData>(),
   MockSpec<FlutterBluePlusMockable>(),
   MockSpec<ScanResult>(),
 ])
 import 'device_scan_cubit_test.mocks.dart';
 
+/// Helper util to create a [MockScanResult] & [MockBluetoothDevice]
+(MockScanResult, MockBluetoothDevice) createScanResultMock(String name) {
+  final scanResult = MockScanResult();
+  final btDevice = MockBluetoothDevice();
+  when(btDevice.platformName).thenReturn(name);
+  when(btDevice.remoteId).thenReturn(DeviceIdentifier(name));
+  when(scanResult.device).thenReturn(btDevice);
+  return (scanResult, btDevice);
+}
+
 void main() {
   test('finds and connects to devices', () async {
     final StreamController<List<ScanResult>> mockResults = StreamController.broadcast();
     final settings = Settings();
 
     final flutterBluePlus = MockFlutterBluePlusMockable();
+    final manager = FlutterBluePlusManager(flutterBluePlus);
+    expect(flutterBluePlus, manager.backend);
+
     when(flutterBluePlus.startScan(
         withServices: [Guid('1810')]
     )).thenAnswer((_) async {
@@ -32,43 +46,36 @@ void main() {
       when(flutterBluePlus.isScanningNow).thenReturn(false);
     });
     when(flutterBluePlus.scanResults).thenAnswer((_) => mockResults.stream);
+
     final cubit = DeviceScanCubit(
-        service: Guid('1810'),
+        service: '1810',
         settings: settings,
-        flutterBluePlus: flutterBluePlus
+        manager: manager
     );
     expect(cubit.state, isA<DeviceListLoading>());
 
-    final wrongRes0 = MockScanResult();
-    final wrongDev0 = MockBluetoothDevice();
-    final wrongRes1 = MockScanResult();
-    final wrongDev1 = MockBluetoothDevice();
-    when(wrongDev0.platformName).thenReturn('wrongDev0');
-    when(wrongRes0.device).thenReturn(wrongDev0);
-    when(wrongDev1.platformName).thenReturn('wrongDev1');
-    when(wrongRes1.device).thenReturn(wrongDev1);
+    final (wrongRes0, wrongDev0) = createScanResultMock('wrongDev0');
+    final (wrongRes1, wrongDev1) = createScanResultMock('wrongDev1');
+
     mockResults.sink.add([wrongRes0]);
     await expectLater(cubit.stream, emits(isA<SingleDeviceAvailable>()));
 
     mockResults.sink.add([wrongRes0, wrongRes1]);
     await expectLater(cubit.stream, emits(isA<DeviceListAvailable>()));
 
-    final dev = MockBluetoothDevice();
-    when(dev.platformName).thenReturn('testDev');
-    final res = MockScanResult();
-    when(res.device).thenReturn(dev);
-
+    final (res, dev) = createScanResultMock('testDev');
     mockResults.sink.add([res]);
-    await expectLater(cubit.stream, emits(isA<SingleDeviceAvailable>()
-        .having((r) => r.device.device, 'device', dev)));
+    await expectLater(cubit.stream, emits(isA<DeviceListAvailable>()
+        .having((r) => r.devices.last.source, 'device', res)));
 
     expect(settings.knownBleDev, isEmpty);
-    await cubit.acceptDevice(dev);
+    await cubit.acceptDevice(FlutterBluePlusDevice(manager, res));
     expect(settings.knownBleDev, contains('testDev'));
     // state should be set as we await above
     await expectLater(cubit.state, isA<DeviceSelected>()
-      .having((s) => s.device, 'device', dev));
+      .having((s) => s.device.source, 'device', res));
   });
+
   test('recognizes devices', () async {
     final StreamController<List<ScanResult>> mockResults = StreamController.broadcast();
     final settings = Settings(
@@ -76,6 +83,7 @@ void main() {
     );
 
     final flutterBluePlus = MockFlutterBluePlusMockable();
+    final manager = FlutterBluePlusManager(flutterBluePlus);
     when(flutterBluePlus.startScan(
       withServices: [Guid('1810')]
     )).thenAnswer((_) async {
@@ -86,24 +94,20 @@ void main() {
     });
     when(flutterBluePlus.scanResults).thenAnswer((_) => mockResults.stream);
     final cubit = DeviceScanCubit(
-      service: Guid('1810'),
+      service: '1810',
       settings: settings,
-      flutterBluePlus: flutterBluePlus
+      manager: manager
     );
     expect(cubit.state, isA<DeviceListLoading>());
 
-    final wrongRes0 = MockScanResult();
-    final wrongDev0 = MockBluetoothDevice();
-    when(wrongDev0.platformName).thenReturn('wrongDev0');
-    when(wrongRes0.device).thenReturn(wrongDev0);
+    final (wrongRes0, wrongDev0) = createScanResultMock('wrongDev0');
     mockResults.sink.add([wrongRes0]);
+
     await expectLater(cubit.stream, emits(isA<SingleDeviceAvailable>()));
 
-    final dev = MockBluetoothDevice();
-    when(dev.platformName).thenReturn('testDev');
-    final res = MockScanResult();
-    when(res.device).thenReturn(dev);
+    final (res, dev) = createScanResultMock('testDev');
     mockResults.sink.add([wrongRes0, res]);
+
     // No prompt when finding the correct device again
     await expectLater(cubit.stream, emits(isA<DeviceSelected>()));
   });
app/test/features/bluetooth/mock/fake_flutter_blue_plus.dart
@@ -1,6 +1,6 @@
 import 'dart:async';
 
-import 'package:blood_pressure_app/features/bluetooth/logic/flutter_blue_plus_mockable.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/flutter_blue_plus/flutter_blue_plus_mockable.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter_blue_plus/flutter_blue_plus.dart';
 
@@ -55,7 +55,7 @@ class FakeFlutterBluePlus extends FlutterBluePlusMockable {
   List<BluetoothDevice> get connectedDevices => throw UnimplementedError();
 
   @override
-  Future<List<BluetoothDevice>> get systemDevices => throw UnimplementedError();
+  Future<List<BluetoothDevice>> systemDevices(List<Guid> withServices) => throw UnimplementedError();
 
   @override
   Future<List<BluetoothDevice>> get bondedDevices => throw UnimplementedError();
@@ -64,7 +64,7 @@ class FakeFlutterBluePlus extends FlutterBluePlusMockable {
   Future<void> setOptions({bool showPowerAlert = true,}) => throw UnimplementedError();
 
   @override
-  Future<void> turnOn({int timeout = 60}) async => null;
+  Future<void> turnOn({int timeout = 60}) async {}
 
   @override
   Future<void> startScan({
app/test/features/bluetooth/mock/mock_ble_read_cubit.dart
@@ -2,7 +2,7 @@ import 'package:blood_pressure_app/features/bluetooth/logic/ble_read_cubit.dart'
 import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_data.dart';
 import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_status.dart';
 import 'package:flutter_bloc/flutter_bloc.dart';
-import 'package:flutter_blue_plus/flutter_blue_plus.dart';
+import 'package:logging/logging.dart';
 
 class MockBleReadCubit extends Cubit<BleReadState> implements BleReadCubit {
   MockBleReadCubit(): super(BleReadSuccess(
@@ -27,9 +27,22 @@ class MockBleReadCubit extends Cubit<BleReadState> implements BleReadCubit {
   ));
 
   @override
-  Guid get characteristicUUID => throw UnimplementedError();
+  String get characteristicUUID => throw UnimplementedError();
 
   @override
-  Guid get serviceUUID => throw UnimplementedError();
+  String get serviceUUID => throw UnimplementedError();
+
+  @override
+  Logger get logger => throw UnimplementedError();
+
+  @override
+  Future<void> takeMeasurement() {
+    throw UnimplementedError();
+  }
+
+  @override
+  Future<void> useMeasurement(BleMeasurementData data) {
+    throw UnimplementedError();
+  }
 
 }
app/test/features/bluetooth/ui/closed_input_test.dart
@@ -12,7 +12,9 @@ import '../../../util.dart';
 
 class MockBluetoothCubit extends MockCubit<BluetoothState>
     implements BluetoothCubit {
+  @override
   Future<bool> enableBluetooth() async => true;
+  @override
   Future<void> forceRefresh() async {}
 }
 
@@ -21,7 +23,7 @@ void main() {
     final states = StreamController<BluetoothState>.broadcast();
 
     final cubit = MockBluetoothCubit();
-    whenListen(cubit, states.stream, initialState: BluetoothInitial());
+    whenListen(cubit, states.stream, initialState: BluetoothStateInitial());
 
     int startCount = 0;
     await tester.pumpWidget(materialApp(ClosedBluetoothInput(
@@ -35,12 +37,12 @@ void main() {
     expect(find.byType(SizedBox), findsOneWidget);
     expect(find.byType(ListTile), findsNothing);
 
-    states.sink.add(BluetoothUnfeasible());
+    states.sink.add(BluetoothStateUnfeasible());
     await tester.pump();
     expect(find.byType(SizedBox), findsOneWidget);
     expect(find.byType(ListTile), findsNothing);
 
-    states.sink.add(BluetoothUnauthorized());
+    states.sink.add(BluetoothStateUnauthorized());
     await tester.pump();
     final localizations = await AppLocalizations.delegate.load(const Locale('en'));
     expect(find.text(localizations.errBleNoPerms), findsOneWidget);
@@ -48,14 +50,14 @@ void main() {
     await tester.tap(find.byType(ClosedBluetoothInput));
     expect(startCount, 0);
 
-    states.sink.add(BluetoothDisabled());
+    states.sink.add(BluetoothStateDisabled());
     await tester.pump();
     expect(find.text(localizations.bluetoothDisabled), findsOneWidget);
 
     await tester.tap(find.byType(ClosedBluetoothInput));
     expect(startCount, 0);
 
-    states.sink.add(BluetoothReady());
+    states.sink.add(BluetoothStateReady());
     await tester.pump();
     expect(find.text(localizations.bluetoothInput), findsOneWidget);
 
app/test/features/bluetooth/ui/device_selection_test.dart
@@ -1,30 +1,21 @@
 import 'dart:ui';
 
+import 'package:blood_pressure_app/features/bluetooth/backend/bluetooth_backend.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/mock/mock_device.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/mock/mock_manager.dart';
 import 'package:blood_pressure_app/features/bluetooth/ui/device_selection.dart';
-import 'package:flutter_blue_plus/flutter_blue_plus.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 import 'package:flutter_test/flutter_test.dart';
-import 'package:mockito/annotations.dart';
-import 'package:mockito/mockito.dart';
 
 import '../../../util.dart';
-@GenerateNiceMocks([
-  MockSpec<BluetoothDevice>(),
-  MockSpec<ScanResult>(),
-])
-import 'device_selection_test.mocks.dart';
 
 void main() {
   testWidgets('Connects with one element', (WidgetTester tester) async {
-    final dev = MockBluetoothDevice();
-    when(dev.platformName).thenReturn('Test device with long name (No.124356)');
-
-    final scanRes = MockScanResult();
-    when(scanRes.device).thenReturn(dev);
+    final dev = MockBluetoothDevice(MockBluetoothManager(), 'Test device with long name (No.124356)');
 
     final List<BluetoothDevice> accepted = [];
     await tester.pumpWidget(materialApp(DeviceSelection(
-      scanResults: [ scanRes ],
+      scanResults: [ dev ],
       onAccepted: accepted.add,
     )));
 
@@ -44,15 +35,7 @@ void main() {
   });
 
   testWidgets('Shows multiple elements', (WidgetTester tester) async {
-    ScanResult getDev(String name) {
-      final dev = MockBluetoothDevice();
-      when(dev.platformName).thenReturn(name);
-
-      final scanRes = MockScanResult();
-      when(scanRes.device).thenReturn(dev);
-
-      return scanRes;
-    }
+    BluetoothDevice getDev(String name) => MockBluetoothDevice(MockBluetoothManager(), name);
 
     await tester.pumpWidget(materialApp(DeviceSelection(
       scanResults: [
app/test/features/bluetooth/ui/measurement_failure_test.dart
@@ -12,6 +12,7 @@ void main() {
     int tapCount = 0;
     await tester.pumpWidget(materialApp(MeasurementFailure(
       onTap: () => tapCount++,
+      reason: '',
     )));
 
     expect(find.byIcon(Icons.error_outline), findsOneWidget);
app/test/features/bluetooth/ui/measurement_multiple_test.dart
@@ -0,0 +1,86 @@
+
+import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_data.dart';
+import 'package:blood_pressure_app/features/bluetooth/logic/characteristics/ble_measurement_status.dart';
+import 'package:blood_pressure_app/features/bluetooth/ui/measurement_multiple.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import '../../../util.dart';
+
+
+void main() {
+  testWidgets('should show everything and be interactive', (WidgetTester tester) async {
+    int tapCount = 0;
+    final List<BleMeasurementData> selected = [];
+    final measurements = [
+      BleMeasurementData(
+        systolic: 123,
+        diastolic: 456,
+        pulse: 67,
+        meanArterialPressure: 123456,
+        isMMHG: true,
+        userID: 3,
+        status: BleMeasurementStatus(
+          bodyMovementDetected: true,
+          cuffTooLose: true,
+          irregularPulseDetected: true,
+          pulseRateInRange: true,
+          pulseRateExceedsUpperLimit: true,
+          pulseRateIsLessThenLowerLimit: true,
+          improperMeasurementPosition: true,
+        ),
+        timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
+      ),
+      BleMeasurementData(
+        systolic: 124,
+        diastolic: 457,
+        pulse: null,
+        meanArterialPressure: 123457,
+        isMMHG: true,
+        userID: null,
+        status: BleMeasurementStatus(
+          bodyMovementDetected: true,
+          cuffTooLose: true,
+          irregularPulseDetected: true,
+          pulseRateInRange: true,
+          pulseRateExceedsUpperLimit: true,
+          pulseRateIsLessThenLowerLimit: true,
+          improperMeasurementPosition: true,
+        ),
+        timestamp: null,
+      ),
+    ];
+
+    await tester.pumpWidget(materialApp(MeasurementMultiple(
+      onClosed: () => tapCount++,
+      onSelect: selected.add,
+      measurements: measurements,
+    )));
+
+    final localizations = await AppLocalizations.delegate.load(const Locale('en'));
+    expect(find.text(localizations.selectMeasurementTitle), findsOneWidget);
+    expect(find.byIcon(Icons.close), findsOneWidget);
+
+    expect(find.byType(ListTile), findsNWidgets(2));
+
+    expect(find.textContaining(localizations.userID), findsOneWidget); // one measurement has UserID: null
+    expect(find.textContaining(localizations.bloodPressure), findsNWidgets(2));
+    for (final measurement in measurements) {
+      expect(find.textContaining(measurement.systolic.toInt().toString()), findsOneWidget);
+    }
+
+    expect(find.text(localizations.measurementIndex(2)), findsOneWidget);
+    expect(find.text(localizations.select), findsNWidgets(2));
+
+    expect(selected, isEmpty);
+    await tester.tap(find.text(localizations.select).first);
+    expect(selected.length, 1);
+    expect(selected, contains(measurements[0]));
+
+    expect(tapCount, 0);
+    await tester.tap(find.byIcon(Icons.close));
+    await tester.pump();
+    expect(tapCount, 1);
+  });
+}
app/test/features/bluetooth/bluetooth_input_test.dart
@@ -1,4 +1,6 @@
 import 'package:bloc_test/bloc_test.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/mock/mock_device.dart';
+import 'package:blood_pressure_app/features/bluetooth/backend/mock/mock_manager.dart';
 import 'package:blood_pressure_app/features/bluetooth/bluetooth_input.dart';
 import 'package:blood_pressure_app/features/bluetooth/logic/ble_read_cubit.dart';
 import 'package:blood_pressure_app/features/bluetooth/logic/bluetooth_cubit.dart';
@@ -7,7 +9,6 @@ import 'package:blood_pressure_app/features/bluetooth/logic/device_scan_cubit.da
 import 'package:blood_pressure_app/features/bluetooth/ui/closed_bluetooth_input.dart';
 import 'package:blood_pressure_app/features/bluetooth/ui/measurement_success.dart';
 import 'package:flutter/material.dart';
-import 'package:flutter_blue_plus/flutter_blue_plus.dart' hide BluetoothState;
 import 'package:flutter_test/flutter_test.dart';
 import 'package:health_data_store/health_data_store.dart';
 
@@ -30,10 +31,10 @@ class _MockBluetoothCubitFailingEnable extends MockCubit<BluetoothState>
 void main() {
   testWidgets('propagates successful read', (WidgetTester tester) async {
     final bluetoothCubit = _MockBluetoothCubit();
-    whenListen(bluetoothCubit, Stream<BluetoothState>.fromIterable([BluetoothReady()]),
-      initialState: BluetoothReady());
+    whenListen(bluetoothCubit, Stream<BluetoothState>.fromIterable([BluetoothStateReady()]),
+      initialState: BluetoothStateReady());
     final deviceScanCubit = _MockDeviceScanCubit();
-    final devScanOk = DeviceSelected(BluetoothDevice(remoteId: DeviceIdentifier('tstDev')));
+    final devScanOk = DeviceSelected(MockBluetoothDevice(MockBluetoothManager(), 'tstDev'));
     whenListen(deviceScanCubit, Stream<DeviceScanState>.fromIterable([devScanOk]),
       initialState: devScanOk);
     final bleReadCubit = _MockBleReadCubit();
@@ -42,10 +43,6 @@ void main() {
       diastolic: 45,
       meanArterialPressure: 67,
       isMMHG: true,
-      pulse: null,
-      userID: null,
-      status: null,
-      timestamp: null,
     ));
     whenListen(bleReadCubit, Stream<BleReadState>.fromIterable([bleReadOk]),
       initialState: bleReadOk,
@@ -53,6 +50,7 @@ void main() {
 
     final List<BloodPressureRecord> reads = [];
     await tester.pumpWidget(materialApp(BluetoothInput(
+      manager: MockBluetoothManager(),
       onMeasurement: reads.add,
       bluetoothCubit: () => bluetoothCubit,
       deviceScanCubit: () => deviceScanCubit,
@@ -70,10 +68,10 @@ void main() {
   });
   testWidgets('allows closing after successful read', (WidgetTester tester) async {
     final bluetoothCubit = _MockBluetoothCubit();
-    whenListen(bluetoothCubit, Stream<BluetoothState>.fromIterable([BluetoothReady()]),
-      initialState: BluetoothReady());
+    whenListen(bluetoothCubit, Stream<BluetoothState>.fromIterable([BluetoothStateReady()]),
+      initialState: BluetoothStateReady());
     final deviceScanCubit = _MockDeviceScanCubit();
-    final devScanOk = DeviceSelected(BluetoothDevice(remoteId: DeviceIdentifier('tstDev')));
+    final devScanOk = DeviceSelected(MockBluetoothDevice(MockBluetoothManager(), 'tstDev'));
     whenListen(deviceScanCubit, Stream<DeviceScanState>.fromIterable([devScanOk]),
       initialState: devScanOk);
     final bleReadCubit = _MockBleReadCubit();
@@ -82,10 +80,6 @@ void main() {
       diastolic: 45,
       meanArterialPressure: 67,
       isMMHG: true,
-      pulse: null,
-      userID: null,
-      status: null,
-      timestamp: null,
     ));
     whenListen(bleReadCubit, Stream<BleReadState>.fromIterable([bleReadOk]),
       initialState: bleReadOk,
@@ -93,6 +87,7 @@ void main() {
 
     final List<BloodPressureRecord> reads = [];
     await tester.pumpWidget(materialApp(BluetoothInput(
+      manager: MockBluetoothManager(),
       onMeasurement: reads.add,
       bluetoothCubit: () => bluetoothCubit,
       deviceScanCubit: () => deviceScanCubit,
@@ -110,9 +105,10 @@ void main() {
   });
   testWidgets("doesn't attempt to turn on bluetooth before interaction", (tester) async {
     final bluetoothCubit = _MockBluetoothCubitFailingEnable();
-    whenListen(bluetoothCubit, Stream<BluetoothState>.fromIterable([BluetoothDisabled()]),
-      initialState: BluetoothReady());
+    whenListen(bluetoothCubit, Stream<BluetoothState>.fromIterable([BluetoothStateDisabled()]),
+      initialState: BluetoothStateReady());
     await tester.pumpWidget(materialApp(BluetoothInput(
+      manager: MockBluetoothManager(),
       onMeasurement: (_) {},
       bluetoothCubit: () => bluetoothCubit,
     )));
app/test/features/input/add_measurement_dialoge_test.dart
@@ -144,8 +144,8 @@ void main() {
         bleInput: true,
       );
       await tester.pumpWidget(materialApp(
-        AddEntryDialoge(
-          availableMeds: const [],
+        const AddEntryDialoge(
+          availableMeds: [],
         ),
         settings: settings,
       ),);
@@ -418,7 +418,7 @@ void main() {
         matching: find.byType(TextFormField),
       );
       expect(focusedTextFormField, findsOneWidget);
-      final field = await tester.widget<TextFormField>(focusedTextFormField);
+      final field = tester.widget<TextFormField>(focusedTextFormField);
       expect(field.initialValue, '12');
     });
     testWidgets('should focus next on input finished', (tester) async {
app/test/features/statistics/clock_bp_graph_test.dart
@@ -15,7 +15,7 @@ void main() {
       home: Scaffold(
         body: ChangeNotifierProvider<Settings>(
           create: (_) => Settings(),
-          child: ClockBpGraph(measurements: []),
+          child: const ClockBpGraph(measurements: []),
         ),
       ),
     ));
@@ -44,7 +44,7 @@ void main() {
     ));
     await expectLater(find.byType(ClockBpGraph), myMatchesGoldenFile('ClockBpGraph-light.png'));
   });
-  testWidgets('renders sample data like expected in dart mode', (tester) async {
+  testWidgets('renders sample data like expected in dark mode', (tester) async {
     final rng = Random(1234);
     await tester.pumpWidget(MaterialApp(
       theme: ThemeData.dark(useMaterial3: true),
app/test/features/statistics/value_graph_test.dart
@@ -17,6 +17,7 @@ void main() {
     final records = [
       mockRecord(time: DateTime(2000), sys: 123),
       mockRecord(time: DateTime(2001), sys: 120),
+      // ignore: avoid_redundant_argument_values
       mockRecord(time: DateTime(2002), sys: null),
       mockRecord(time: DateTime(2003), sys: 123),
       mockRecord(time: DateTime(2004), sys: 200),
@@ -35,6 +36,7 @@ void main() {
     final records = [
       mockRecord(time: DateTime(2000), dia: 123),
       mockRecord(time: DateTime(2001), dia: 120),
+      // ignore: avoid_redundant_argument_values
       mockRecord(time: DateTime(2002), dia: null),
       mockRecord(time: DateTime(2003), dia: 123),
       mockRecord(time: DateTime(2004), dia: 200),
@@ -53,6 +55,7 @@ void main() {
     final records = [
       mockRecord(time: DateTime(2000), pul: 123),
       mockRecord(time: DateTime(2001), pul: 120),
+      // ignore: avoid_redundant_argument_values
       mockRecord(time: DateTime(2002), pul: null),
       mockRecord(time: DateTime(2003), pul: 123),
       mockRecord(time: DateTime(2004), pul: 200),
app/test/logging_test.dart
@@ -0,0 +1,14 @@
+import 'package:blood_pressure_app/logging.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+/// Helper util for tests
+void throwError() {
+  throw Error();
+}
+
+void main() {
+  test('Log.withoutTypes strips type references from log statement', () {
+    const logLine = 'SomeClass<SomeType>';
+    expect(Log.withoutTypes(logLine), 'SomeClass');
+  });
+}
app/windows/flutter/generated_plugin_registrant.cc
@@ -6,9 +6,12 @@
 
 #include "generated_plugin_registrant.h"
 
+#include <bluetooth_low_energy_windows/bluetooth_low_energy_windows_plugin_c_api.h>
 #include <url_launcher_windows/url_launcher_windows.h>
 
 void RegisterPlugins(flutter::PluginRegistry* registry) {
+  BluetoothLowEnergyWindowsPluginCApiRegisterWithRegistrar(
+      registry->GetRegistrarForPlugin("BluetoothLowEnergyWindowsPluginCApi"));
   UrlLauncherWindowsRegisterWithRegistrar(
       registry->GetRegistrarForPlugin("UrlLauncherWindows"));
 }
app/windows/flutter/generated_plugins.cmake
@@ -3,6 +3,7 @@
 #
 
 list(APPEND FLUTTER_PLUGIN_LIST
+  bluetooth_low_energy_windows
   url_launcher_windows
 )
 
app/pubspec.lock
@@ -86,6 +86,62 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "9.1.7"
+  bluetooth_low_energy:
+    dependency: "direct main"
+    description:
+      name: bluetooth_low_energy
+      sha256: "057c146c3d5378f6a048b6a792cee7029bb185c7b3392dfb1605228325a5e336"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.2"
+  bluetooth_low_energy_android:
+    dependency: transitive
+    description:
+      name: bluetooth_low_energy_android
+      sha256: f8cbef16b980f96c09df5d1d46b61be9f05683866151440e9987796607a4e7d8
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.3"
+  bluetooth_low_energy_darwin:
+    dependency: transitive
+    description:
+      name: bluetooth_low_energy_darwin
+      sha256: "849ba53f7d34845ad7491cd9cdb3784301aa54fe682c91cab804ed55cfd259d5"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.0"
+  bluetooth_low_energy_linux:
+    dependency: transitive
+    description:
+      name: bluetooth_low_energy_linux
+      sha256: "4d1aaaede517f95320dcf9ad271091ab42c4ad8ba5bfa0e822744d850dcf0048"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.0"
+  bluetooth_low_energy_platform_interface:
+    dependency: transitive
+    description:
+      name: bluetooth_low_energy_platform_interface
+      sha256: bc2e8d97c141653e5747bcb3cdc9fe956541b6ecc6e5f158b99a2f3abc2d946a
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.0"
+  bluetooth_low_energy_windows:
+    dependency: transitive
+    description:
+      name: bluetooth_low_energy_windows
+      sha256: "4904530cb3e7e1dd7a66919b4c926f8a03ed9924c3c2ce068aef7e0e10ced555"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.0.0"
+  bluez:
+    dependency: transitive
+    description:
+      name: bluez
+      sha256: "203a1924e818a9dd74af2b2c7a8f375ab8e5edf0e486bba8f90a0d8a17ed9fce"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.8.2"
   boolean_selector:
     dependency: transitive
     description:
@@ -194,10 +250,10 @@ packages:
     dependency: "direct main"
     description:
       name: collection
-      sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
+      sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
       url: "https://pub.dev"
     source: hosted
-    version: "1.18.0"
+    version: "1.19.0"
   convert:
     dependency: transitive
     description:
@@ -246,6 +302,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.3.7"
+  dbus:
+    dependency: transitive
+    description:
+      name: dbus
+      sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.7.10"
   diff_match_patch:
     dependency: transitive
     description:
@@ -443,6 +507,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "4.0.2"
+  hybrid_logging:
+    dependency: transitive
+    description:
+      name: hybrid_logging
+      sha256: "54248d52ce68c14702a42fbc4083bac5c6be30f6afad8a41be4bbadd197b8af5"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.0"
   image:
     dependency: transitive
     description:
@@ -492,18 +564,18 @@ packages:
     dependency: transitive
     description:
       name: leak_tracker
-      sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
+      sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
       url: "https://pub.dev"
     source: hosted
-    version: "10.0.5"
+    version: "10.0.7"
   leak_tracker_flutter_testing:
     dependency: transitive
     description:
       name: leak_tracker_flutter_testing
-      sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
+      sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
       url: "https://pub.dev"
     source: hosted
-    version: "3.0.5"
+    version: "3.0.8"
   leak_tracker_testing:
     dependency: transitive
     description:
@@ -521,7 +593,7 @@ packages:
     source: hosted
     version: "5.0.0"
   logging:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: logging
       sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
@@ -852,7 +924,7 @@ packages:
     dependency: transitive
     description: flutter
     source: sdk
-    version: "0.0.99"
+    version: "0.0.0"
   source_gen:
     dependency: transitive
     description:
@@ -917,6 +989,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.3.4"
+  sqflite_darwin:
+    dependency: transitive
+    description:
+      name: sqflite_darwin
+      sha256: "769733dddf94622d5541c73e4ddc6aa7b252d865285914b6fcd54a63c4b4f027"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.3.3+1"
   sqflite_darwin:
     dependency: transitive
     description:
@@ -969,10 +1049,10 @@ packages:
     dependency: transitive
     description:
       name: string_scanner
-      sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
+      sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
       url: "https://pub.dev"
     source: hosted
-    version: "1.2.0"
+    version: "1.3.0"
   sync_http:
     dependency: transitive
     description:
@@ -1001,26 +1081,26 @@ packages:
     dependency: transitive
     description:
       name: test
-      sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e"
+      sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f"
       url: "https://pub.dev"
     source: hosted
-    version: "1.25.7"
+    version: "1.25.8"
   test_api:
     dependency: transitive
     description:
       name: test_api
-      sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
+      sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
       url: "https://pub.dev"
     source: hosted
-    version: "0.7.2"
+    version: "0.7.3"
   test_core:
     dependency: transitive
     description:
       name: test_core
-      sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696"
+      sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d"
       url: "https://pub.dev"
     source: hosted
-    version: "0.6.4"
+    version: "0.6.5"
   timing:
     dependency: transitive
     description:
app/pubspec.yaml
@@ -32,11 +32,13 @@ dependencies:
   health_data_store:
     path: ../health_data_store/
   flutter_bloc: ^8.1.6
+  bluetooth_low_energy: ^6.0.2
   flutter_blue_plus: ^1.33.6
   archive: ^3.6.1
   file_picker: ^8.1.3
   fluttertoast: ^8.2.8
   app_settings: ^5.1.1
+  logging: ^1.2.0
   persistent_user_dir_access_android: ^0.0.1
 
   # desktop only
BLUETOOTH.md
@@ -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|
+|---|---| :---: | :---: | :---: |
+|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/