diff --git a/lib/src/managers/ble_gatt_manager.dart b/lib/src/managers/ble_gatt_manager.dart index d424619..7340e2f 100644 --- a/lib/src/managers/ble_gatt_manager.dart +++ b/lib/src/managers/ble_gatt_manager.dart @@ -30,6 +30,19 @@ abstract class BleGattManager { required String characteristicId, }); + /// Ensures notifications are enabled before request/response flows depend on them. + Future prepareSubscription({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + subscribe( + deviceId: deviceId, + serviceId: serviceId, + characteristicId: characteristicId, + ); + } + /// Reads data from a specific characteristic of the connected device. Future> read({ required String deviceId, diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index ef71e7e..6ec17bb 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -15,6 +15,7 @@ class BleManager extends BleGattManager { int get mtu => _mtu; final Map>> _streamControllers = {}; + final Map> _subscriptionSetups = {}; /// A stream of discovered devices during scanning. StreamController? _scanStreamController; @@ -24,6 +25,35 @@ class BleManager extends BleGattManager { String _getCharacteristicKey(String deviceId, String characteristicId) => "$deviceId||$characteristicId"; + StreamController> _ensureStreamController(String streamIdentifier) { + return _streamControllers.putIfAbsent( + streamIdentifier, + StreamController>.broadcast, + ); + } + + Future _setupSubscription({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) { + final streamIdentifier = _getCharacteristicKey(deviceId, characteristicId); + _ensureStreamController(streamIdentifier); + + return _subscriptionSetups.putIfAbsent(streamIdentifier, () async { + try { + await UniversalBle.subscribeNotifications( + deviceId, + serviceId, + characteristicId, + ); + } catch (error) { + _subscriptionSetups.remove(streamIdentifier); + rethrow; + } + }); + } + final Map _connectionCompleters = {}; final Map _connectCallbacks = {}; final Map _disconnectCallbacks = {}; @@ -48,6 +78,7 @@ class BleManager extends BleGattManager { for (final key in keys) { logger.d("Closing stream for $key due to device disconnection"); + _subscriptionSetups.remove(key); _streamControllers.remove(key)?.close(); } } @@ -324,33 +355,47 @@ class BleManager extends BleGattManager { deviceId, characteristicId, ); - StreamController>? streamController = - _streamControllers[streamIdentifier]; - streamController ??= StreamController>.broadcast(); - if (!_streamControllers.containsKey(streamIdentifier)) { - UniversalBle.subscribeNotifications( - deviceId, - serviceId, - characteristicId, + final streamController = _ensureStreamController(streamIdentifier); + if (!_subscriptionSetups.containsKey(streamIdentifier)) { + unawaited( + _setupSubscription( + deviceId: deviceId, + serviceId: serviceId, + characteristicId: characteristicId, + ).catchError((Object error, StackTrace stackTrace) { + logger.e( + "Failed to subscribe to $streamIdentifier: $error\n$stackTrace", + ); + }), ); - _streamControllers[streamIdentifier] = streamController; } streamController.onCancel = () { if (_streamControllers.containsKey(streamIdentifier)) { + _subscriptionSetups.remove(streamIdentifier); _streamControllers.remove(streamIdentifier)?.close(); - UniversalBle.unsubscribe( - deviceId, - serviceId, - characteristicId, + unawaited( + UniversalBle.unsubscribe(deviceId, serviceId, characteristicId), ); - _streamControllers.remove(streamIdentifier); } }; return streamController.stream; } + @override + Future prepareSubscription({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) { + return _setupSubscription( + deviceId: deviceId, + serviceId: serviceId, + characteristicId: characteristicId, + ); + } + /// Reads data from a specific characteristic of the connected Earable device. @override Future> read({ diff --git a/lib/src/utils/sensor_scheme_parser/v2_sensor_scheme_reader.dart b/lib/src/utils/sensor_scheme_parser/v2_sensor_scheme_reader.dart index 07b03df..de6e262 100644 --- a/lib/src/utils/sensor_scheme_parser/v2_sensor_scheme_reader.dart +++ b/lib/src/utils/sensor_scheme_parser/v2_sensor_scheme_reader.dart @@ -59,7 +59,14 @@ class V2SensorSchemeReader extends SensorSchemeReader { return _sensorSchemes[sensorId]!; } - // Listen to the notification of the characteristic + await _bleManager.prepareSubscription( + deviceId: _deviceId, + serviceId: parseInfoServiceUuid, + characteristicId: sensorSchemeCharacteristicUuid, + ); + + // Listen only after notifications are enabled, otherwise some Android + // stacks can drop the first response packet. final Stream> stream = _bleManager.subscribe( deviceId: _deviceId, serviceId: parseInfoServiceUuid, @@ -83,22 +90,26 @@ class V2SensorSchemeReader extends SensorSchemeReader { "Received notification for sensor scheme of sensor $sensorId: $value", ); - final scheme = _parseSensorScheme(value); - if (scheme.sensorId == 0 && sensorId != 0) { - logger.w( - "Sensor scheme response for sensor $sensorId omitted the sensor id. Using the requested id.", - ); - scheme.sensorId = sensorId; - } else if (scheme.sensorId != sensorId) { - logger.w( - "Sensor scheme response for sensor $sensorId reported sensor id ${scheme.sensorId}. Using the returned scheme.", - ); - } - - _sensorSchemes[scheme.sensorId] = scheme; - return scheme; + return _storeParsedScheme( + requestedSensorId: sensorId, + rawScheme: value, + source: 'notification', + ); } on TimeoutException catch (e) { - throw TimeoutException("Timeout while waiting for sensor scheme: $e"); + logger.w( + "Notification timeout while waiting for sensor scheme $sensorId: $e. " + "Falling back to direct characteristic read.", + ); + final polledValue = await _bleManager.read( + deviceId: _deviceId, + serviceId: parseInfoServiceUuid, + characteristicId: sensorSchemeCharacteristicUuid, + ); + return _storeParsedScheme( + requestedSensorId: sensorId, + rawScheme: polledValue, + source: 'direct read', + ); } } @@ -107,11 +118,23 @@ class V2SensorSchemeReader extends SensorSchemeReader { if (_sensorIds.isEmpty || forceRead) { await _readSensorIds(); } + if (forceRead) { + _sensorSchemes.clear(); + } - for (int sensorId in _sensorIds) { - if (!_sensorSchemes.containsKey(sensorId) || forceRead) { + final int maxPasses = _sensorIds.length; + for (int pass = 0; pass < maxPasses; pass++) { + final missingSensorIds = _sensorIds + .where((sensorId) => !_sensorSchemes.containsKey(sensorId)) + .toList(); + if (missingSensorIds.isEmpty) { + break; + } + + final int schemesBeforePass = _sensorSchemes.length; + for (final sensorId in missingSensorIds) { try { - SensorScheme scheme = await getSchemeForSensor(sensorId); + final scheme = await getSchemeForSensor(sensorId); _sensorSchemes[scheme.sensorId] = scheme; } catch (e) { logger.e( @@ -123,10 +146,16 @@ class V2SensorSchemeReader extends SensorSchemeReader { "This may be a BLE notification timeout or subscription issue.", ); } - // Continue with next sensor instead of failing entirely - continue; } } + + if (_sensorSchemes.length == schemesBeforePass) { + logger.w( + "Sensor scheme read made no progress on pass ${pass + 1}. " + "Stopping with ${_sensorSchemes.length}/${_sensorIds.length} scheme(s).", + ); + break; + } } logger.d( @@ -142,23 +171,31 @@ class V2SensorSchemeReader extends SensorSchemeReader { int nameLength = byteStream[currentIndex++]; - List nameBytes = - byteStream.sublist(currentIndex, currentIndex + nameLength); + List nameBytes = byteStream.sublist( + currentIndex, + currentIndex + nameLength, + ); String sensorName = utf8.decode(nameBytes); currentIndex += nameLength; int componentCount = byteStream[currentIndex++]; - SensorScheme sensorScheme = - SensorScheme(sensorId, sensorName, componentCount, null); + SensorScheme sensorScheme = SensorScheme( + sensorId, + sensorName, + componentCount, + null, + ); for (int j = 0; j < componentCount; j++) { int componentType = byteStream[currentIndex++]; int groupNameLength = byteStream[currentIndex++]; - List groupNameBytes = - byteStream.sublist(currentIndex, currentIndex + groupNameLength); + List groupNameBytes = byteStream.sublist( + currentIndex, + currentIndex + groupNameLength, + ); String groupName = utf8.decode(groupNameBytes); currentIndex += groupNameLength; @@ -173,8 +210,10 @@ class V2SensorSchemeReader extends SensorSchemeReader { int unitNameLength = byteStream[currentIndex++]; - List unitNameBytes = - byteStream.sublist(currentIndex, currentIndex + unitNameLength); + List unitNameBytes = byteStream.sublist( + currentIndex, + currentIndex + unitNameLength, + ); String unitName = utf8.decode(unitNameBytes); currentIndex += unitNameLength; @@ -223,4 +262,30 @@ class V2SensorSchemeReader extends SensorSchemeReader { return sensorScheme; } + + SensorScheme _storeParsedScheme({ + required int requestedSensorId, + required List rawScheme, + required String source, + }) { + logger.d( + "Received $source for sensor scheme of sensor $requestedSensorId: $rawScheme", + ); + + final scheme = _parseSensorScheme(rawScheme); + final bool canAssumeMissingId = source == 'notification'; + if (canAssumeMissingId && scheme.sensorId == 0 && requestedSensorId != 0) { + logger.w( + "Sensor scheme response for sensor $requestedSensorId omitted the sensor id. Using the requested id.", + ); + scheme.sensorId = requestedSensorId; + } else if (scheme.sensorId != requestedSensorId) { + logger.w( + "Sensor scheme response for sensor $requestedSensorId reported sensor id ${scheme.sensorId}. Using the returned scheme.", + ); + } + + _sensorSchemes[scheme.sensorId] = scheme; + return scheme; + } } diff --git a/test/v2_sensor_scheme_reader_test.dart b/test/v2_sensor_scheme_reader_test.dart new file mode 100644 index 0000000..9ff27dc --- /dev/null +++ b/test/v2_sensor_scheme_reader_test.dart @@ -0,0 +1,212 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/src/constants.dart'; +import 'package:open_earable_flutter/src/managers/ble_gatt_manager.dart'; +import 'package:open_earable_flutter/src/utils/sensor_scheme_parser/v2_sensor_scheme_reader.dart'; + +void main() { + test( + 'waits for notification subscription before requesting a v2 scheme', + () async { + final bleManager = _FakeBleGattManager(); + final reader = V2SensorSchemeReader(bleManager, 'device-1'); + + final schemes = await reader.readSensorSchemes(); + + expect(schemes, hasLength(1)); + expect(schemes.single.sensorId, 7); + expect(schemes.single.sensorName, 'IMU'); + expect( + bleManager.events, + containsAllInOrder([ + 'read:sensor-list', + 'prepare:scheme', + 'subscribe:scheme', + 'write:7', + ]), + ); + }, + ); + + test( + 'falls back to direct read when the notification path times out', + () async { + final bleManager = _FakeBleGattManager(deliverNotification: false); + final reader = V2SensorSchemeReader(bleManager, 'device-1'); + + final scheme = await reader.getSchemeForSensor(7); + + expect(scheme.sensorId, 7); + expect(scheme.sensorName, 'IMU'); + expect( + bleManager.events, + containsAllInOrder([ + 'read:sensor-list', + 'prepare:scheme', + 'subscribe:scheme', + 'write:7', + 'read:scheme', + ]), + ); + }, + ); + + test( + 'keeps the returned sensor id on direct-read fallback', + () async { + final bleManager = _FakeBleGattManager( + deliverNotification: false, + sensorIds: const [0, 5], + directReadResponses: >[ + _schemeBytes(sensorId: 0, name: 'IMU'), + ], + ); + final reader = V2SensorSchemeReader(bleManager, 'device-1'); + + final scheme = await reader.getSchemeForSensor(5); + + expect(scheme.sensorId, 0); + expect(scheme.sensorName, 'IMU'); + expect(bleManager.events, containsAllInOrder(['write:5', 'read:scheme'])); + }, + ); + + test( + 'retries passes until all schemes are collected', + () async { + final bleManager = _FakeBleGattManager( + deliverNotification: false, + sensorIds: const [0, 4, 5], + directReadResponses: >[ + _schemeBytes(sensorId: 0, name: 'IMU'), + _schemeBytes(sensorId: 0, name: 'IMU'), + _schemeBytes(sensorId: 4, name: 'PPG'), + _schemeBytes(sensorId: 5, name: 'BARO'), + ], + ); + final reader = V2SensorSchemeReader(bleManager, 'device-1'); + + final schemes = await reader.readSensorSchemes(); + + expect(schemes.map((scheme) => scheme.sensorId).toSet(), {0, 4, 5}); + expect( + bleManager.events.where((event) => event.startsWith('write:')).length, + greaterThan(3), + ); + }, + ); +} + +class _FakeBleGattManager extends BleGattManager { + _FakeBleGattManager({ + this.deliverNotification = true, + this.sensorIds = const [7], + List>? directReadResponses, + }) : _directReadResponses = + directReadResponses ?? >[_imuSchemeBytes]; + + final List events = []; + final StreamController> _schemeController = + StreamController>.broadcast(); + final bool deliverNotification; + final List sensorIds; + final List> _directReadResponses; + int _directReadIndex = 0; + int? lastRequestedSensorId; + + bool _prepared = false; + + @override + bool isConnected(String deviceId) => true; + + @override + Future hasService({ + required String deviceId, + required String serviceId, + }) async { + return true; + } + + @override + Future hasCharacteristic({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + return true; + } + + @override + Future prepareSubscription({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + expect(serviceId, parseInfoServiceUuid); + expect(characteristicId, sensorSchemeCharacteristicUuid); + events.add('prepare:scheme'); + _prepared = true; + } + + @override + Stream> subscribe({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) { + expect(serviceId, parseInfoServiceUuid); + expect(characteristicId, sensorSchemeCharacteristicUuid); + events.add('subscribe:scheme'); + return _schemeController.stream; + } + + @override + Future> read({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + expect(serviceId, parseInfoServiceUuid); + if (characteristicId == sensorListCharacteristicUuid) { + events.add('read:sensor-list'); + return [sensorIds.length, ...sensorIds]; + } + expect(characteristicId, sensorSchemeCharacteristicUuid); + events.add('read:scheme'); + final index = _directReadIndex < _directReadResponses.length + ? _directReadIndex++ + : _directReadResponses.length - 1; + return _directReadResponses[index]; + } + + @override + Future write({ + required String deviceId, + required String serviceId, + required String characteristicId, + required List byteData, + }) async { + expect(_prepared, isTrue); + expect(serviceId, parseInfoServiceUuid); + expect(characteristicId, requestSensorSchemeCharacteristicUuid); + expect(byteData, hasLength(1)); + lastRequestedSensorId = byteData.single; + events.add('write:${byteData.single}'); + if (deliverNotification) { + Future.microtask(() => _schemeController.add(_imuSchemeBytes)); + } + } + + @override + Future disconnect(String deviceId) async {} +} + +final List _imuSchemeBytes = [7, 3, ...'IMU'.codeUnits, 0, 0]; + +List _schemeBytes({ + required int sensorId, + required String name, +}) { + return [sensorId, name.length, ...name.codeUnits, 0, 0]; +}