From 72dd491a981ac2f93713830d1caf28dc581cc6a3 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:28:24 +0200 Subject: [PATCH 01/15] chore: update version to 2.5.0 in pubspec.yaml --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index b81f405..4773379 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: open_earable_flutter description: This package provides functionality for interacting with OpenEarable devices. Control LED colors, control audio, and access raw sensor data. -version: 2.4.1 +version: 2.5.0 repository: https://github.com/OpenEarable/open_earable_flutter/tree/main platforms: From 26fe27bad26f9602e71b35c3760e5e8e0dfe9a28 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sat, 3 Oct 2026 18:19:47 +0200 Subject: [PATCH 02/15] fix(battery): decode GATT status fields in little endian --- .../battery_health_status_gatt_reader.dart | 2 +- ...tery_level_status_service_gatt_reader.dart | 40 +++++---- test/battery_gatt_reader_test.dart | 83 +++++++++++++++++++ 3 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 test/battery_gatt_reader_test.dart diff --git a/lib/src/models/devices/battery_gatt_reader/battery_health_status_gatt_reader.dart b/lib/src/models/devices/battery_gatt_reader/battery_health_status_gatt_reader.dart index 83ac442..928ee49 100644 --- a/lib/src/models/devices/battery_gatt_reader/battery_health_status_gatt_reader.dart +++ b/lib/src/models/devices/battery_gatt_reader/battery_health_status_gatt_reader.dart @@ -26,7 +26,7 @@ mixin BatteryHealthStatusGattReader on BluetoothWearable } int healthSummary = healthStatusList[1]; - int cycleCount = (healthStatusList[2] << 8) | healthStatusList[3]; + int cycleCount = healthStatusList[2] | (healthStatusList[3] << 8); int currentTemperature = healthStatusList[4]; BatteryHealthStatus batteryHealthStatus = BatteryHealthStatus( diff --git a/lib/src/models/devices/battery_gatt_reader/battery_level_status_service_gatt_reader.dart b/lib/src/models/devices/battery_gatt_reader/battery_level_status_service_gatt_reader.dart index 0e31fa1..bf19486 100644 --- a/lib/src/models/devices/battery_gatt_reader/battery_level_status_service_gatt_reader.dart +++ b/lib/src/models/devices/battery_gatt_reader/battery_level_status_service_gatt_reader.dart @@ -17,41 +17,53 @@ mixin BatteryLevelStatusServiceGattReader on BluetoothWearable characteristicId: _batteryLevelStatusCharacteristicUuid, ); - int powerState = (powerStateList[1] << 8) | powerStateList[2]; + if (powerStateList.length < 3) { + throw StateError( + 'Battery power status requires flags and two state bytes',); + } + int powerState = powerStateList[1] | (powerStateList[2] << 8); logger.d("Battery power status bits: ${powerState.toRadixString(2)}"); - bool batteryPresent = powerState >> 15 & 0x1 != 0; + bool batteryPresent = powerState & 0x1 != 0; - int wiredExternalPowerSourceConnectedRaw = (powerState >> 13) & 0x3; + int wiredExternalPowerSourceConnectedRaw = (powerState >> 1) & 0x3; ExternalPowerSourceConnected wiredExternalPowerSourceConnected = - ExternalPowerSourceConnected - .values[wiredExternalPowerSourceConnectedRaw]; + wiredExternalPowerSourceConnectedRaw < + ExternalPowerSourceConnected.values.length + ? ExternalPowerSourceConnected + .values[wiredExternalPowerSourceConnectedRaw] + : ExternalPowerSourceConnected.unknown; - int wirelessExternalPowerSourceConnectedRaw = (powerState >> 11) & 0x3; + int wirelessExternalPowerSourceConnectedRaw = (powerState >> 3) & 0x3; ExternalPowerSourceConnected wirelessExternalPowerSourceConnected = - ExternalPowerSourceConnected - .values[wirelessExternalPowerSourceConnectedRaw]; + wirelessExternalPowerSourceConnectedRaw < + ExternalPowerSourceConnected.values.length + ? ExternalPowerSourceConnected + .values[wirelessExternalPowerSourceConnectedRaw] + : ExternalPowerSourceConnected.unknown; - int chargeStateRaw = (powerState >> 9) & 0x3; + int chargeStateRaw = (powerState >> 5) & 0x3; ChargeState chargeState = ChargeState.values[chargeStateRaw]; int chargeLevelRaw = (powerState >> 7) & 0x3; BatteryChargeLevel chargeLevel = BatteryChargeLevel.values[chargeLevelRaw]; - int chargingTypeRaw = (powerState >> 5) & 0x7; + int chargingTypeRaw = (powerState >> 9) & 0x7; BatteryChargingType chargingType = - BatteryChargingType.values[chargingTypeRaw]; + chargingTypeRaw < BatteryChargingType.values.length + ? BatteryChargingType.values[chargingTypeRaw] + : BatteryChargingType.unknown; - int chargingFaultReasonRaw = (powerState >> 2) & 0x5; + int chargingFaultReasonRaw = (powerState >> 12) & 0x7; List chargingFaultReason = []; if ((chargingFaultReasonRaw & 0x1) != 0) { - chargingFaultReason.add(ChargingFaultReason.other); + chargingFaultReason.add(ChargingFaultReason.battery); } if ((chargingFaultReasonRaw & 0x2) != 0) { chargingFaultReason.add(ChargingFaultReason.externalPowerSource); } if ((chargingFaultReasonRaw & 0x4) != 0) { - chargingFaultReason.add(ChargingFaultReason.battery); + chargingFaultReason.add(ChargingFaultReason.other); } BatteryPowerStatus batteryPowerStatus = BatteryPowerStatus( diff --git a/test/battery_gatt_reader_test.dart b/test/battery_gatt_reader_test.dart new file mode 100644 index 0000000..bfb4717 --- /dev/null +++ b/test/battery_gatt_reader_test.dart @@ -0,0 +1,83 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_earable_flutter/src/models/devices/battery_gatt_reader/battery_health_status_gatt_reader.dart'; +import 'package:open_earable_flutter/src/models/devices/battery_gatt_reader/battery_level_status_service_gatt_reader.dart'; +import 'package:open_earable_flutter/src/models/devices/bluetooth_wearable.dart'; + +void main() { + test('decodes captured charged-ear payload and cycle count', () async { + final reader = _Reader(_Gatt([0, 0xe3, 0])); + final status = await reader.readPowerStatus(); + expect(status.batteryPresent, isTrue); + expect(status.wiredExternalPowerSourceConnected, + ExternalPowerSourceConnected.yes,); + expect(status.wirelessExternalPowerSourceConnected, + ExternalPowerSourceConnected.no,); + expect(status.chargeState, ChargeState.dischargingInactive); + expect(status.chargeLevel, BatteryChargeLevel.good); + expect(status.chargingType, BatteryChargingType.unknown); + expect(status.chargingFaultReason, isEmpty); + final health = await _Reader(_Gatt([7, 100, 1, 0, 29])).readHealthStatus(); + expect(health.cycleCount, 1); + }); + + test('decodes independent power-state fields and all fault bits', () async { + final status = await _Reader(_Gatt([0, 0x55, 0x79])).readPowerStatus(); + expect(status.batteryPresent, isTrue); + expect(status.wiredExternalPowerSourceConnected, + ExternalPowerSourceConnected.unknown,); + expect(status.wirelessExternalPowerSourceConnected, + ExternalPowerSourceConnected.unknown,); + expect(status.chargeState, ChargeState.dischargingActive); + expect(status.chargingType, BatteryChargingType.float); + expect(status.chargingFaultReason, ChargingFaultReason.values); + }); + + test('reserved enum values are unknown, not out-of-range accesses', () async { + final status = await _Reader(_Gatt([0, 0x1e, 0x0e])).readPowerStatus(); + expect(status.wiredExternalPowerSourceConnected, + ExternalPowerSourceConnected.unknown,); + expect(status.wirelessExternalPowerSourceConnected, + ExternalPowerSourceConnected.unknown,); + expect(status.chargingType, BatteryChargingType.unknown); + }); + + test('rejects truncated power status', () async { + await expectLater( + _Reader(_Gatt([0, 0xe3])).readPowerStatus(), throwsStateError,); + }); +} + +class _Gatt implements BleGattManager { + _Gatt(this.bytes); + final List bytes; + @override + Future> read( + {required String deviceId, + required String serviceId, + required String characteristicId,}) async => + bytes; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Reader extends BluetoothWearable + with BatteryLevelStatusServiceGattReader, BatteryHealthStatusGattReader { + _Reader(BleGattManager manager) + : super( + name: 'ear', + bleManager: manager, + disconnectNotifier: WearableDisconnectNotifier(), + discoveredDevice: DiscoveredDevice( + id: 'ear', + name: 'ear', + manufacturerData: Uint8List(0), + rssi: -40, + serviceUuids: [],),); + @override + String get deviceId => 'ear'; + @override + Future disconnect() async {} +} From f2a1670a0b9e89c34ec749d448697f089197903b Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sat, 3 Oct 2026 20:02:29 +0200 Subject: [PATCH 03/15] fix(fota): finish failed preparation and early aborts --- lib/src/fota/bloc/update_bloc.dart | 49 ++++++++--- .../handlers/firmware_update_handler.dart | 73 ++++++++-------- test/fota_preparation_failure_test.dart | 86 +++++++++++++++++++ test/fota_unpacker_test.dart | 83 ++++++++++++++++++ 4 files changed, 242 insertions(+), 49 deletions(-) create mode 100644 test/fota_preparation_failure_test.dart create mode 100644 test/fota_unpacker_test.dart diff --git a/lib/src/fota/bloc/update_bloc.dart b/lib/src/fota/bloc/update_bloc.dart index 3bf94c5..496d190 100644 --- a/lib/src/fota/bloc/update_bloc.dart +++ b/lib/src/fota/bloc/update_bloc.dart @@ -32,10 +32,28 @@ class UpdateBloc extends Bloc { _abortRequested = false; await _cancelSubscriptions(); - _firmwareUpdateManager = await handler.handleFirmwareUpdate( - firmwareUpdateRequest, - (FirmwareUpdateState state) => add(_StateConverter.convert(state)), - ); + try { + _firmwareUpdateManager = await handler.handleFirmwareUpdate( + firmwareUpdateRequest, + (FirmwareUpdateState state) { + if (_abortRequested || isClosed) { + throw StateError('Firmware update was aborted'); + } + add(_StateConverter.convert(state)); + }, + ); + } catch (error) { + if (!_abortRequested && !isClosed) { + add(UploadFailed(error.toString())); + } + return; + } + if (_abortRequested || isClosed) { + await _firmwareUpdateManager?.cancel(); + await _firmwareUpdateManager?.kill(); + _firmwareUpdateManager = null; + return; + } final progressStream = _firmwareUpdateManager!.progressStream .map( @@ -104,14 +122,17 @@ class UpdateBloc extends Bloc { ); }); on((event, emit) { + if (_abortRequested || (_state?.isComplete ?? false)) return; _state = _updatedState(UpdateFirmware('Download firmware')); emit(_state!); }); on((event, emit) { + if (_abortRequested || (_state?.isComplete ?? false)) return; _state = _updatedState(UpdateFirmware('Unpack firmware')); emit(_state!); }); on((event, emit) { + if (_abortRequested || (_state?.isComplete ?? false)) return; if (event is UploadProgress) { _state = _updatedState( UpdateProgressFirmware( @@ -127,6 +148,7 @@ class UpdateBloc extends Bloc { } }); on((event, emit) { + if (_abortRequested || (_state?.isComplete ?? false)) return; _state = _updatedState( UpdateCompleteSuccess(), updateManager: _firmwareUpdateManager, @@ -165,6 +187,16 @@ class UpdateBloc extends Bloc { UpdateFirmware currentState, { FirmwareUpdateManager? updateManager, }) { + if (currentState is UpdateCompleteSuccess || + currentState is UpdateCompleteFailure || + currentState is UpdateCompleteAborted) { + return UpdateFirmwareStateHistory( + null, + [...?_state?.history, currentState], + isComplete: true, + updateManager: updateManager, + ); + } if (_state == null) { return UpdateFirmwareStateHistory( currentState, @@ -191,15 +223,6 @@ class UpdateBloc extends Bloc { updateManager: updateManager, ); } - } else if (currentState is UpdateCompleteSuccess || - currentState is UpdateCompleteFailure || - currentState is UpdateCompleteAborted) { - return UpdateFirmwareStateHistory( - null, - _state!.history + [currentState], - isComplete: true, - updateManager: updateManager, - ); } else { return UpdateFirmwareStateHistory( currentState, diff --git a/lib/src/fota/handlers/firmware_update_handler.dart b/lib/src/fota/handlers/firmware_update_handler.dart index 6c70512..fc74e1b 100644 --- a/lib/src/fota/handlers/firmware_update_handler.dart +++ b/lib/src/fota/handlers/firmware_update_handler.dart @@ -97,48 +97,49 @@ class FirmwareUnpacker extends FirmwareUpdateHandler { final tempDir = Directory('${systemTempDir.path}/$prefix'); await tempDir.create(); - final firmware = request as MultiImageFirmwareUpdateRequest; - final firmwareFileData = firmware.zipFile!; - final firmwareFile = File('${tempDir.path}/firmware.zip'); - await firmwareFile.writeAsBytes(firmwareFileData); - - final destinationDir = Directory('${tempDir.path}/firmware'); - await destinationDir.create(); try { - await ZipFile.extractToDirectory( - zipFile: firmwareFile, - destinationDir: destinationDir, - ); - } catch (e) { - throw Exception('Failed to unzip firmware'); - } + final firmware = request as MultiImageFirmwareUpdateRequest; + final firmwareFileData = firmware.zipFile!; + final firmwareFile = File('${tempDir.path}/firmware.zip'); + await firmwareFile.writeAsBytes(firmwareFileData); + + final destinationDir = Directory('${tempDir.path}/firmware'); + await destinationDir.create(); + try { + await ZipFile.extractToDirectory( + zipFile: firmwareFile, + destinationDir: destinationDir, + ); + } catch (e) { + throw Exception('Failed to unzip firmware'); + } - // read manifest.json - final manifestFile = File('${destinationDir.path}/manifest.json'); - final manifestString = await manifestFile.readAsString(); - Map manifestJson = json.decode(manifestString); - Manifest manifest; + // read manifest.json + final manifestFile = File('${destinationDir.path}/manifest.json'); + final manifestString = await manifestFile.readAsString(); + Map manifestJson = json.decode(manifestString); + Manifest manifest; - try { - manifest = Manifest.fromJson(manifestJson); - } catch (e) { - throw Exception('Failed to parse manifest.json'); - } + try { + manifest = Manifest.fromJson(manifestJson); + } catch (e) { + throw Exception('Failed to parse manifest.json'); + } - firmware.firmwareImages = []; - for (final file in manifest.files) { - final firmwareFile = File('${destinationDir.path}/${file.file}'); - final firmwareFileData = await firmwareFile.readAsBytes(); - final image = Image( - image: file.image, - data: firmwareFileData, - ); - firmware.firmwareImages!.add(image); + firmware.firmwareImages = []; + for (final file in manifest.files) { + final firmwareFile = File('${destinationDir.path}/${file.file}'); + final firmwareFileData = await firmwareFile.readAsBytes(); + final image = Image( + image: file.image, + data: firmwareFileData, + ); + firmware.firmwareImages!.add(image); + } + } finally { + await tempDir.delete(recursive: true); } - // delete tempDir - await tempDir.delete(recursive: true); - return await _nextHandler!.handleFirmwareUpdate(request, callback); } } diff --git a/test/fota_preparation_failure_test.dart b/test/fota_preparation_failure_test.dart new file mode 100644 index 0000000..1396d13 --- /dev/null +++ b/test/fota_preparation_failure_test.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mcumgr_flutter/mcumgr_flutter.dart'; +import 'package:open_earable_flutter/src/fota/bloc/update_bloc.dart'; +import 'package:open_earable_flutter/src/fota/handlers/firmware_update_handler.dart'; +import 'package:open_earable_flutter/src/fota/model/firmware_update_request.dart'; + +class _Handler extends FirmwareUpdateHandler { + final Future Function(FirmwareUpdateCallback?) run; + _Handler(this.run); + @override + Future handleFirmwareUpdate( + FirmwareUpdateRequest request, + FirmwareUpdateCallback? callback, + ) => + run(callback); +} + +class _Bloc extends UpdateBloc { + final FirmwareUpdateHandler handler; + _Bloc(this.handler) : super(firmwareUpdateRequest: FirmwareUpdateRequest()); + @override + FirmwareUpdateHandler createFirmwareUpdateHandler() => handler; +} + +Future completed(UpdateBloc bloc) => bloc.stream + .where((s) => s is UpdateFirmwareStateHistory && s.isComplete) + .cast() + .first + .timeout(const Duration(seconds: 1)); + +void main() { + for (final unpackStarted in [false, true]) { + test('preparation failure completes (unpack started: $unpackStarted)', + () async { + final bloc = _Bloc(_Handler((callback) async { + if (unpackStarted) callback?.call(FirmwareUnpackStarted()); + throw const FormatException('Invalid firmware manifest'); + }),); + addTearDown(bloc.close); + final result = completed(bloc); + bloc.add(BeginUpdateProcess()); + final state = await result; + expect(state.currentState, isNull); + expect(state.history.last, isA()); + expect((state.history.last as UpdateCompleteFailure).error, + contains('Invalid firmware manifest'),); + }); + } + + test('one abort completes before any progress history exists', () async { + final bloc = + _Bloc(_Handler((_) => Completer().future)); + addTearDown(bloc.close); + final result = completed(bloc); + bloc.add(AbortUpdate()); + expect((await result).history.last, isA()); + }); + + test('abort during preparation stops the next upload stage', () async { + final resume = Completer(); + var uploadStarted = false; + final bloc = _Bloc(_Handler((callback) async { + callback?.call(FirmwareUnpackStarted()); + await resume.future; + callback?.call(FirmwareUploadStarted()); + uploadStarted = true; + throw StateError('Must not start uploading'); + }),); + addTearDown(bloc.close); + final unpack = + bloc.stream.firstWhere((s) => s is UpdateFirmwareStateHistory); + bloc.add(BeginUpdateProcess()); + await unpack; + final result = completed(bloc); + bloc.add(AbortUpdate()); + expect((await result).history.last, isA()); + resume.complete(); + await Future.delayed(const Duration(milliseconds: 20)); + expect(uploadStarted, isFalse); + expect((bloc.state as UpdateFirmwareStateHistory).isComplete, isTrue); + expect((bloc.state as UpdateFirmwareStateHistory).history.last, + isA(),); + }); +} diff --git a/test/fota_unpacker_test.dart b/test/fota_unpacker_test.dart new file mode 100644 index 0000000..027749c --- /dev/null +++ b/test/fota_unpacker_test.dart @@ -0,0 +1,83 @@ +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mcumgr_flutter/mcumgr_flutter.dart'; +import 'package:open_earable_flutter/src/fota/handlers/firmware_update_handler.dart'; +import 'package:open_earable_flutter/src/fota/model/firmware_update_request.dart'; + +class _Manager implements FirmwareUpdateManager { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Upload extends FirmwareUpdateHandler { + int calls = 0; + @override + Future handleFirmwareUpdate( + FirmwareUpdateRequest request, + FirmwareUpdateCallback? callback, + ) async { + calls++; + return _Manager(); + } +} + +void main() { + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + late Directory temp; + const pathChannel = MethodChannel('plugins.flutter.io/path_provider'); + const archiveChannel = MethodChannel('flutter_archive'); + setUp(() async { + temp = await Directory.systemTemp.createTemp('fota-test-'); + binding.defaultBinaryMessenger + .setMockMethodCallHandler(pathChannel, (_) async => temp.path); + }); + tearDown(() async { + binding.defaultBinaryMessenger.setMockMethodCallHandler(pathChannel, null); + binding.defaultBinaryMessenger + .setMockMethodCallHandler(archiveChannel, null); + await temp.delete(recursive: true); + }); + for (final kind in [ + 'archive', + 'json', + 'manifest', + 'missing image', + 'valid', + ]) { + test('unpacker cleans temporary files after $kind', () async { + binding.defaultBinaryMessenger.setMockMethodCallHandler(archiveChannel, + (call) async { + if (kind == 'archive') throw PlatformException(code: 'invalid zip'); + final dir = call.arguments['destinationDir'] as String; + final manifest = kind == 'json' + ? '{' + : kind == 'manifest' + ? '{"format-version":1,"time":0,"files":"invalid-test-value"}' + : '{"format-version":1,"time":0,"files":[{"file":"app.bin"}]}'; + await File('$dir/manifest.json').writeAsString(manifest); + if (kind == 'valid') await File('$dir/app.bin').writeAsBytes([1, 2, 3]); + return null; + }); + final upload = _Upload(); + final handler = FirmwareUnpacker()..setNextHandler(upload); + final request = MultiImageFirmwareUpdateRequest( + firmware: LocalFirmware( + name: 'test.zip', + data: Uint8List(0), + type: FirmwareType.multiImage,), + zipFile: Uint8List(0), + ); + if (kind == 'valid') { + await handler.handleFirmwareUpdate(request, null); + expect(upload.calls, 1); + expect(request.firmwareImages!.single.data, [1, 2, 3]); + } else { + await expectLater( + handler.handleFirmwareUpdate(request, null), throwsA(anything),); + expect(upload.calls, 0); + } + expect(await temp.list().toList(), isEmpty); + }); + } +} From 75ee28a48f651e9f8dcf7a1a4638add726d1be6b Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sat, 3 Oct 2026 21:04:26 +0200 Subject: [PATCH 04/15] fix: clear BLE connections when the adapter powers off --- lib/src/managers/ble_manager.dart | 46 +++++-- test/ble_manager_power_cycle_test.dart | 175 +++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 test/ble_manager_power_cycle_test.dart diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index ca81d66..b6b2d9c 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -33,6 +33,7 @@ class BleManager extends BleGattManager { final Map _disconnectCallbacks = {}; final List _connectedDevicesIds = []; + StreamSubscription? _availabilitySubscription; bool _firstScan = true; @@ -61,6 +62,18 @@ class BleManager extends BleGattManager { void _init() { _scanStreamController = StreamController.broadcast(); + _availabilitySubscription = UniversalBle.availabilityStream.listen((state) { + if (state == AvailabilityState.poweredOff) { + // Adapter shutdown does not always emit a disconnect for each device. + for (final deviceId in { + ..._connectedDevicesIds, + ..._disconnectCallbacks.keys, + }) { + _handleDisconnect(deviceId); + } + } + }); + UniversalBle.onConnectionChange = ( String deviceId, bool isConnected, @@ -72,10 +85,7 @@ class BleManager extends BleGattManager { _connectCallbacks[deviceId]?.call(); _connectCallbacks.remove(deviceId); } else { - _connectedDevicesIds.remove(deviceId); - _closeAndRemoveStreamsForDevice(deviceId); - _disconnectCallbacks[deviceId]?.call(); - _disconnectCallbacks.remove(deviceId); + _handleDisconnect(deviceId); } }; @@ -98,6 +108,12 @@ class BleManager extends BleGattManager { }; } + void _handleDisconnect(String deviceId) { + _connectedDevicesIds.removeWhere((id) => id == deviceId); + _closeAndRemoveStreamsForDevice(deviceId); + _disconnectCallbacks.remove(deviceId)?.call(); + } + static Future checkAndRequestPermissions() async { bool permGranted = false; @@ -232,8 +248,11 @@ class BleManager extends BleGattManager { final completer = Completer<(bool, List)>(); _connectionCompleters[device.id] = completer; - final connectionFuture = completer.future.whenComplete(() { - _connectionFutures.remove(device.id); + late final Future<(bool, List)> connectionFuture; + connectionFuture = completer.future.whenComplete(() { + if (identical(_connectionFutures[device.id], connectionFuture)) { + _connectionFutures.remove(device.id); + } }); _connectionFutures[device.id] = connectionFuture; @@ -245,12 +264,18 @@ class BleManager extends BleGattManager { final services = await UniversalBle.discoverServices(device.id); - _connectionCompleters[device.id]?.complete((true, services)); + if (!completer.isCompleted) { + completer.complete((true, services)); + } } catch (error, stack) { - _connectionCompleters[device.id]?.completeError(error, stack); + if (!completer.isCompleted) { + completer.completeError(error, stack); + } } finally { - _connectionCompleters.remove(device.id); - _connectCallbacks.remove(device.id); + if (identical(_connectionCompleters[device.id], completer)) { + _connectionCompleters.remove(device.id); + _connectCallbacks.remove(device.id); + } } }; @@ -458,6 +483,7 @@ class BleManager extends BleGattManager { /// Cancel connection state subscription void dispose() { + _availabilitySubscription?.cancel(); UniversalBle.onConnectionChange = ( String deviceId, bool isConnected, diff --git a/test/ble_manager_power_cycle_test.dart b/test/ble_manager_power_cycle_test.dart new file mode 100644 index 0000000..6f345bd --- /dev/null +++ b/test/ble_manager_power_cycle_test.dart @@ -0,0 +1,175 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_earable_flutter/src/managers/ble_manager.dart'; +import 'package:universal_ble/universal_ble.dart'; + +DiscoveredDevice device(String id) => DiscoveredDevice( + id: id, + name: 'Test wearable', + manufacturerData: Uint8List(0), + rssi: -40, + serviceUuids: const [], + ); + +Future flushEvents() => Future.delayed(Duration.zero); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('power off disconnects both peers even without native callbacks', + () async { + final platform = _Platform(); + UniversalBle.setInstance(platform); + final manager = BleManager(); + addTearDown(manager.dispose); + final disconnected = []; + for (final id in ['left', 'right']) { + await manager.connectToDevice(device(id), () => disconnected.add(id)); + } + final stream = await manager.subscribe( + deviceId: 'left', + serviceId: '180f', + characteristicId: '2a19', + ); + final closed = Completer(); + final sub = stream.listen((_) {}, onDone: closed.complete); + addTearDown(sub.cancel); + + platform.updateAvailability(AvailabilityState.poweredOff); + await flushEvents(); + expect(manager.isConnected('left'), isFalse); + expect(manager.isConnected('right'), isFalse); + expect(disconnected, ['left', 'right']); + await closed.future.timeout(const Duration(seconds: 1)); + + // Some platforms also deliver per-device events, possibly after power-off. + platform.updateConnection('left', false); + platform.updateConnection('right', false); + platform.updateAvailability(AvailabilityState.poweredOff); + await flushEvents(); + expect(disconnected, ['left', 'right']); + + platform.updateAvailability(AvailabilityState.poweredOn); + await manager.connectToDevice( + device('left'), () => disconnected.add('left')); + expect(manager.isConnected('left'), isTrue); + platform.updateConnection('left', false); + expect(disconnected, ['left', 'right', 'left']); + }); + + test( + 'an old service discovery cannot finish a new connection after power off', + () async { + UniversalBle.queueType = QueueType.none; + addTearDown(() => UniversalBle.queueType = QueueType.global); + final oldServices = Completer>(); + final newServices = Completer>(); + final platform = _Platform()..discoveryReplies = [oldServices, newServices]; + UniversalBle.setInstance(platform); + final manager = BleManager(); + addTearDown(manager.dispose); + final first = manager.connectToDevice(device('left'), () {}); + await flushEvents(); + expect(platform.discoveryCalls, 1); + platform.updateAvailability(AvailabilityState.poweredOff); + await flushEvents(); + expect((await first).$1, isFalse); + + platform.updateAvailability(AvailabilityState.poweredOn); + var completed = false; + final second = + manager.connectToDevice(device('left'), () {}).then((result) { + completed = true; + return result; + }); + await flushEvents(); + expect(platform.discoveryCalls, 2); + oldServices.complete([]); + await flushEvents(); + expect(completed, isFalse); + final expected = [BleService('180f', [])]; + newServices.complete(expected); + expect((await second).$2, expected); + }); + + test('wearable manager permits reconnect after a missing disconnect event', + () async { + final platform = _Platform(); + UniversalBle.setInstance(platform); + final manager = WearableManager(); + manager.clearWearableFactories(); + manager.addWearableFactory(_Factory()); + addTearDown(manager.dispose); + final left = await manager.connectToDevice(device('left')); + final right = await manager.connectToDevice(device('right')); + var disconnects = 0; + left.addDisconnectListener(() => disconnects++); + right.addDisconnectListener(() => disconnects++); + + platform.updateAvailability(AvailabilityState.poweredOff); + await flushEvents(); + expect(disconnects, 2); + platform.updateAvailability(AvailabilityState.poweredOn); + final newLeft = await manager.connectToDevice(device('left')); + final newRight = await manager.connectToDevice(device('right')); + expect(identical(newLeft, left), isFalse); + expect(identical(newRight, right), isFalse); + }); +} + +class _Platform extends UniversalBlePlatform { + List>>? discoveryReplies; + int discoveryCalls = 0; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); + @override + Future getBluetoothAvailabilityState() async => + AvailabilityState.poweredOn; + @override + Future connect(String deviceId, + {Duration? connectionTimeout, + bool autoConnect = false, + Object? platformConfig}) async { + updateConnection(deviceId, true); + } + + @override + Future requestMtu(String deviceId, int expectedMtu) async => expectedMtu; + @override + Future> discoverServices( + String deviceId, bool withDescriptors) async { + final index = discoveryCalls++; + return discoveryReplies == null + ? [] + : await discoveryReplies![index].future; + } + + @override + Future setNotifiable(String deviceId, String service, + String characteristic, BleInputProperty property) async {} + @override + Future stopScan() async {} +} + +class _Factory extends WearableFactory { + @override + Future matches( + DiscoveredDevice device, List services) async => + true; + @override + Future createFromDevice(DiscoveredDevice device, + {Set options = const {}}) async => + _Wearable(device.id, disconnectNotifier!); +} + +class _Wearable extends Wearable { + _Wearable(this.deviceId, WearableDisconnectNotifier notifier) + : super(name: 'Test wearable', disconnectNotifier: notifier); + @override + final String deviceId; + @override + Future disconnect() async {} +} From fa73f0992a10f8cdc283cc266873377c79085de4 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sat, 3 Oct 2026 21:04:58 +0200 Subject: [PATCH 05/15] style: satisfy regression test formatting --- test/ble_manager_power_cycle_test.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/ble_manager_power_cycle_test.dart b/test/ble_manager_power_cycle_test.dart index 6f345bd..3607b01 100644 --- a/test/ble_manager_power_cycle_test.dart +++ b/test/ble_manager_power_cycle_test.dart @@ -54,7 +54,7 @@ void main() { platform.updateAvailability(AvailabilityState.poweredOn); await manager.connectToDevice( - device('left'), () => disconnected.add('left')); + device('left'), () => disconnected.add('left'),); expect(manager.isConnected('left'), isTrue); platform.updateConnection('left', false); expect(disconnected, ['left', 'right', 'left']); @@ -132,7 +132,7 @@ class _Platform extends UniversalBlePlatform { Future connect(String deviceId, {Duration? connectionTimeout, bool autoConnect = false, - Object? platformConfig}) async { + Object? platformConfig,}) async { updateConnection(deviceId, true); } @@ -140,7 +140,7 @@ class _Platform extends UniversalBlePlatform { Future requestMtu(String deviceId, int expectedMtu) async => expectedMtu; @override Future> discoverServices( - String deviceId, bool withDescriptors) async { + String deviceId, bool withDescriptors,) async { final index = discoveryCalls++; return discoveryReplies == null ? [] @@ -149,7 +149,7 @@ class _Platform extends UniversalBlePlatform { @override Future setNotifiable(String deviceId, String service, - String characteristic, BleInputProperty property) async {} + String characteristic, BleInputProperty property,) async {} @override Future stopScan() async {} } @@ -157,11 +157,11 @@ class _Platform extends UniversalBlePlatform { class _Factory extends WearableFactory { @override Future matches( - DiscoveredDevice device, List services) async => + DiscoveredDevice device, List services,) async => true; @override Future createFromDevice(DiscoveredDevice device, - {Set options = const {}}) async => + {Set options = const {},}) async => _Wearable(device.id, disconnectNotifier!); } From 7c442040fd4d44c2e5f51b9ed1cb0f361d8063f6 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sat, 3 Oct 2026 21:15:11 +0200 Subject: [PATCH 06/15] fix: release native GATT handles before reconnecting --- lib/src/managers/ble_manager.dart | 9 +++- test/ble_manager_power_cycle_test.dart | 70 ++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index b6b2d9c..60030f5 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -34,6 +34,7 @@ class BleManager extends BleGattManager { final List _connectedDevicesIds = []; StreamSubscription? _availabilitySubscription; + final Map> _adapterShutdowns = {}; bool _firstScan = true; @@ -70,6 +71,11 @@ class BleManager extends BleGattManager { ..._disconnectCallbacks.keys, }) { _handleDisconnect(deviceId); + // Android may retain a GATT handle tied to the stopped BT service. + _adapterShutdowns[deviceId] ??= + UniversalBle.disconnect(deviceId).catchError((Object error) { + logger.w('Bluetooth shutdown cleanup failed for $deviceId: $error'); + }).whenComplete(() => _adapterShutdowns.remove(deviceId)); } } }); @@ -235,7 +241,8 @@ class BleManager extends BleGattManager { Future<(bool, List)> connectToDevice( DiscoveredDevice device, VoidCallback onDisconnect, - ) { + ) async { + await _adapterShutdowns[device.id]; final pendingConnection = _connectionFutures[device.id]; if (pendingConnection != null) { logger.d("Reusing pending connection for ${device.id}"); diff --git a/test/ble_manager_power_cycle_test.dart b/test/ble_manager_power_cycle_test.dart index 3607b01..8ebc8a3 100644 --- a/test/ble_manager_power_cycle_test.dart +++ b/test/ble_manager_power_cycle_test.dart @@ -43,6 +43,7 @@ void main() { expect(manager.isConnected('left'), isFalse); expect(manager.isConnected('right'), isFalse); expect(disconnected, ['left', 'right']); + expect(platform.disconnects, ['left', 'right']); await closed.future.timeout(const Duration(seconds: 1)); // Some platforms also deliver per-device events, possibly after power-off. @@ -54,12 +55,33 @@ void main() { platform.updateAvailability(AvailabilityState.poweredOn); await manager.connectToDevice( - device('left'), () => disconnected.add('left'),); + device('left'), + () => disconnected.add('left'), + ); expect(manager.isConnected('left'), isTrue); platform.updateConnection('left', false); expect(disconnected, ['left', 'right', 'left']); }); + test('reconnect waits until the old native handle is released', () async { + final platform = _Platform(); + UniversalBle.setInstance(platform); + final manager = BleManager(); + addTearDown(manager.dispose); + await manager.connectToDevice(device('left'), () {}); + final cleanup = Completer(); + platform.disconnectGate = cleanup.future; + platform.updateAvailability(AvailabilityState.poweredOff); + await flushEvents(); + platform.updateAvailability(AvailabilityState.poweredOn); + final reconnected = manager.connectToDevice(device('left'), () {}); + await flushEvents(); + expect(platform.connectCalls, 1); + cleanup.complete(); + expect((await reconnected).$1, isTrue); + expect(platform.connectCalls, 2); + }); + test( 'an old service discovery cannot finish a new connection after power off', () async { @@ -123,24 +145,42 @@ void main() { class _Platform extends UniversalBlePlatform { List>>? discoveryReplies; int discoveryCalls = 0; + int connectCalls = 0; + final disconnects = []; + Future? disconnectGate; @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); @override Future getBluetoothAvailabilityState() async => AvailabilityState.poweredOn; @override - Future connect(String deviceId, - {Duration? connectionTimeout, - bool autoConnect = false, - Object? platformConfig,}) async { + Future connect( + String deviceId, { + Duration? connectionTimeout, + bool autoConnect = false, + Object? platformConfig, + }) async { + connectCalls++; updateConnection(deviceId, true); } + @override + Future getConnectionState(String deviceId) async => + BleConnectionState.disconnected; + @override + Future disconnect(String deviceId) async { + disconnects.add(deviceId); + await disconnectGate; + updateConnection(deviceId, false); + } + @override Future requestMtu(String deviceId, int expectedMtu) async => expectedMtu; @override Future> discoverServices( - String deviceId, bool withDescriptors,) async { + String deviceId, + bool withDescriptors, + ) async { final index = discoveryCalls++; return discoveryReplies == null ? [] @@ -148,8 +188,12 @@ class _Platform extends UniversalBlePlatform { } @override - Future setNotifiable(String deviceId, String service, - String characteristic, BleInputProperty property,) async {} + Future setNotifiable( + String deviceId, + String service, + String characteristic, + BleInputProperty property, + ) async {} @override Future stopScan() async {} } @@ -157,11 +201,15 @@ class _Platform extends UniversalBlePlatform { class _Factory extends WearableFactory { @override Future matches( - DiscoveredDevice device, List services,) async => + DiscoveredDevice device, + List services, + ) async => true; @override - Future createFromDevice(DiscoveredDevice device, - {Set options = const {},}) async => + Future createFromDevice( + DiscoveredDevice device, { + Set options = const {}, + }) async => _Wearable(device.id, disconnectNotifier!); } From 34f45873ef046a92acb37382a9e855be922445f8 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sat, 3 Oct 2026 21:16:15 +0200 Subject: [PATCH 07/15] fix: complete native shutdown cleanup without chaining itself --- lib/src/managers/ble_manager.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index 60030f5..f5eeb75 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -75,7 +75,9 @@ class BleManager extends BleGattManager { _adapterShutdowns[deviceId] ??= UniversalBle.disconnect(deviceId).catchError((Object error) { logger.w('Bluetooth shutdown cleanup failed for $deviceId: $error'); - }).whenComplete(() => _adapterShutdowns.remove(deviceId)); + }).whenComplete(() { + _adapterShutdowns.remove(deviceId); + }); } } }); From 199f245401553f9c362435051420c6dedc35e085 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sun, 4 Oct 2026 22:03:20 +0200 Subject: [PATCH 08/15] feat(ppg): decode firmware 2.3 samples while retaining legacy support --- CHANGELOG.md | 5 + example/pubspec.lock | 13 +- .../models/devices/open_earable_factory.dart | 5 +- lib/src/models/devices/open_earable_v2.dart | 2 +- .../v2_sensor_value_parser.dart | 113 +++++++++- pubspec.yaml | 6 +- test/compact_ppg_test.dart | 213 ++++++++++++++++++ 7 files changed, 337 insertions(+), 20 deletions(-) create mode 100644 test/compact_ppg_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b33c9f..114dfd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +- Decode compact BLE PPG for firmware 2.3.x per device while preserving legacy 2.2.x and SD sample decoding. +- Accept firmware 2.3.x in the OpenEarable V2 support range. + ## 2.4.1 * fixed microphone gain capability detection so it is only registered when the device exposes the required characteristic. diff --git a/example/pubspec.lock b/example/pubspec.lock index 4539e62..84a091c 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -414,15 +414,16 @@ packages: path: ".." relative: true source: path - version: "2.4.1" + version: "2.5.0" open_earable_protocols: dependency: transitive description: - name: open_earable_protocols - sha256: c11cae4914827c1d7617d44647a5c49304f0c2a2c02c907c595682cfade6ddb2 - url: "https://pub.dev" - source: hosted - version: "0.0.2" + path: "generated/dart" + ref: "7dc1765e5490bc7b6843ac7236a503df11b6e8a7" + resolved-ref: "7dc1765e5490bc7b6843ac7236a503df11b6e8a7" + url: "https://github.com/OpenEarable/protocol.git" + source: git + version: "0.0.4" package_config: dependency: transitive description: diff --git a/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index a1d82b0..832d505 100644 --- a/lib/src/models/devices/open_earable_factory.dart +++ b/lib/src/models/devices/open_earable_factory.dart @@ -91,7 +91,7 @@ class OpenEarableFactory extends WearableFactory { ); } else if (_v2Regex.hasMatch(firmwareVersion)) { (List, List) sensorInfo = - await _initSensors(device); + await _initSensors(device, firmwareVersion); final wearable = OpenEarableV2( name: device.name, disconnectNotifier: disconnectNotifier!, @@ -202,6 +202,7 @@ class OpenEarableFactory extends WearableFactory { Future<(List, List)> _initSensors( DiscoveredDevice device, + String firmwareVersion, ) async { List sensors = []; List sensorConfigurations = []; @@ -212,7 +213,7 @@ class OpenEarableFactory extends WearableFactory { bleManager: bleManager!, discoveredDevice: device, sensorSchemeParser: schemeParser, - sensorValueParser: V2SensorValueParser(), + sensorValueParser: V2SensorValueParser.forFirmware(firmwareVersion), ); List sensorSchemes = await schemeParser.readSensorSchemes(); diff --git a/lib/src/models/devices/open_earable_v2.dart b/lib/src/models/devices/open_earable_v2.dart index ad3a250..eaa0de5 100644 --- a/lib/src/models/devices/open_earable_v2.dart +++ b/lib/src/models/devices/open_earable_v2.dart @@ -44,7 +44,7 @@ const String _timeSyncRttCharacteristicUuid = "2e04cbf9-939d-4be5-823e-271838b75259"; final VersionConstraint _versionConstraint = - VersionConstraint.parse(">=2.1.0 <2.3.0"); + VersionConstraint.parse(">=2.1.0 <2.4.0"); // MARK: OpenEarableV2 diff --git a/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart b/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart index 4a9b2d0..5f76f44 100644 --- a/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart +++ b/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart @@ -1,13 +1,32 @@ import 'dart:typed_data'; +import 'package:open_earable_protocols/open_earable_protocols.dart'; +import 'package:pub_semver/pub_semver.dart'; + import '../sensor_scheme_parser/sensor_scheme_reader.dart'; import 'sensor_value_parser.dart'; const int _boneAccelSensorId = 0x07; class V2SensorValueParser extends SensorValueParser { + /// Defaults to the legacy layout, which all SD/.oe files still use. + V2SensorValueParser({this.compactPpg = false}); + + /// Select once per BLE connection; never infer encoding from packet length. + factory V2SensorValueParser.forFirmware(String firmwareVersion) { + final version = Version.parse(firmwareVersion.trim()); + return V2SensorValueParser( + compactPpg: version.major == 2 && version.minor >= 3, + ); + } + + final bool compactPpg; + @override - List> parse(ByteData data, List sensorSchemes) { + List> parse( + ByteData data, + List sensorSchemes, + ) { int i = 0; // Header @@ -28,6 +47,10 @@ class V2SensorValueParser extends SensorValueParser { final baseTimestamp = _readUint64(data, i); i += 8; + if (compactPpg && sensorId == 4) { + return _parseCompactPpg(data, scheme, baseTimestamp); + } + // Precompute size of one component payload for efficiency. final compSizes = scheme.components.map((c) => c.type.size()).toList(); final payloadSizePerSample = compSizes.fold(0, (a, b) => a + b); @@ -36,11 +59,15 @@ class V2SensorValueParser extends SensorValueParser { const headerSize = 2; if (data.lengthInBytes - headerSize - payloadSizePerSample < 0) { - throw FormatException('Truncated frame: need at least ${timestampSize + offsetSize} bytes ' + throw FormatException( + 'Truncated frame: need at least ${timestampSize + offsetSize} bytes ' 'for first sample, have ${data.lengthInBytes - headerSize}.'); } - if ((data.lengthInBytes - headerSize - timestampSize) != payloadSizePerSample && - (data.lengthInBytes - headerSize - timestampSize - offsetSize) % payloadSizePerSample != 0) { + if ((data.lengthInBytes - headerSize - timestampSize) != + payloadSizePerSample && + (data.lengthInBytes - headerSize - timestampSize - offsetSize) % + payloadSizePerSample != + 0) { if (sensorId == _boneAccelSensorId) { final fixedBytes = Uint8List(data.lengthInBytes + 2); // Bulk-copy existing bytes @@ -55,18 +82,25 @@ class V2SensorValueParser extends SensorValueParser { data = fixedData; } - if ((data.lengthInBytes - headerSize - timestampSize) != payloadSizePerSample && - (data.lengthInBytes - headerSize - timestampSize - offsetSize) % payloadSizePerSample != 0) { - throw FormatException('Truncated frame: have ${data.lengthInBytes - headerSize} bytes, ' + if ((data.lengthInBytes - headerSize - timestampSize) != + payloadSizePerSample && + (data.lengthInBytes - headerSize - timestampSize - offsetSize) % + payloadSizePerSample != + 0) { + throw FormatException( + 'Truncated frame: have ${data.lengthInBytes - headerSize} bytes, ' 'which is not consistent with sample size $payloadSizePerSample, timestamp and offset sizes.'); } } int dataCount; - if (data.lengthInBytes - headerSize - timestampSize == payloadSizePerSample) { + if (data.lengthInBytes - headerSize - timestampSize == + payloadSizePerSample) { dataCount = 1; } else { - dataCount = (data.lengthInBytes - headerSize - timestampSize - offsetSize) ~/ payloadSizePerSample; + dataCount = + (data.lengthInBytes - headerSize - timestampSize - offsetSize) ~/ + payloadSizePerSample; } if (dataCount < 1) { @@ -93,6 +127,62 @@ class V2SensorValueParser extends SensorValueParser { return results; } + + List> _parseCompactPpg( + ByteData data, + SensorScheme scheme, + int timestamp, + ) { + final payloadSize = data.lengthInBytes - 10; + if (data.getUint8(1) != payloadSize || + (payloadSize != 10 && + (payloadSize < 22 || (payloadSize - 2) % 10 != 0))) { + throw const FormatException('Invalid compact PPG payload length'); + } + if (scheme.components.length != 4 || + scheme.components.any((c) => c.type != ParseType.uint32)) { + throw const FormatException( + 'Compact PPG requires four uint32 components', + ); + } + final count = payloadSize == 10 ? 1 : (payloadSize - 2) ~/ 10; + final period = count == 1 ? 0 : _getTimeDiff(data); + if (count > 1 && period == 0) { + throw const FormatException('Invalid compact PPG sample period'); + } + final expanded = ByteData(16); + final result = >[]; + for (var i = 0; i < count; i++) { + final sample = PpgCompactSample.fromBytes( + data.buffer.asUint8List(data.offsetInBytes + 10 + i * 10, 10), + ); + if (sample.bits_64_79 > 0x0fff) { + throw const FormatException('Nonzero compact PPG reserved bits'); + } + expanded.setUint32(0, sample.bits_0_31 & 0x7ffff, Endian.little); + expanded.setUint32( + 4, + (sample.bits_0_31 >>> 19) | ((sample.bits_32_63 & 0x3f) << 13), + Endian.little, + ); + expanded.setUint32(8, (sample.bits_32_63 >>> 6) & 0x7ffff, Endian.little); + expanded.setUint32( + 12, + (sample.bits_32_63 >>> 25) | (sample.bits_64_79 << 7), + Endian.little, + ); + result.add( + _parseSample( + data: expanded, + startIndex: 0, + scheme: scheme, + timestamp: timestamp + i * period, + compSizes: const [4, 4, 4, 4], + ).map, + ); + } + return result; + } } /// Helpers @@ -177,7 +267,10 @@ _ParsedSample _parseSample({ i += sz; // install group and component - out.putIfAbsent(comp.groupName, () => {'units': {}}); + out.putIfAbsent( + comp.groupName, + () => {'units': {}}, + ); (out[comp.groupName] as Map)[comp.componentName] = val; // units diff --git a/pubspec.yaml b/pubspec.yaml index 4773379..cc48cc6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,7 +39,11 @@ dependencies: bloc: ^9.1.0 meta: ^1.16.0 pub_semver: ^2.2.0 - open_earable_protocols: ^0.0.2 + open_earable_protocols: + git: + url: https://github.com/OpenEarable/protocol.git + ref: 7dc1765e5490bc7b6843ac7236a503df11b6e8a7 + path: generated/dart dev_dependencies: flutter_test: diff --git a/test/compact_ppg_test.dart b/test/compact_ppg_test.dart new file mode 100644 index 0000000..317a688 --- /dev/null +++ b/test/compact_ppg_test.dart @@ -0,0 +1,213 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:pub_semver/pub_semver.dart' as semver; +import 'package:open_earable_flutter/src/utils/sensor_scheme_parser/sensor_scheme_reader.dart'; +import 'package:open_earable_flutter/src/utils/sensor_value_parser/v2_sensor_value_parser.dart'; + +const channels = ['Red', 'Infrared', 'Green', 'Ambient']; +const timestamp = 1700000000123456; + +class UnusedBle extends Fake implements BleGattManager {} + +final scheme = SensorScheme(4, 'PPG', 4, null) + ..components = [ + for (final name in channels) + Component(ParseType.uint32, 'PPG', name, 'raw'), + ]; + +ByteData packet( + List> samples, { + required bool compact, + int period = 1953, +}) { + final width = compact ? 10 : 16; + final data = + ByteData(10 + samples.length * width + (samples.length > 1 ? 2 : 0)); + data.setUint8(0, 4); + data.setUint8(1, data.lengthInBytes - 10); + data.setUint32(2, timestamp % 0x100000000, Endian.little); + data.setUint32(6, timestamp ~/ 0x100000000, Endian.little); + for (var n = 0; n < samples.length; n++) { + if (compact) { + // Independent reference packing, including on Dart web. + var bits = BigInt.zero; + for (var c = 0; c < 4; c++) { + bits |= BigInt.from(samples[n][c]) << (19 * c); + } + for (var b = 0; b < 10; b++) { + data.setUint8( + 10 + n * width + b, + ((bits >> (8 * b)) & BigInt.from(255)).toInt(), + ); + } + } else { + for (var c = 0; c < 4; c++) { + data.setUint32(10 + n * width + 4 * c, samples[n][c], Endian.little); + } + } + } + if (samples.length > 1) { + data.setUint16(data.lengthInBytes - 2, period, Endian.little); + } + return data; +} + +void expectSamples( + V2SensorValueParser parser, + ByteData bytes, + List> expected, +) { + final result = parser.parse(bytes, [scheme]); + expect(result, hasLength(expected.length)); + for (var n = 0; n < result.length; n++) { + expect(result[n]['sensorId'], 4); + expect(result[n]['timestamp'], timestamp + n * 1953); + for (var c = 0; c < 4; c++) { + expect(result[n]['PPG'][channels[c]], expected[n][c]); + } + } +} + +void main() { + test('device support range retains older firmware and accepts all 2.3.x', () { + final wearable = OpenEarableV2( + name: 'Test', + disconnectNotifier: WearableDisconnectNotifier(), + sensors: [], + sensorConfigurations: [], + bleManager: UnusedBle(), + discoveredDevice: DiscoveredDevice( + id: 'test', + name: 'Test', + manufacturerData: Uint8List(0), + rssi: -40, + serviceUuids: [], + ), + ); + for (final version in [ + '2.1.0', + '2.2.9', + '2.2.10', + '2.3.0-dev.1+gabc', + '2.3.0', + '2.3.99', + ]) { + expect( + wearable.supportedFirmwareRange.allows(semver.Version.parse(version)), + isTrue, + ); + } + expect( + wearable.supportedFirmwareRange.allows(semver.Version(2, 4, 0)), + isFalse, + ); + }); + + test( + 'selects compact BLE data by each device firmware, including prereleases', + () { + for (final version in ['2.1.0', '2.2.9', '2.2.10', '2.2.10-dev.3+gabc']) { + expect(V2SensorValueParser.forFirmware(version).compactPpg, isFalse); + } + for (final version in ['2.3.0', '2.3.0-dev.1+gabc', '2.3.99']) { + expect(V2SensorValueParser.forFirmware(version).compactPpg, isTrue); + } + }); + + test('retains every channel bit and timestamps in maximum-sized batches', () { + final random = Random(318); + for (var run = 0; run < 100; run++) { + final samples = List.generate( + 23, + (_) => List.generate(4, (_) => random.nextInt(1 << 19)), + ); + samples[0] = [0, 0x7ffff, 0x40000, 1]; + samples[1] = [0x7ffff, 0x7ffff, 0x7ffff, 0x7ffff]; + expectSamples( + V2SensorValueParser.forFirmware('2.3.0'), + packet(samples, compact: true), + samples, + ); + } + }); + + test('single sample fits a 20-byte notification and handles ByteData views', + () { + final samples = [ + [1, 2, 3, 4], + ]; + final bytes = packet(samples, compact: true); + expect(bytes.lengthInBytes, 20); + expect( + bytes.buffer.asUint8List().sublist(10), + [1, 0, 16, 0, 192, 0, 0, 8, 0, 0], + ); + final storage = Uint8List(30)..setRange(5, 25, bytes.buffer.asUint8List()); + expectSamples( + V2SensorValueParser.forFirmware('2.3.0'), + ByteData.sublistView(storage, 5, 25), + samples, + ); + }); + + test('mixed-version peers independently decode equal-length packets', () { + final oldSamples = List.generate(5, (i) => [i, i + 1, i + 2, i + 3]); + final newSamples = List.generate(8, (i) => [i, i + 1, i + 2, i + 3]); + final oldPacket = packet(oldSamples, compact: false); + final newPacket = packet(newSamples, compact: true); + expect(oldPacket.lengthInBytes, newPacket.lengthInBytes); + for (var i = 0; i < 3; i++) { + expectSamples( + V2SensorValueParser.forFirmware('2.2.9'), + oldPacket, + oldSamples, + ); + expectSamples( + V2SensorValueParser.forFirmware('2.3.0'), + newPacket, + newSamples, + ); + } + // File/SD decoding uses the legacy default even for recordings from 2.3.x. + expectSamples(V2SensorValueParser(), oldPacket, oldSamples); + }); + + test('rejects malformed compact packets without emitting partial results', + () { + final parser = V2SensorValueParser.forFirmware('2.3.0'); + final samples = [ + [1, 2, 3, 4], + [5, 6, 7, 8], + ]; + final badLength = packet(samples, compact: true)..setUint8(1, 10); + final badReserved = packet(samples, compact: true)..setUint8(29, 0x80); + final badPeriod = packet(samples, compact: true, period: 0); + for (final malformed in [badLength, badReserved, badPeriod]) { + expect(() => parser.parse(malformed, [scheme]), throwsFormatException); + } + final good = packet(samples, compact: true); + for (var length = 0; length < good.lengthInBytes; length++) { + expect( + () => parser.parse(ByteData.sublistView(good, 0, length), [scheme]), + throwsFormatException, + ); + } + }); + + test('2.3 parser leaves non-PPG sensor decoding unchanged', () { + final temperature = SensorScheme(6, 'Temperature', 1, null) + ..components = [Component(ParseType.float, 'Temperature', 'value', 'C')]; + final bytes = ByteData(14) + ..setUint8(0, 6) + ..setUint8(1, 4) + ..setUint32(2, 123, Endian.little) + ..setFloat32(10, 36.5, Endian.little); + expect( + V2SensorValueParser.forFirmware('2.3.0').parse(bytes, [temperature]), + V2SensorValueParser.forFirmware('2.2.9').parse(bytes, [temperature]), + ); + }); +} From bb202f8e14fdcb1a1cddc972aab044a27119c3bf Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sun, 4 Oct 2026 22:10:41 +0200 Subject: [PATCH 09/15] build: declare versioned protocol dependency with review overrides --- example/pubspec.lock | 2 +- example/pubspec.yaml | 8 ++++++++ pubspec.yaml | 14 +++++++++----- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/example/pubspec.lock b/example/pubspec.lock index 84a091c..72010f1 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -416,7 +416,7 @@ packages: source: path version: "2.5.0" open_earable_protocols: - dependency: transitive + dependency: "direct overridden" description: path: "generated/dart" ref: "7dc1765e5490bc7b6843ac7236a503df11b6e8a7" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index e078869..04b84ea 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -62,6 +62,14 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 +# Remove after the protocol 0.0.4 package is published. +dependency_overrides: + open_earable_protocols: + git: + url: https://github.com/OpenEarable/protocol.git + ref: 7dc1765e5490bc7b6843ac7236a503df11b6e8a7 + path: generated/dart + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/pubspec.yaml b/pubspec.yaml index cc48cc6..94639a0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,11 +39,7 @@ dependencies: bloc: ^9.1.0 meta: ^1.16.0 pub_semver: ^2.2.0 - open_earable_protocols: - git: - url: https://github.com/OpenEarable/protocol.git - ref: 7dc1765e5490bc7b6843ac7236a503df11b6e8a7 - path: generated/dart + open_earable_protocols: ^0.0.4 dev_dependencies: flutter_test: @@ -63,3 +59,11 @@ flutter: - assets/wearable_icons/polar/default/ - assets/wearable_icons/polar/watch/ - assets/wearable_icons/polar/strap_sensor/ + +# Remove after the protocol 0.0.4 package is published. +dependency_overrides: + open_earable_protocols: + git: + url: https://github.com/OpenEarable/protocol.git + ref: 7dc1765e5490bc7b6843ac7236a503df11b6e8a7 + path: generated/dart From 358f790476fcd84b6d9026d7bf74ef95148de030 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sun, 4 Oct 2026 22:41:50 +0200 Subject: [PATCH 10/15] Restore compact IMU BLE samples to existing float values --- CHANGELOG.md | 2 + example/pubspec.lock | 4 +- example/pubspec.yaml | 2 +- .../v2_sensor_value_parser.dart | 62 ++++++- pubspec.yaml | 2 +- test/compact_imu_test.dart | 158 ++++++++++++++++++ 6 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 test/compact_imu_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 114dfd6..bb04eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Unreleased +- Decode compact firmware 2.3.x IMU samples back to the same float values and units; retain legacy firmware and SD formats. + - Decode compact BLE PPG for firmware 2.3.x per device while preserving legacy 2.2.x and SD sample decoding. - Accept firmware 2.3.x in the OpenEarable V2 support range. diff --git a/example/pubspec.lock b/example/pubspec.lock index 72010f1..f13a885 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -419,8 +419,8 @@ packages: dependency: "direct overridden" description: path: "generated/dart" - ref: "7dc1765e5490bc7b6843ac7236a503df11b6e8a7" - resolved-ref: "7dc1765e5490bc7b6843ac7236a503df11b6e8a7" + ref: bf61cbe72922045ff850dbdbabfaece8c7a38177 + resolved-ref: bf61cbe72922045ff850dbdbabfaece8c7a38177 url: "https://github.com/OpenEarable/protocol.git" source: git version: "0.0.4" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 04b84ea..a52e69b 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -67,7 +67,7 @@ dependency_overrides: open_earable_protocols: git: url: https://github.com/OpenEarable/protocol.git - ref: 7dc1765e5490bc7b6843ac7236a503df11b6e8a7 + ref: bf61cbe72922045ff850dbdbabfaece8c7a38177 path: generated/dart # For information on the generic Dart part of this file, see the diff --git a/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart b/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart index 5f76f44..1b5479d 100644 --- a/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart +++ b/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart @@ -10,17 +10,19 @@ const int _boneAccelSensorId = 0x07; class V2SensorValueParser extends SensorValueParser { /// Defaults to the legacy layout, which all SD/.oe files still use. - V2SensorValueParser({this.compactPpg = false}); + V2SensorValueParser({this.compactPpg = false, this.compactImu = false}); /// Select once per BLE connection; never infer encoding from packet length. factory V2SensorValueParser.forFirmware(String firmwareVersion) { final version = Version.parse(firmwareVersion.trim()); return V2SensorValueParser( compactPpg: version.major == 2 && version.minor >= 3, + compactImu: version.major == 2 && version.minor >= 3, ); } final bool compactPpg; + final bool compactImu; @override List> parse( @@ -50,6 +52,9 @@ class V2SensorValueParser extends SensorValueParser { if (compactPpg && sensorId == 4) { return _parseCompactPpg(data, scheme, baseTimestamp); } + if (compactImu && sensorId == 0) { + return _parseCompactImu(data, scheme, baseTimestamp); + } // Precompute size of one component payload for efficiency. final compSizes = scheme.components.map((c) => c.type.size()).toList(); @@ -128,6 +133,61 @@ class V2SensorValueParser extends SensorValueParser { return results; } + List> _parseCompactImu( + ByteData data, + SensorScheme scheme, + int timestamp, + ) { + final payloadSize = data.lengthInBytes - 10; + if (data.getUint8(1) != payloadSize || + (payloadSize != 24 && + (payloadSize < 50 || (payloadSize - 2) % 24 != 0))) { + throw const FormatException('Invalid compact IMU payload length'); + } + if (scheme.components.length != 9 || + scheme.components.any((c) => c.type != ParseType.float)) { + throw const FormatException('Compact IMU requires nine float components'); + } + final count = payloadSize == 24 ? 1 : (payloadSize - 2) ~/ 24; + final period = count == 1 ? 0 : _getTimeDiff(data); + if (count > 1 && period == 0) { + throw const FormatException('Invalid compact IMU sample period'); + } + final expanded = ByteData(36); + final result = >[]; + for (var i = 0; i < count; i++) { + final sample = ImuCompactSample.fromBytes( + data.buffer.asUint8List(data.offsetInBytes + 10 + i * 24, 24), + ); + final raw = [ + sample.accel_x, + sample.accel_y, + sample.accel_z, + sample.gyro_x, + sample.gyro_y, + sample.gyro_z, + ]; + for (var axis = 0; axis < 6; axis++) { + // Exact firmware float32 scales; round the product back to float32. + final scale = axis < 3 ? 0.0005985504249110818 : 0.06103515625; + expanded.setFloat32(axis * 4, raw[axis] * scale, Endian.little); + } + expanded.setFloat32(24, sample.mag_x, Endian.little); + expanded.setFloat32(28, sample.mag_y, Endian.little); + expanded.setFloat32(32, sample.mag_z, Endian.little); + result.add( + _parseSample( + data: expanded, + startIndex: 0, + scheme: scheme, + timestamp: timestamp + i * period, + compSizes: const [4, 4, 4, 4, 4, 4, 4, 4, 4], + ).map, + ); + } + return result; + } + List> _parseCompactPpg( ByteData data, SensorScheme scheme, diff --git a/pubspec.yaml b/pubspec.yaml index 94639a0..f241ced 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,5 +65,5 @@ dependency_overrides: open_earable_protocols: git: url: https://github.com/OpenEarable/protocol.git - ref: 7dc1765e5490bc7b6843ac7236a503df11b6e8a7 + ref: bf61cbe72922045ff850dbdbabfaece8c7a38177 path: generated/dart diff --git a/test/compact_imu_test.dart b/test/compact_imu_test.dart new file mode 100644 index 0000000..ff788a8 --- /dev/null +++ b/test/compact_imu_test.dart @@ -0,0 +1,158 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/src/utils/sensor_scheme_parser/sensor_scheme_reader.dart'; +import 'package:open_earable_flutter/src/utils/sensor_value_parser/v2_sensor_value_parser.dart'; + +const groups = ['ACCELEROMETER', 'GYROSCOPE', 'MAGNETOMETER']; +const units = ['m/s^2', 'dps', 'uT']; +const axes = ['X', 'Y', 'Z']; +final scheme = SensorScheme(0, '9-Axis IMU', 9, null) + ..components = [ + for (var group = 0; group < 3; group++) + for (final axis in axes) + Component(ParseType.float, groups[group], axis, units[group]), + ]; +double f32(double value) => (ByteData(4)..setFloat32(0, value, Endian.little)) + .getFloat32(0, Endian.little); + +ByteData packet(List> samples, {required bool compact}) { + final width = compact ? 24 : 36; + final data = + ByteData(10 + samples.length * width + (samples.length > 1 ? 2 : 0)) + ..setUint8(0, 0) + ..setUint32(2, 123456, Endian.little); + data.setUint8(1, data.lengthInBytes - 10); + for (var i = 0; i < samples.length; i++) { + for (var axis = 0; axis < 6; axis++) { + if (compact) { + data.setInt16( + 10 + i * width + 2 * axis, + samples[i][axis], + Endian.little, + ); + } else { + // Independently reproduce the existing sensor's float32 calculation. + final scale = + axis < 3 ? f32(2.0 * f32(9.80665)) / 32768.0 : 2000.0 / 32768.0; + data.setFloat32( + 10 + i * width + 4 * axis, + samples[i][axis] * scale, + Endian.little, + ); + } + } + for (var axis = 0; axis < 3; axis++) { + data.setFloat32( + 10 + i * width + width - 12 + axis * 4, + [12.345, -67.89, -0.0][axis], + Endian.little, + ); + } + } + if (samples.length > 1) { + data.setUint16(data.lengthInBytes - 2, 10000, Endian.little); + } + return data; +} + +void main() { + final compact = V2SensorValueParser.forFirmware('2.3.0'); + final legacy = V2SensorValueParser.forFirmware('2.2.9'); + + test('firmware selects compact IMU per connection; files retain legacy', () { + for (final version in ['2.1.0', '2.2.9', '2.2.10', '2.2.10-dev.1']) { + expect(V2SensorValueParser.forFirmware(version).compactImu, isFalse); + } + for (final version in ['2.3.0', '2.3.0-dev.1+gabc', '2.3.99']) { + expect(V2SensorValueParser.forFirmware(version).compactImu, isTrue); + } + expect(V2SensorValueParser().compactImu, isFalse); + }); + + test('all int16 counts reconstruct the exact existing floats', () { + for (var raw = -32768; raw <= 32767; raw += 6) { + final samples = [ + for (var n = raw; n <= min(raw + 5, 32767); n++) List.filled(6, n), + ]; + expect( + compact.parse(packet(samples, compact: true), [scheme]), + legacy.parse(packet(samples, compact: false), [scheme]), + ); + } + }); + + test( + 'axis order, units, float types, signed limits, timestamps and views remain unchanged', + () { + final samples = + List.generate(6, (i) => [-32768 + i, 32767 - i, -i, i, 12345, -23456]); + final bytes = packet(samples, compact: true); + final storage = Uint8List(bytes.lengthInBytes + 7) + ..setRange(3, 3 + bytes.lengthInBytes, bytes.buffer.asUint8List()); + final result = compact.parse( + ByteData.sublistView(storage, 3, 3 + bytes.lengthInBytes), + [scheme], + ); + expect(result, legacy.parse(packet(samples, compact: false), [scheme])); + for (var i = 0; i < result.length; i++) { + expect(result[i]['timestamp'], 123456 + 10000 * i); + for (var group = 0; group < 3; group++) { + for (var axis = 0; axis < 3; axis++) { + expect(result[i][groups[group]][axes[axis]], isA()); + expect(scheme.components[group * 3 + axis].unitName, units[group]); + expect(scheme.components[group * 3 + axis].type, ParseType.float); + } + } + expect((result[i]['MAGNETOMETER']['Z'] as double).isNegative, isTrue); + } + expect( + V2SensorValueParser().parse(packet(samples, compact: false), [scheme]), + result, + ); + }); + + test( + 'nine-sample packets and single samples decode; mixed equal lengths do not select format', + () { + final samples = List.generate(9, (i) => List.filled(6, i)); + final bytes = packet(samples, compact: true); + expect(bytes.lengthInBytes, 228); + final parsed = compact.parse(bytes, [scheme]); + expect(parsed, hasLength(9)); + expect(parsed.last['timestamp'], 203456); + final oldBytes = packet(samples.sublist(0, 6), compact: false); + expect(oldBytes.lengthInBytes, bytes.lengthInBytes); + for (var i = 0; i < 3; i++) { + expect(legacy.parse(oldBytes, [scheme]), parsed.sublist(0, 6)); + expect(compact.parse(bytes, [scheme]), parsed); + } + final one = packet(samples.sublist(0, 1), compact: true); + expect(one.lengthInBytes, 34); + expect(compact.parse(one, [scheme]), parsed.sublist(0, 1)); + }); + + test('rejects truncated packets, bad lengths, zero periods and wrong schemes', + () { + final good = packet([List.filled(6, 0), List.filled(6, 1)], compact: true); + for (var length = 0; length < good.lengthInBytes; length++) { + expect( + () => compact.parse(ByteData.sublistView(good, 0, length), [scheme]), + throwsFormatException, + ); + } + final badLength = + ByteData.sublistView(Uint8List.fromList(good.buffer.asUint8List())) + ..setUint8(1, 1); + final badPeriod = + ByteData.sublistView(Uint8List.fromList(good.buffer.asUint8List())) + ..setUint16(good.lengthInBytes - 2, 0, Endian.little); + for (final bytes in [badLength, badPeriod]) { + expect(() => compact.parse(bytes, [scheme]), throwsFormatException); + } + final wrongScheme = SensorScheme(0, 'Wrong', 1, null) + ..components = [Component(ParseType.int16, 'Wrong', 'X', 'counts')]; + expect(() => compact.parse(good, [wrongScheme]), throwsFormatException); + }); +} From 07456abb5ebf5e54255d84342cd3f4c643875dd7 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sun, 4 Oct 2026 23:08:48 +0200 Subject: [PATCH 11/15] Select BLE sample format from firmware instead of hardware version --- .../models/devices/open_earable_factory.dart | 40 ++++-- test/factory_transport_version_test.dart | 135 ++++++++++++++++++ 2 files changed, 161 insertions(+), 14 deletions(-) create mode 100644 test/factory_transport_version_test.dart diff --git a/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index 832d505..bc31ec6 100644 --- a/lib/src/models/devices/open_earable_factory.dart +++ b/lib/src/models/devices/open_earable_factory.dart @@ -34,8 +34,10 @@ import 'wearable.dart'; import '../../fota/firmware_slot_manager_impl.dart'; const String _deviceInfoServiceUuid = "45622510-6468-465a-b141-0b9b0f96b468"; -const String _deviceFirmwareVersionCharacteristicUuid = +const String _deviceHardwareVersionCharacteristicUuid = "45622512-6468-465a-b141-0b9b0f96b468"; +const String _deviceFirmwareVersionCharacteristicUuid = + "45622513-6468-465a-b141-0b9b0f96b468"; class OpenEarableFactory extends WearableFactory { final _v1Regex = RegExp(r'^1\.\d+\.\d+$'); @@ -58,13 +60,16 @@ class OpenEarableFactory extends WearableFactory { logger.d("'$device' has no service matching '$_deviceInfoServiceUuid'"); return false; } - String firmwareVersion = await _getFirmwareVersion(device); - logger.d("Firmware Version: '$firmwareVersion'"); + final hardwareVersion = await _readVersion( + device, + _deviceHardwareVersionCharacteristicUuid, + ); + logger.d("Hardware Version: '$hardwareVersion'"); - logger.t("matches V2: ${_v2Regex.hasMatch(firmwareVersion)}"); + logger.t("matches V2: ${_v2Regex.hasMatch(hardwareVersion)}"); - return _v1Regex.hasMatch(firmwareVersion) || - _v2Regex.hasMatch(firmwareVersion); + return _v1Regex.hasMatch(hardwareVersion) || + _v2Regex.hasMatch(hardwareVersion); } @override @@ -80,18 +85,23 @@ class OpenEarableFactory extends WearableFactory { "disconnectNotifier needs to be set before using the factory", ); } - String firmwareVersion = await _getFirmwareVersion(device); + final hardwareVersion = await _readVersion( + device, + _deviceHardwareVersionCharacteristicUuid, + ); - if (_v1Regex.hasMatch(firmwareVersion)) { + if (_v1Regex.hasMatch(hardwareVersion)) { return OpenEarableV1( name: device.name, disconnectNotifier: disconnectNotifier!, bleManager: bleManager!, discoveredDevice: device, ); - } else if (_v2Regex.hasMatch(firmwareVersion)) { - (List, List) sensorInfo = - await _initSensors(device, firmwareVersion); + } else if (_v2Regex.hasMatch(hardwareVersion)) { + (List, List) sensorInfo = await _initSensors( + device, + await _readVersion(device, _deviceFirmwareVersionCharacteristicUuid), + ); final wearable = OpenEarableV2( name: device.name, disconnectNotifier: disconnectNotifier!, @@ -172,13 +182,15 @@ class OpenEarableFactory extends WearableFactory { } } - Future _getFirmwareVersion(DiscoveredDevice device) async { + Future _readVersion( + DiscoveredDevice device, + String characteristicId, + ) async { List softwareGenerationBytes = await bleManager!.read( deviceId: device.id, serviceId: _deviceInfoServiceUuid, - characteristicId: _deviceFirmwareVersionCharacteristicUuid, + characteristicId: characteristicId, ); - logger.d("Raw Firmware Version: $softwareGenerationBytes"); int firstZeroIndex = softwareGenerationBytes.indexOf(0); if (firstZeroIndex != -1) { softwareGenerationBytes = diff --git a/test/factory_transport_version_test.dart b/test/factory_transport_version_test.dart new file mode 100644 index 0000000..74c8664 --- /dev/null +++ b/test/factory_transport_version_test.dart @@ -0,0 +1,135 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_earable_flutter/src/constants.dart'; +import 'package:open_earable_flutter/src/models/devices/open_earable_factory.dart'; +import 'package:universal_ble/universal_ble.dart'; + +const hardwareUuid = '45622512-6468-465a-b141-0b9b0f96b468'; +const firmwareUuid = '45622513-6468-465a-b141-0b9b0f96b468'; + +class VersionGatt extends Fake implements BleGattManager { + String firmware = '2.2.9'; + final reads = []; + final schemes = StreamController>.broadcast(); + final data = StreamController>.broadcast(); + Completer subscribed = Completer(); + + @override + bool isConnected(String deviceId) => true; + + @override + Future hasService({ + required String deviceId, + required String serviceId, + }) async => + false; + + @override + Future hasCharacteristic({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async => + false; + + @override + Future> read({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + reads.add(characteristicId); + if (characteristicId == hardwareUuid) return '2.0.1\x00'.codeUnits; + if (characteristicId == firmwareUuid) return '$firmware\x00'.codeUnits; + if (characteristicId == sensorListCharacteristicUuid) return [1, 4]; + throw StateError('Unexpected read: $characteristicId'); + } + + @override + Future>> subscribe({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + if (characteristicId == sensorSchemeCharacteristicUuid) { + return schemes.stream; + } + expect(characteristicId, sensorDataCharacteristicUuid); + subscribed.complete(); + return data.stream; + } + + @override + Future write({ + required String deviceId, + required String serviceId, + required String characteristicId, + required List byteData, + bool withoutResponse = false, + }) async { + expect(characteristicId, requestSensorSchemeCharacteristicUuid); + List text(String s) => [s.length, ...s.codeUnits]; + schemes.add([ + 4, ...text('PPG'), 4, + for (final axis in ['RED', 'IR', 'GREEN', 'AMBIENT']) ...[ + 5, + ...text('PPG'), + ...text(axis), + ...text('ADC'), + ], + 1, // Streaming, with no optional frequency table. + ]); + } +} + +void main() { + test('real factory selects transport from firmware and refreshes after FOTA', + () async { + final ble = VersionGatt(); + addTearDown(ble.schemes.close); + addTearDown(ble.data.close); + final factory = OpenEarableFactory() + ..bleManager = ble + ..disconnectNotifier = WearableDisconnectNotifier(); + final device = DiscoveredDevice( + id: 'ear', + name: 'Ear', + manufacturerData: Uint8List(0), + rssi: -40, + serviceUuids: [], + ); + for (final version in ['2.2.9', '2.3.0', '2.3.0-dev.91+gabc', '2.2.9']) { + ble.firmware = version; + ble.reads.clear(); + ble.subscribed = Completer(); + expect( + await factory.matches(device, [ + BleService( + OpenEarableV2.deviceInfoServiceUuid, + [], + ), + ]), + isTrue, + ); + final wearable = await factory.createFromDevice(device); + expect(ble.reads, contains(firmwareUuid)); + final sensor = wearable.requireCapability().sensors.single; + expect(sensor.axisUnits, ['ADC', 'ADC', 'ADC', 'ADC']); + final value = sensor.sensorStream.first; + await ble.subscribed.future; + await Future.delayed(Duration.zero); + final compact = version.startsWith('2.3.'); + final sample = compact + ? [1, 0, 16, 0, 192, 0, 0, 8, 0, 0] + : [1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 4, 0, 0, 0]; + ble.data.add([4, sample.length, 123, 0, 0, 0, 0, 0, 0, 0, ...sample]); + final parsed = + await value.timeout(const Duration(seconds: 2)) as SensorDoubleValue; + expect(parsed.values, [1.0, 2.0, 3.0, 4.0]); + expect(parsed.timestamp, 123); + } + }); +} From 6b9f4d441d0873333e82bdd9c92a2f89292cf9fd Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Mon, 5 Oct 2026 04:10:52 +0200 Subject: [PATCH 12/15] Read saved LED state on firmware 2.3.0 --- lib/open_earable_flutter.dart | 1 + .../models/capabilities/led_state_reader.dart | 20 +++++++ .../models/devices/open_earable_factory.dart | 24 +++++++- .../open_earable_v2_led_state_reader.dart | 40 +++++++++++++ test/factory_transport_version_test.dart | 4 ++ test/led_state_reader_test.dart | 56 +++++++++++++++++++ 6 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 lib/src/models/capabilities/led_state_reader.dart create mode 100644 lib/src/models/devices/open_earable_v2_led_state_reader.dart create mode 100644 test/led_state_reader_test.dart diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index 459df4e..aef0914 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -48,6 +48,7 @@ export 'src/models/capabilities/battery_health_status.dart'; export 'src/models/capabilities/battery_energy_status.dart'; export 'src/models/capabilities/rgb_led.dart'; export 'src/models/capabilities/status_led.dart'; +export 'src/models/capabilities/led_state_reader.dart'; export 'src/models/capabilities/sensor.dart'; export 'src/models/capabilities/sensor_specializations/heart_rate_sensor.dart'; export 'src/models/capabilities/sensor_specializations/heart_rate_variability_sensor.dart'; diff --git a/lib/src/models/capabilities/led_state_reader.dart b/lib/src/models/capabilities/led_state_reader.dart new file mode 100644 index 0000000..6ddc77f --- /dev/null +++ b/lib/src/models/capabilities/led_state_reader.dart @@ -0,0 +1,20 @@ +/// Optional readback of the LED override selected on a device. +abstract class LedStateReader { + Future readLedState(); +} + +class LedState { + final bool showStatus; + final int red; + final int green; + final int blue; + + const LedState({ + required this.showStatus, + required this.red, + required this.green, + required this.blue, + }); + + bool get isBlack => red == 0 && green == 0 && blue == 0; +} diff --git a/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index bc31ec6..cd2614c 100644 --- a/lib/src/models/devices/open_earable_factory.dart +++ b/lib/src/models/devices/open_earable_factory.dart @@ -7,6 +7,7 @@ import 'package:open_earable_flutter/src/utils/sensor_scheme_parser/sensor_schem import 'package:open_earable_flutter/src/utils/sensor_scheme_parser/v2_sensor_scheme_reader.dart'; import 'package:open_earable_protocols/open_earable_protocols.dart'; import 'package:universal_ble/universal_ble.dart'; +import 'package:pub_semver/pub_semver.dart' as semver; import '../../../open_earable_flutter.dart' show logger; import '../../constants.dart'; @@ -17,6 +18,7 @@ import '../capabilities/audio_response_manager.dart'; import '../capabilities/fota_capability.dart'; import '../capabilities/fota_slot_info_capability.dart'; import '../capabilities/microphone_gain_manager.dart'; +import '../capabilities/led_state_reader.dart'; import '../capabilities/power_saving_mode_manager.dart'; import '../capabilities/sensor.dart'; import '../capabilities/sensor_configuration.dart'; @@ -30,6 +32,7 @@ import 'open_earable_v1.dart'; import 'open_earable_v2.dart'; import 'open_earable_v2_audio_response_manager.dart'; import 'open_earable_v2_microphone_gain_manager.dart'; +import 'open_earable_v2_led_state_reader.dart'; import 'wearable.dart'; import '../../fota/firmware_slot_manager_impl.dart'; @@ -98,9 +101,11 @@ class OpenEarableFactory extends WearableFactory { discoveredDevice: device, ); } else if (_v2Regex.hasMatch(hardwareVersion)) { + final firmwareVersion = + await _readVersion(device, _deviceFirmwareVersionCharacteristicUuid); (List, List) sensorInfo = await _initSensors( device, - await _readVersion(device, _deviceFirmwareVersionCharacteristicUuid), + firmwareVersion, ); final wearable = OpenEarableV2( name: device.name, @@ -120,6 +125,14 @@ class OpenEarableFactory extends WearableFactory { }, isConnectedViaSystem: options.contains(const ConnectedViaSystem()), ); + if (_supportsLedReadback(firmwareVersion)) { + wearable.registerCapability( + OpenEarableV2LedStateReader( + bleManager: bleManager!, + deviceId: device.id, + ), + ); + } if (await bleManager!.hasService( deviceId: device.id, serviceId: timeSynchronizationServiceUuid, @@ -199,6 +212,15 @@ class OpenEarableFactory extends WearableFactory { return String.fromCharCodes(softwareGenerationBytes); } + bool _supportsLedReadback(String firmwareVersion) { + try { + final version = semver.Version.parse(firmwareVersion.trim()); + return version.major == 2 && version.minor >= 3; + } on FormatException { + return false; + } + } + Future _hasPowerSavingService(DiscoveredDevice device) async { return await bleManager!.hasCharacteristic( deviceId: device.id, diff --git a/lib/src/models/devices/open_earable_v2_led_state_reader.dart b/lib/src/models/devices/open_earable_v2_led_state_reader.dart new file mode 100644 index 0000000..f6da837 --- /dev/null +++ b/lib/src/models/devices/open_earable_v2_led_state_reader.dart @@ -0,0 +1,40 @@ +import '../../managers/ble_gatt_manager.dart'; +import '../capabilities/led_state_reader.dart'; + +/// LED readback added in OpenEarable firmware 2.3.0. +class OpenEarableV2LedStateReader implements LedStateReader { + static const serviceUuid = '81040a2e-4819-11ee-be56-0242ac120002'; + static const colorUuid = '81040e7a-4819-11ee-be56-0242ac120002'; + static const modeUuid = '81040e7b-4819-11ee-be56-0242ac120002'; + + final BleGattManager bleManager; + final String deviceId; + + const OpenEarableV2LedStateReader({ + required this.bleManager, + required this.deviceId, + }); + + @override + Future readLedState() async { + final mode = await bleManager.read( + deviceId: deviceId, + serviceId: serviceUuid, + characteristicId: modeUuid, + ); + final color = await bleManager.read( + deviceId: deviceId, + serviceId: serviceUuid, + characteristicId: colorUuid, + ); + if (mode.length != 1 || mode[0] > 1 || color.length != 3) { + throw const FormatException('Invalid LED state'); + } + return LedState( + showStatus: mode[0] == 0, + red: color[0], + green: color[1], + blue: color[2], + ); + } +} diff --git a/test/factory_transport_version_test.dart b/test/factory_transport_version_test.dart index 74c8664..5a41439 100644 --- a/test/factory_transport_version_test.dart +++ b/test/factory_transport_version_test.dart @@ -116,6 +116,10 @@ void main() { ); final wearable = await factory.createFromDevice(device); expect(ble.reads, contains(firmwareUuid)); + expect( + wearable.hasCapability(), + version.startsWith('2.3.'), + ); final sensor = wearable.requireCapability().sensors.single; expect(sensor.axisUnits, ['ADC', 'ADC', 'ADC', 'ADC']); final value = sensor.sensorStream.first; diff --git a/test/led_state_reader_test.dart b/test/led_state_reader_test.dart new file mode 100644 index 0000000..dbd020a --- /dev/null +++ b/test/led_state_reader_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_earable_flutter/src/models/devices/open_earable_v2_led_state_reader.dart'; + +class LedGatt extends Fake implements BleGattManager { + List mode = [1]; + List color = [12, 34, 56]; + + @override + Future> read({ + required String deviceId, + required String serviceId, + required String characteristicId, + }) async { + expect(deviceId, 'ear'); + expect(serviceId, OpenEarableV2LedStateReader.serviceUuid); + if (characteristicId == OpenEarableV2LedStateReader.modeUuid) return mode; + expect(characteristicId, OpenEarableV2LedStateReader.colorUuid); + return color; + } +} + +void main() { + test('reads manual color, disabled output and automatic status', () async { + final ble = LedGatt(); + final reader = + OpenEarableV2LedStateReader(bleManager: ble, deviceId: 'ear'); + var state = await reader.readLedState(); + expect(state.showStatus, isFalse); + expect([state.red, state.green, state.blue], [12, 34, 56]); + expect(state.isBlack, isFalse); + ble.color = [0, 0, 0]; + state = await reader.readLedState(); + expect(state.isBlack, isTrue); + ble.mode = [0]; + expect((await reader.readLedState()).showStatus, isTrue); + }); + + test('rejects malformed state instead of displaying a guessed mode', + () async { + final ble = LedGatt(); + final reader = + OpenEarableV2LedStateReader(bleManager: ble, deviceId: 'ear'); + for (final mode in >[ + [], + [0, 1], + [2], + ]) { + ble.mode = mode; + await expectLater(reader.readLedState(), throwsFormatException); + } + ble.mode = [1]; + ble.color = [1, 2]; + await expectLater(reader.readLedState(), throwsFormatException); + }); +} From 6c76ca18db8c8774ed0a456e6623d24100587b9d Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Mon, 5 Oct 2026 07:37:15 +0200 Subject: [PATCH 13/15] Release Android BLE clients when the Flutter engine closes --- example/pubspec.lock | 4 ++-- lib/src/managers/ble_manager.dart | 8 +++++++- pubspec.yaml | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/example/pubspec.lock b/example/pubspec.lock index f13a885..cd02945 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -689,10 +689,10 @@ packages: dependency: transitive description: name: universal_ble - sha256: "8e5f4e2827375900b805fe3eb6cb8a305ade2303289d9a5cba0d09a3a37fea29" + sha256: bdebe7d4ff3a24f28b5e56c999104ea1584a9de55da4ee0b4c2346bcf8f8c528 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.3.0" uuid: dependency: transitive description: diff --git a/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index f5eeb75..d49f32c 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -298,7 +298,13 @@ class BleManager extends BleGattManager { }; try { - UniversalBle.connect(device.id); + UniversalBle.connect( + device.id, + platformConfig: ConnectionPlatformConfig( + // A new Flutter engine cannot receive the old GATT client's callbacks. + android: AndroidConnectionOptions(closeGattOnDetach: true), + ), + ); } catch (error, stack) { _connectCallbacks.remove(device.id); _disconnectCallbacks.remove(device.id); diff --git a/pubspec.yaml b/pubspec.yaml index f241ced..fa7dc5b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,7 +21,7 @@ dependencies: typed_data: ^1.3.2 convert: ^3.1.1 permission_handler: ^12.0.1 - universal_ble: ^2.0.1 + universal_ble: ^2.3.0 logger: ^2.5.0 # for fota: equatable: ^2.0.5 From aa1b49385ace2b263a3ff9ac2e9a8241d2a25018 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Tue, 6 Oct 2026 14:05:31 +0200 Subject: [PATCH 14/15] chore(release): prepare library 2.5.0 package publication --- CHANGELOG.md | 6 +++++- example/pubspec.yaml | 8 -------- pubspec.yaml | 8 -------- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d92911f..7b51ad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,13 @@ -## Unreleased +## 2.5.0 - Decode compact firmware 2.3.x IMU samples back to the same float values and units; retain legacy firmware and SD formats. - Decode compact BLE PPG for firmware 2.3.x per device while preserving legacy 2.2.x and SD sample decoding. - Accept firmware 2.3.x in the OpenEarable V2 support range. +- Read and subscribe to LED state on firmware 2.3.x. +- Correct battery status decoding and finish failed or aborted firmware update preparation. +- Clean up Bluetooth connections after adapter power cycles and Flutter engine shutdown. +- Use the published `open_earable_protocols` 0.0.4 package. ## 2.4.2 diff --git a/example/pubspec.yaml b/example/pubspec.yaml index a52e69b..e078869 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -62,14 +62,6 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 -# Remove after the protocol 0.0.4 package is published. -dependency_overrides: - open_earable_protocols: - git: - url: https://github.com/OpenEarable/protocol.git - ref: bf61cbe72922045ff850dbdbabfaece8c7a38177 - path: generated/dart - # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/pubspec.yaml b/pubspec.yaml index fa7dc5b..9218545 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,11 +59,3 @@ flutter: - assets/wearable_icons/polar/default/ - assets/wearable_icons/polar/watch/ - assets/wearable_icons/polar/strap_sensor/ - -# Remove after the protocol 0.0.4 package is published. -dependency_overrides: - open_earable_protocols: - git: - url: https://github.com/OpenEarable/protocol.git - ref: bf61cbe72922045ff850dbdbabfaece8c7a38177 - path: generated/dart From 149fbc9b88f597cd418f56b750e701a82d659d85 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Tue, 6 Oct 2026 18:38:12 +0200 Subject: [PATCH 15/15] chore(release): lock published protocol dependency --- CHANGELOG.md | 2 +- example/pubspec.lock | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b51ad8..92f3b01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Decode compact BLE PPG for firmware 2.3.x per device while preserving legacy 2.2.x and SD sample decoding. - Accept firmware 2.3.x in the OpenEarable V2 support range. -- Read and subscribe to LED state on firmware 2.3.x. +- Read back the configured LED color and status mode on firmware 2.3.x. - Correct battery status decoding and finish failed or aborted firmware update preparation. - Clean up Bluetooth connections after adapter power cycles and Flutter engine shutdown. - Use the published `open_earable_protocols` 0.0.4 package. diff --git a/example/pubspec.lock b/example/pubspec.lock index cd02945..5cde9cf 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -416,13 +416,12 @@ packages: source: path version: "2.5.0" open_earable_protocols: - dependency: "direct overridden" + dependency: transitive description: - path: "generated/dart" - ref: bf61cbe72922045ff850dbdbabfaece8c7a38177 - resolved-ref: bf61cbe72922045ff850dbdbabfaece8c7a38177 - url: "https://github.com/OpenEarable/protocol.git" - source: git + name: open_earable_protocols + sha256: ca63be893d54cf8982814ee3024f524740911481aa55e14fd3b5d93f10d9a8a0 + url: "https://pub.dev" + source: hosted version: "0.0.4" package_config: dependency: transitive @@ -782,5 +781,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.4 <4.0.0" + dart: ">=3.10.3 <4.0.0" flutter: ">=3.38.4"