From f62ee1adebbdeae2f9cbccb00cc05573f63f68db Mon Sep 17 00:00:00 2001 From: Bertrand Esperou Date: Sat, 12 Apr 2025 11:40:32 +0000 Subject: [PATCH 01/10] implement flutter_reactive_ble with previously existing method Ergomenter class methods --- lib/models/ergometer.dart | 103 +++++++++++++++++++++++++++----------- 1 file changed, 74 insertions(+), 29 deletions(-) diff --git a/lib/models/ergometer.dart b/lib/models/ergometer.dart index 5b0e4fe..0949e2f 100644 --- a/lib/models/ergometer.dart +++ b/lib/models/ergometer.dart @@ -36,35 +36,44 @@ class Ergometer { //this may cause problems if the device goes out of range between scenning and trying to connect. maybe use connectToAdvertisingDevice instead to mitigate this and prevent a hang on android //if no services are specified in the `servicesWithCharacteristicsToDiscover` parameter, then full service discovery will be performed - return _flutterReactiveBle.connectToDevice(id: _peripheral.id).asyncMap((connectionStateUpdate) { - switch (connectionStateUpdate.connectionState) { - case DeviceConnectionState.connecting: - return ErgometerConnectionState.connecting; - case DeviceConnectionState.connected: - return ErgometerConnectionState.connected; - case DeviceConnectionState.disconnecting: - return ErgometerConnectionState.disconnected; - case DeviceConnectionState.disconnected: - return ErgometerConnectionState.disconnected; - default: - return ErgometerConnectionState.disconnected; - } - }); + _flutterReactiveBle.connectToDevice(id: _peripheral.id); + return monitorConnectionState; + } + + /// Deprecation notice: disconnect does not exists on FlutterReactiveBle library + @Deprecated("Destroy the Ergometer object to disconnect") + void disconnectOrCancel() { + throw NoSuchMethodError; + } + /// Subscribe to a stream of data from the erg + /// (ex: general.distance, stroke.drive_length, ...) + Stream monitorForData(Set datakey) { + throw UnimplementedError('$datakey not implemented'); } /// Returns a stream of [WorkoutSummary] objects upon completion of any workout that would normally be saved to the Erg's memory. This includes any pre-programmed piece and any "just row" pieces longer than 1 minute. @Deprecated("This API is being deprecated in an upcoming version") Stream monitorForWorkoutSummary() { - - var workoutSummaryCharacteristic1 = QualifiedCharacteristic(serviceId: Uuid.parse(Identifiers.C2_ROWING_PRIMARY_SERVICE_UUID), characteristicId: Uuid.parse(Identifiers.C2_ROWING_END_OF_WORKOUT_SUMMARY_CHARACTERISTIC_UUID), deviceId: _peripheral.id); - - var workoutSummaryCharacteristic2 = QualifiedCharacteristic(serviceId: Uuid.parse(Identifiers.C2_ROWING_PRIMARY_SERVICE_UUID), characteristicId: Uuid.parse(Identifiers.C2_ROWING_END_OF_WORKOUT_SUMMARY_CHARACTERISTIC2_UUID), deviceId: _peripheral.id); - - Stream ws1 = _flutterReactiveBle.subscribeToCharacteristic(workoutSummaryCharacteristic1).asyncMap((datapoint) => Uint8List.fromList(datapoint)); - - - Stream ws2 = _flutterReactiveBle.subscribeToCharacteristic(workoutSummaryCharacteristic2).asyncMap((datapoint) => Uint8List.fromList(datapoint)); + var workoutSummaryCharacteristic1 = QualifiedCharacteristic( + serviceId: Uuid.parse(Identifiers.C2_ROWING_PRIMARY_SERVICE_UUID), + characteristicId: Uuid.parse( + Identifiers.C2_ROWING_END_OF_WORKOUT_SUMMARY_CHARACTERISTIC_UUID), + deviceId: _peripheral.id); + + var workoutSummaryCharacteristic2 = QualifiedCharacteristic( + serviceId: Uuid.parse(Identifiers.C2_ROWING_PRIMARY_SERVICE_UUID), + characteristicId: Uuid.parse( + Identifiers.C2_ROWING_END_OF_WORKOUT_SUMMARY_CHARACTERISTIC2_UUID), + deviceId: _peripheral.id); + + Stream ws1 = _flutterReactiveBle + .subscribeToCharacteristic(workoutSummaryCharacteristic1) + .asyncMap((datapoint) => Uint8List.fromList(datapoint)); + + Stream ws2 = _flutterReactiveBle + .subscribeToCharacteristic(workoutSummaryCharacteristic2) + .asyncMap((datapoint) => Uint8List.fromList(datapoint)); return Rx.zip2(ws1, ws2, (Uint8List ws1Result, Uint8List ws2Result) { List combinedList = ws1Result.toList(); @@ -73,13 +82,43 @@ class Ergometer { }); } + // Ensure compatibility + @Deprecated("Use getMonitorConnectionState getter") + Stream monitorConnectionState() { + return getMonitorConnectionState; + } + + /// Expose a stream of events to enable monitoring the erg's connection state + /// This acts as a wrapper around the state provided by the internal bluetooth library to aid with swapping it out later. + Stream get getMonitorConnectionState => + _flutterReactiveBle.connectedDeviceStream + .asyncMap((connectionStateUpdate) { + switch (connectionStateUpdate.connectionState) { + case DeviceConnectionState.connecting: + return ErgometerConnectionState.connecting; + case DeviceConnectionState.connected: + return ErgometerConnectionState.connected; + case DeviceConnectionState.disconnecting: + return ErgometerConnectionState.disconnected; + default: + return ErgometerConnectionState.disconnected; + } + }); + /// An internal read function for accessing the PM's CSAFE API over bluetooth. /// /// Intended for passing to the csafe_fitness library to allow it to read response data from the erg Stream _readCsafe() { - var csafeRxCharacteristic = QualifiedCharacteristic(serviceId: Uuid.parse(Identifiers.C2_ROWING_CONTROL_SERVICE_UUID), characteristicId: Uuid.parse(Identifiers.C2_ROWING_PM_TRANSMIT_CHARACTERISTIC_UUID), deviceId: _peripheral.id); - - return _flutterReactiveBle.subscribeToCharacteristic(csafeRxCharacteristic).asyncMap((datapoint) => Uint8List.fromList(datapoint)).asyncMap((datapoint) { + var csafeRxCharacteristic = QualifiedCharacteristic( + serviceId: Uuid.parse(Identifiers.C2_ROWING_CONTROL_SERVICE_UUID), + characteristicId: + Uuid.parse(Identifiers.C2_ROWING_PM_TRANSMIT_CHARACTERISTIC_UUID), + deviceId: _peripheral.id); + + return _flutterReactiveBle + .subscribeToCharacteristic(csafeRxCharacteristic) + .asyncMap((datapoint) => Uint8List.fromList(datapoint)) + .asyncMap((datapoint) { print("reading data: $datapoint"); return datapoint; }); @@ -89,7 +128,11 @@ class Ergometer { /// /// Intended for passing to the csafe_fitness library to allow it to write commands to the erg void _writeCsafe(Uint8List value) { - var csafeTxCharacteristic = QualifiedCharacteristic(serviceId: Uuid.parse(Identifiers.C2_ROWING_CONTROL_SERVICE_UUID), characteristicId: Uuid.parse(Identifiers.C2_ROWING_PM_RECEIVE_CHARACTERISTIC_UUID), deviceId: _peripheral.id); + var csafeTxCharacteristic = QualifiedCharacteristic( + serviceId: Uuid.parse(Identifiers.C2_ROWING_CONTROL_SERVICE_UUID), + characteristicId: + Uuid.parse(Identifiers.C2_ROWING_PM_RECEIVE_CHARACTERISTIC_UUID), + deviceId: _peripheral.id); // return _peripheral.writeCharacteristic( // Identifiers.C2_ROWING_CONTROL_SERVICE_UUID, @@ -98,10 +141,12 @@ class Ergometer { // true); // //.asyncMap((datapoint) => datapoint.read()); - _flutterReactiveBle.writeCharacteristicWithResponse(csafeTxCharacteristic, value: value); + _flutterReactiveBle.writeCharacteristicWithResponse(csafeTxCharacteristic, + value: value); } - @Deprecated("This is a temporary function for development/experimentation and will be gone very soon") + @Deprecated( + "This is a temporary function for development/experimentation and will be gone very soon") void configure2kWorkout() async { //Workout workout await _csafeClient!.sendCommands([ From 631344ae7038f48844c2efd495555894c7822d84 Mon Sep 17 00:00:00 2001 From: Bertrand Esperou Date: Sat, 12 Apr 2025 14:13:15 +0000 Subject: [PATCH 02/10] fixing flutter analyze --- lib/models/ergometer.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/ergometer.dart b/lib/models/ergometer.dart index 0949e2f..43670e1 100644 --- a/lib/models/ergometer.dart +++ b/lib/models/ergometer.dart @@ -37,7 +37,7 @@ class Ergometer { //if no services are specified in the `servicesWithCharacteristicsToDiscover` parameter, then full service discovery will be performed _flutterReactiveBle.connectToDevice(id: _peripheral.id); - return monitorConnectionState; + return getMonitorConnectionState; } /// Deprecation notice: disconnect does not exists on FlutterReactiveBle library From b2c41b976a7fe8bcb8252d2ff57e87d224a64e0d Mon Sep 17 00:00:00 2001 From: Bertrand Esperou Date: Wed, 16 Apr 2025 19:55:12 +0000 Subject: [PATCH 03/10] first bluetooth tests making use of the mocktail library --- example/test/widget_test.dart | 30 --------- lib/models/ergometer.dart | 11 +-- pubspec.yaml | 1 + test/ergometer_test.dart | 122 +++++++++++++++++++++++++++++++++- 4 files changed, 128 insertions(+), 36 deletions(-) delete mode 100644 example/test/widget_test.dart diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart deleted file mode 100644 index 6db856c..0000000 --- a/example/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../lib/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/lib/models/ergometer.dart b/lib/models/ergometer.dart index 43670e1..564a86d 100644 --- a/lib/models/ergometer.dart +++ b/lib/models/ergometer.dart @@ -2,10 +2,10 @@ import 'dart:typed_data'; import 'package:c2bluetooth/c2bluetooth.dart'; import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; -import '../internal/commands.dart'; -import '../internal/datatypes.dart'; +import 'package:c2bluetooth/internal/commands.dart'; +import 'package:c2bluetooth/internal/datatypes.dart'; import 'package:csafe_fitness/csafe_fitness.dart'; -import '../helpers.dart'; +import 'package:c2bluetooth/helpers.dart'; import 'workout.dart'; import 'package:c2bluetooth/constants.dart' as Identifiers; import 'package:rxdart/rxdart.dart'; @@ -13,7 +13,7 @@ import 'package:rxdart/rxdart.dart'; enum ErgometerConnectionState { connecting, connected, disconnected } class Ergometer { - final _flutterReactiveBle = FlutterReactiveBle(); + final FlutterReactiveBle _flutterReactiveBle; DiscoveredDevice _peripheral; Csafe? _csafeClient; @@ -25,7 +25,8 @@ class Ergometer { /// This is intended only for internal use by [ErgBleManager.startErgScan]. /// Consider this method a private API that is subject to unannounced breaking /// changes. There are likely much better methods to use for whatever you are trying to do. - Ergometer(this._peripheral); + Ergometer(this._peripheral, {FlutterReactiveBle? bleClient}) + : _flutterReactiveBle = bleClient ?? FlutterReactiveBle(); /// Connect to this erg and discover the services and characteristics that it offers /// this returns a stream of [ErgometerConnectionState] events to enable monitoring the erg's connection state and disconnecting. diff --git a/pubspec.yaml b/pubspec.yaml index b1f4ab4..38a8eed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + mocktail: ^1.0.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/ergometer_test.dart b/test/ergometer_test.dart index f88884f..451ac11 100644 --- a/test/ergometer_test.dart +++ b/test/ergometer_test.dart @@ -1,7 +1,127 @@ +import 'package:c2bluetooth/models/ergometer.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; import 'package:flutter_test/flutter_test.dart'; -// import '../lib/models/ergometer.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockFlutterReactiveBle extends Mock implements FlutterReactiveBle {} void main() { + group('Bluetooth tests', () { + setUp(() { + registerFallbackValue(Stream.value(ConnectionStateUpdate( + deviceId: 'fallback', + connectionState: DeviceConnectionState.disconnected, + failure: null, + ))); + registerFallbackValue(DiscoveredDevice( + id: 'deviceId', + name: 'deviceName', + serviceData: {}, + manufacturerData: Uint8List.fromList([1, 1, 1, 1]), + rssi: 90, + serviceUuids: [])); + registerFallbackValue(QualifiedCharacteristic( + characteristicId: Uuid.parse('c5cc5bf5-2bd1-4d1a-939a-5e15fb9b81a1'), + serviceId: Uuid.parse('c5cc5bf5-2bd1-4d1a-939a-5e15fb9b81a2'), + deviceId: 'deviceId')); + }); + + test('Ensure DeviceConnectionState to ErgometerConnectionState translation', + () { + DiscoveredDevice device = DiscoveredDevice( + id: 'deviceId', + name: 'deviceName', + serviceData: {}, + manufacturerData: Uint8List.fromList([1, 1, 1, 1]), + rssi: 90, + serviceUuids: []); + final fakeConnectionUpdates = Stream.fromIterable([ + ConnectionStateUpdate( + deviceId: 'deviceId', + connectionState: DeviceConnectionState.connecting, + failure: null, + ), + ConnectionStateUpdate( + deviceId: 'deviceId', + connectionState: DeviceConnectionState.connected, + failure: null, + ), + ConnectionStateUpdate( + deviceId: 'deviceId', + connectionState: DeviceConnectionState.disconnecting, + failure: null), + ConnectionStateUpdate( + deviceId: 'deviceId', + connectionState: DeviceConnectionState.disconnected, + failure: null) + ]); + final mockBle = MockFlutterReactiveBle(); + final erg = Ergometer(device, bleClient: mockBle); + when(() => mockBle.connectedDeviceStream) + .thenAnswer((_) => fakeConnectionUpdates); + expect( + erg.getMonitorConnectionState, + emitsInOrder([ + ErgometerConnectionState.connecting, + ErgometerConnectionState.connected, + ErgometerConnectionState.disconnected, + ErgometerConnectionState.disconnected + ])); + verifyNever(() => mockBle.connectToDevice( + id: 'deviceId', + connectionTimeout: any(named: 'connectionTimeout'), + )); + }); + test('Retrieve ErgometerConnectionState status during connection', () { + // dummy discovered device + DiscoveredDevice device = DiscoveredDevice( + id: 'deviceId', + name: 'deviceName', + serviceData: {}, + manufacturerData: Uint8List.fromList([1, 1, 1, 1]), + rssi: 90, + serviceUuids: []); + final fakeSubscriptionChar = Stream>.fromIterable([ + [1, 1, 1], + [2, 2, 2] + ]); + final fakeConnectionUpdates = Stream.fromIterable([ + ConnectionStateUpdate( + deviceId: 'deviceId', + connectionState: DeviceConnectionState.connecting, + failure: null, + ), + ConnectionStateUpdate( + deviceId: 'deviceId', + connectionState: DeviceConnectionState.connected, + failure: null, + ) + ]); + final mockBle = MockFlutterReactiveBle(); + final erg = Ergometer(device, bleClient: mockBle); + when(() => mockBle.connectedDeviceStream) + .thenAnswer((_) => fakeConnectionUpdates); + when( + () => mockBle.connectToDevice( + id: any(named: 'id'), + connectionTimeout: any(named: 'connectionTimeout'), + ), + ).thenAnswer((_) => fakeConnectionUpdates); + when(() => mockBle.subscribeToCharacteristic(any())) + .thenAnswer((_) => fakeSubscriptionChar); + expect( + erg.connectAndDiscover(), + emitsInOrder([ + ErgometerConnectionState.connecting, + ErgometerConnectionState.connected + ])); + verify(() => mockBle.connectToDevice( + id: 'deviceId', + connectionTimeout: any(named: 'connectionTimeout'), + )).called(1); + }); + }); test('instantiate from a peripheral', () { // final bytes = Uint8List.fromList([0, 0, 0, 128]); // expect(CsafeIntExtension.fromBytes(bytes), 128); From ca4f4d48816bc83e217fc62f2ff0fd121e8a888c Mon Sep 17 00:00:00 2001 From: Bertrand Esperou Date: Tue, 29 Apr 2025 11:48:12 +0000 Subject: [PATCH 04/10] Single instance of FlutterReactiveBle in memory owned by ErgBleManager --- lib/models/ergblemanager.dart | 2 +- lib/models/ergometer.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/models/ergblemanager.dart b/lib/models/ergblemanager.dart index 6fc3ca9..55ee0db 100644 --- a/lib/models/ergblemanager.dart +++ b/lib/models/ergblemanager.dart @@ -12,7 +12,7 @@ class ErgBleManager { Stream startErgScan() { return _manager.scanForDevices(withServices: [ Uuid.parse(Identifiers.C2_ROWING_BASE_UUID) - ]).map((scanResult) => Ergometer(scanResult)); + ]).map((scanResult) => Ergometer(scanResult, bleClient: _manager)); } /// Clean up/destroy/deallocate resources so that they are availalble again diff --git a/lib/models/ergometer.dart b/lib/models/ergometer.dart index 564a86d..88f2d09 100644 --- a/lib/models/ergometer.dart +++ b/lib/models/ergometer.dart @@ -25,8 +25,8 @@ class Ergometer { /// This is intended only for internal use by [ErgBleManager.startErgScan]. /// Consider this method a private API that is subject to unannounced breaking /// changes. There are likely much better methods to use for whatever you are trying to do. - Ergometer(this._peripheral, {FlutterReactiveBle? bleClient}) - : _flutterReactiveBle = bleClient ?? FlutterReactiveBle(); + Ergometer(this._peripheral, {required FlutterReactiveBle bleClient}) + : _flutterReactiveBle = bleClient; /// Connect to this erg and discover the services and characteristics that it offers /// this returns a stream of [ErgometerConnectionState] events to enable monitoring the erg's connection state and disconnecting. From 1fd745c8df9601e9f37bd227bb17f793b39ce497 Mon Sep 17 00:00:00 2001 From: Bertrand Esperou Date: Tue, 29 Apr 2025 12:11:21 +0000 Subject: [PATCH 05/10] Allow testing when using a mock in place of the reactive package --- lib/models/ergblemanager.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/models/ergblemanager.dart b/lib/models/ergblemanager.dart index 55ee0db..4f13f07 100644 --- a/lib/models/ergblemanager.dart +++ b/lib/models/ergblemanager.dart @@ -1,9 +1,17 @@ import 'package:c2bluetooth/constants.dart' as Identifiers; +import 'package:flutter/foundation.dart'; import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; import 'ergometer.dart'; class ErgBleManager { - final _manager = FlutterReactiveBle(); + final FlutterReactiveBle _manager; + + ErgBleManager() : _manager = FlutterReactiveBle(); + + /// Allow [ErgBleManager] to be tested using a Mocked bluetooth client + @visibleForTesting + ErgBleManager.withDependency({FlutterReactiveBle? bleClient}) + : _manager = bleClient ?? FlutterReactiveBle(); /// Begin scanning for Ergs. /// From 8d1177b39eb56d6fa502e4ec267ce275a65fe71c Mon Sep 17 00:00:00 2001 From: Bertrand Esperou Date: Sat, 26 Apr 2025 07:13:15 +0000 Subject: [PATCH 06/10] adding mocktail test for ErgBleManager --- test/ergblemanager_test.dart | 62 ++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/test/ergblemanager_test.dart b/test/ergblemanager_test.dart index 279d749..ac2b433 100644 --- a/test/ergblemanager_test.dart +++ b/test/ergblemanager_test.dart @@ -1,17 +1,59 @@ +import 'dart:typed_data'; + +import 'package:c2bluetooth/c2bluetooth.dart'; +import 'package:c2bluetooth/constants.dart' as Identifiers; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; -// import '../lib/models/ergblemanager.dart'; +class MockFlutterReactiveBle extends Mock implements FlutterReactiveBle {} void main() { - test('can obtain stream of ergometers present', () { - // final bytes = Uint8List.fromList([0, 0, 0, 128]); - // expect(CsafeIntExtension.fromBytes(bytes), 128); - // expect(CsafeIntExtension.fromBytes(bytes, Endian.little), 2147483648); - }); + setUp(() {}); + test('translate the stream of discovered devices as ergometers', () { + /// The whole purpose of the startErgScan method is to translate + /// FlutterReactiveBle stream of DiscoveredDevice into Ergometer objects. + /// + /// - non-PM5 devices are already filtered-out by FlutterReactiveBle + /// - during subscribing we return a fake status data + + /// declare ErgBleManager with a mocked Reactive Ble + final mockReactive = MockFlutterReactiveBle(); + final ble = ErgBleManager.withDependency(bleClient: mockReactive); + + /// create a fake stream of Discovered devices matching C2_ROWING_BASE_UUID service + final fakePM_1 = DiscoveredDevice( + id: 'xxxx', + name: 'PM5_1', + serviceUuids: [Uuid.parse(Identifiers.C2_ROWING_BASE_UUID)], + serviceData: {}, + manufacturerData: Uint8List.fromList([1, 0, 0]), + rssi: 10); + final fakePM_2 = DiscoveredDevice( + id: 'yyyy', + name: 'PM5_2', + serviceUuids: [Uuid.parse(Identifiers.C2_ROWING_BASE_UUID)], + serviceData: {}, + manufacturerData: Uint8List.fromList([2, 0, 0]), + rssi: 10); + final fakeScan = Stream.fromIterable([fakePM_1, fakePM_2]); + + /// Adding mock answer from the [FlutterReactiveBle] + when(() => mockReactive.scanForDevices( + withServices: any( + named: "withServices", + that: predicate>((services) => services + .contains(Uuid.parse(Identifiers.C2_ROWING_BASE_UUID)))))) + .thenAnswer((_) => fakeScan); - test('does not recognize non-concept2 devices', () { - // final bytes = Uint8List.fromList([0, 0, 0, 128]); - // expect(CsafeIntExtension.fromBytes(bytes), 128); - // expect(CsafeIntExtension.fromBytes(bytes, Endian.little), 2147483648); + /// Ensure DiscoveredDevice events are translated as Ergometer events + /// we expect only them in matching order + expect( + ble.startErgScan(), + emitsInOrder([ + predicate((e) => e.name == fakePM_1.name), + predicate((e) => e.name == fakePM_2.name), + emitsDone, + ])); }); } From 234e4cb3d4809dd5b1148da91a2bfdbd7e5709d2 Mon Sep 17 00:00:00 2001 From: Bertrand Esperou Date: Wed, 30 Apr 2025 19:20:38 +0000 Subject: [PATCH 07/10] fixing mock for statusStream --- test/ergblemanager_test.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/ergblemanager_test.dart b/test/ergblemanager_test.dart index ac2b433..abe4d95 100644 --- a/test/ergblemanager_test.dart +++ b/test/ergblemanager_test.dart @@ -45,6 +45,8 @@ void main() { that: predicate>((services) => services .contains(Uuid.parse(Identifiers.C2_ROWING_BASE_UUID)))))) .thenAnswer((_) => fakeScan); + when(() => mockReactive.statusStream) + .thenAnswer((_) => Stream.value(BleStatus.ready)); /// Ensure DiscoveredDevice events are translated as Ergometer events /// we expect only them in matching order From c8a7f3786b407f343b10f867c2ad408a7d4f3caa Mon Sep 17 00:00:00 2001 From: Bertrand Esperou Date: Wed, 30 Apr 2025 19:39:15 +0000 Subject: [PATCH 08/10] removing typo --- README.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8e7c583..3e022cd 100644 --- a/README.md +++ b/README.md @@ -79,13 +79,12 @@ Once you have the `Ergometer` instance for the erg you want to connect to, you c ```dart StreamSubscription ergConnectionStream = myErg.connectAndDiscover().listen((event) { - if(event == ErgometerConnectionState.connected) { - //do stuff here once the erg is connected - } else if (event == ErgometerConnectionState.disconnected) { - //handle disconnection here - } - }); -} + if(event == ErgometerConnectionState.connected) { + //do stuff here once the erg is connected + } else if (event == ErgometerConnectionState.disconnected) { + //handle disconnection here + } +}); ``` When you are done, disconnect from your erg by cancelling the stream: From b4fcb50f6d26c9b07e08fd1df200198261e5283c Mon Sep 17 00:00:00 2001 From: bertrand esperou Date: Wed, 28 May 2025 23:47:59 +0200 Subject: [PATCH 09/10] save connection stream so we can dispose of it when disconnecting --- lib/models/ergometer.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/models/ergometer.dart b/lib/models/ergometer.dart index 88f2d09..8a86320 100644 --- a/lib/models/ergometer.dart +++ b/lib/models/ergometer.dart @@ -15,6 +15,7 @@ enum ErgometerConnectionState { connecting, connected, disconnected } class Ergometer { final FlutterReactiveBle _flutterReactiveBle; DiscoveredDevice _peripheral; + Stream? _connection; Csafe? _csafeClient; /// Get the name of this erg. i.e. "PM5" + serial number @@ -37,7 +38,7 @@ class Ergometer { //this may cause problems if the device goes out of range between scenning and trying to connect. maybe use connectToAdvertisingDevice instead to mitigate this and prevent a hang on android //if no services are specified in the `servicesWithCharacteristicsToDiscover` parameter, then full service discovery will be performed - _flutterReactiveBle.connectToDevice(id: _peripheral.id); + _connection = _flutterReactiveBle.connectToDevice(id: _peripheral.id); return getMonitorConnectionState; } @@ -92,8 +93,7 @@ class Ergometer { /// Expose a stream of events to enable monitoring the erg's connection state /// This acts as a wrapper around the state provided by the internal bluetooth library to aid with swapping it out later. Stream get getMonitorConnectionState => - _flutterReactiveBle.connectedDeviceStream - .asyncMap((connectionStateUpdate) { + _connection!.asyncMap((connectionStateUpdate) { switch (connectionStateUpdate.connectionState) { case DeviceConnectionState.connecting: return ErgometerConnectionState.connecting; From ceb9c94399d46fe10fe28ef1e4af5ae8097a8f31 Mon Sep 17 00:00:00 2001 From: bertrand esperou Date: Wed, 28 May 2025 23:58:33 +0200 Subject: [PATCH 10/10] ergometer reactive mock test using streamcontrollers --- test/ergometer_test.dart | 133 +++++++++++++++++++++------------------ 1 file changed, 71 insertions(+), 62 deletions(-) diff --git a/test/ergometer_test.dart b/test/ergometer_test.dart index 451ac11..97382f5 100644 --- a/test/ergometer_test.dart +++ b/test/ergometer_test.dart @@ -1,41 +1,68 @@ -import 'package:c2bluetooth/models/ergometer.dart'; -import 'package:flutter/services.dart'; +import 'dart:async'; + +import 'package:c2bluetooth/c2bluetooth.dart'; +import 'package:flutter/foundation.dart'; +import 'package:c2bluetooth/constants.dart' as Identifiers; import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; class MockFlutterReactiveBle extends Mock implements FlutterReactiveBle {} +class FakeQualifiedCharacteristic extends Fake + implements QualifiedCharacteristic {} + +late MockFlutterReactiveBle mockBle; +late StreamController> characteristicController; +late StreamController deviceConnectionController; +late DiscoveredDevice device = DiscoveredDevice( + id: 'deviceId', + name: 'deviceName', + serviceData: {}, + manufacturerData: Uint8List.fromList([1, 1, 1, 1]), + rssi: 90, + serviceUuids: []); void main() { group('Bluetooth tests', () { - setUp(() { - registerFallbackValue(Stream.value(ConnectionStateUpdate( - deviceId: 'fallback', - connectionState: DeviceConnectionState.disconnected, - failure: null, - ))); - registerFallbackValue(DiscoveredDevice( - id: 'deviceId', - name: 'deviceName', - serviceData: {}, - manufacturerData: Uint8List.fromList([1, 1, 1, 1]), - rssi: 90, - serviceUuids: [])); + setUpAll(() { + // Fallback values registerFallbackValue(QualifiedCharacteristic( characteristicId: Uuid.parse('c5cc5bf5-2bd1-4d1a-939a-5e15fb9b81a1'), serviceId: Uuid.parse('c5cc5bf5-2bd1-4d1a-939a-5e15fb9b81a2'), - deviceId: 'deviceId')); + deviceId: device.id)); + }); + setUp(() { + mockBle = MockFlutterReactiveBle(); + // Mock ReactiveBle methods using streamcontrollers + characteristicController = + StreamController>.broadcast(sync: true); + deviceConnectionController = + StreamController.broadcast(sync: true); + when( + () => mockBle.connectToDevice( + id: any(named: 'id'), + connectionTimeout: any(named: 'connectionTimeout'), + ), + ).thenAnswer((c) { + debugPrint("connectToDevice(${c.namedArguments})"); + return deviceConnectionController.stream; + }); + when(() => mockBle.connectedDeviceStream) + .thenAnswer((_) => deviceConnectionController.stream); + when(() => + mockBle.subscribeToCharacteristic(any())) + .thenAnswer((q) { + debugPrint("subscribeToCharacteristic(${q.positionalArguments})"); + return characteristicController.stream; + }); + }); + tearDownAll(() { + deviceConnectionController.close(); + characteristicController.close(); }); test('Ensure DeviceConnectionState to ErgometerConnectionState translation', () { - DiscoveredDevice device = DiscoveredDevice( - id: 'deviceId', - name: 'deviceName', - serviceData: {}, - manufacturerData: Uint8List.fromList([1, 1, 1, 1]), - rssi: 90, - serviceUuids: []); final fakeConnectionUpdates = Stream.fromIterable([ ConnectionStateUpdate( deviceId: 'deviceId', @@ -56,10 +83,10 @@ void main() { connectionState: DeviceConnectionState.disconnected, failure: null) ]); - final mockBle = MockFlutterReactiveBle(); final erg = Ergometer(device, bleClient: mockBle); - when(() => mockBle.connectedDeviceStream) - .thenAnswer((_) => fakeConnectionUpdates); + fakeConnectionUpdates.forEach(deviceConnectionController.add); + StreamSubscription _connection = + erg.connectAndDiscover().listen((_) {}); expect( erg.getMonitorConnectionState, emitsInOrder([ @@ -68,23 +95,14 @@ void main() { ErgometerConnectionState.disconnected, ErgometerConnectionState.disconnected ])); - verifyNever(() => mockBle.connectToDevice( - id: 'deviceId', - connectionTimeout: any(named: 'connectionTimeout'), - )); + _connection.cancel(); }); test('Retrieve ErgometerConnectionState status during connection', () { - // dummy discovered device - DiscoveredDevice device = DiscoveredDevice( - id: 'deviceId', - name: 'deviceName', - serviceData: {}, - manufacturerData: Uint8List.fromList([1, 1, 1, 1]), - rssi: 90, - serviceUuids: []); final fakeSubscriptionChar = Stream>.fromIterable([ - [1, 1, 1], - [2, 2, 2] + // Each StatusData1 is 18 bytes + [0x31, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // 0 m + [0x31, 0, 0, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // 1 m + [0x31, 0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], // 2 m ]); final fakeConnectionUpdates = Stream.fromIterable([ ConnectionStateUpdate( @@ -98,38 +116,29 @@ void main() { failure: null, ) ]); - final mockBle = MockFlutterReactiveBle(); final erg = Ergometer(device, bleClient: mockBle); - when(() => mockBle.connectedDeviceStream) - .thenAnswer((_) => fakeConnectionUpdates); - when( - () => mockBle.connectToDevice( - id: any(named: 'id'), - connectionTimeout: any(named: 'connectionTimeout'), - ), - ).thenAnswer((_) => fakeConnectionUpdates); - when(() => mockBle.subscribeToCharacteristic(any())) - .thenAnswer((_) => fakeSubscriptionChar); + fakeConnectionUpdates.forEach(deviceConnectionController.add); + fakeSubscriptionChar.forEach(characteristicController.add); expect( erg.connectAndDiscover(), emitsInOrder([ ErgometerConnectionState.connecting, ErgometerConnectionState.connected ])); + // Connection should happen once verify(() => mockBle.connectToDevice( - id: 'deviceId', + id: device.id, connectionTimeout: any(named: 'connectionTimeout'), )).called(1); + // Subscribed only to the initial subscriptions: + // - Identifiers.C2_ROWING_CONTROL_SERVICE_UUID + verify(() => mockBle.subscribeToCharacteristic(any( + that: isA().having( + (e) => e.characteristicId, + 'characteristicId', + Uuid.parse( + Identifiers.C2_ROWING_PM_TRANSMIT_CHARACTERISTIC_UUID))))) + .called(1); }); }); - test('instantiate from a peripheral', () { - // final bytes = Uint8List.fromList([0, 0, 0, 128]); - // expect(CsafeIntExtension.fromBytes(bytes), 128); - // expect(CsafeIntExtension.fromBytes(bytes, Endian.little), 2147483648); - }); - test('can provide WorkoutSummary data', () { - // final bytes = Uint8List.fromList([0, 0, 0, 128]); - // expect(CsafeIntExtension.fromBytes(bytes), 128); - // expect(CsafeIntExtension.fromBytes(bytes, Endian.little), 2147483648); - }); }