diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c436c35..92f3b019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 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 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. + ## 2.4.2 * fixed firmware update version comparisons for development firmware labels, prereleases, build metadata, and release tag prefixes. diff --git a/example/pubspec.lock b/example/pubspec.lock index 1b6d29d8..5cde9cff 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -414,15 +414,15 @@ packages: path: ".." relative: true source: path - version: "2.4.2" + version: "2.5.0" open_earable_protocols: dependency: transitive description: name: open_earable_protocols - sha256: c11cae4914827c1d7617d44647a5c49304f0c2a2c02c907c595682cfade6ddb2 + sha256: ca63be893d54cf8982814ee3024f524740911481aa55e14fd3b5d93f10d9a8a0 url: "https://pub.dev" source: hosted - version: "0.0.2" + version: "0.0.4" package_config: dependency: transitive description: @@ -688,10 +688,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: @@ -781,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" diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index 459df4e9..aef0914f 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/fota/bloc/update_bloc.dart b/lib/src/fota/bloc/update_bloc.dart index 3bf94c59..496d190a 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 6c705123..fc74e1b9 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/lib/src/managers/ble_manager.dart b/lib/src/managers/ble_manager.dart index ca81d66b..d49f32c7 100644 --- a/lib/src/managers/ble_manager.dart +++ b/lib/src/managers/ble_manager.dart @@ -33,6 +33,8 @@ class BleManager extends BleGattManager { final Map _disconnectCallbacks = {}; final List _connectedDevicesIds = []; + StreamSubscription? _availabilitySubscription; + final Map> _adapterShutdowns = {}; bool _firstScan = true; @@ -61,6 +63,25 @@ 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); + // 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); + }); + } + } + }); + UniversalBle.onConnectionChange = ( String deviceId, bool isConnected, @@ -72,10 +93,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 +116,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; @@ -219,7 +243,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}"); @@ -232,8 +257,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 +273,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); + } } }; @@ -264,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); @@ -458,6 +498,7 @@ class BleManager extends BleGattManager { /// Cancel connection state subscription void dispose() { + _availabilitySubscription?.cancel(); UniversalBle.onConnectionChange = ( String deviceId, bool isConnected, 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 00000000..6ddc77f0 --- /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/battery_gatt_reader/battery_health_status_gatt_reader.dart b/lib/src/models/devices/battery_gatt_reader/battery_health_status_gatt_reader.dart index 83ac4424..928ee491 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 0e31fa1a..bf194864 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/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index a1d82b0c..cd2614c4 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,12 +32,15 @@ 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'; 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 +63,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 +88,25 @@ 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); + } else if (_v2Regex.hasMatch(hardwareVersion)) { + final firmwareVersion = + await _readVersion(device, _deviceFirmwareVersionCharacteristicUuid); + (List, List) sensorInfo = await _initSensors( + device, + firmwareVersion, + ); final wearable = OpenEarableV2( name: device.name, disconnectNotifier: disconnectNotifier!, @@ -110,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, @@ -172,13 +195,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 = @@ -187,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, @@ -202,6 +236,7 @@ class OpenEarableFactory extends WearableFactory { Future<(List, List)> _initSensors( DiscoveredDevice device, + String firmwareVersion, ) async { List sensors = []; List sensorConfigurations = []; @@ -212,7 +247,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 ad3a2505..eaa0de53 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/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 00000000..f6da837a --- /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/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart b/lib/src/utils/sensor_value_parser/v2_sensor_value_parser.dart index 4a9b2d0c..1b5479da 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,34 @@ 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, 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(ByteData data, List sensorSchemes) { + List> parse( + ByteData data, + List sensorSchemes, + ) { int i = 0; // Header @@ -28,6 +49,13 @@ class V2SensorValueParser extends SensorValueParser { final baseTimestamp = _readUint64(data, i); i += 8; + 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(); final payloadSizePerSample = compSizes.fold(0, (a, b) => a + b); @@ -36,11 +64,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 +87,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 +132,117 @@ 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, + 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 +327,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 728afd22..92185456 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.2 +version: 2.5.0 repository: https://github.com/OpenEarable/open_earable_flutter/tree/main platforms: @@ -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 @@ -39,7 +39,7 @@ dependencies: bloc: ^9.1.0 meta: ^1.16.0 pub_semver: ^2.2.0 - open_earable_protocols: ^0.0.2 + open_earable_protocols: ^0.0.4 dev_dependencies: flutter_test: diff --git a/test/battery_gatt_reader_test.dart b/test/battery_gatt_reader_test.dart new file mode 100644 index 00000000..bfb47179 --- /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 {} +} diff --git a/test/ble_manager_power_cycle_test.dart b/test/ble_manager_power_cycle_test.dart new file mode 100644 index 00000000..8ebc8a36 --- /dev/null +++ b/test/ble_manager_power_cycle_test.dart @@ -0,0 +1,223 @@ +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']); + expect(platform.disconnects, ['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('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 { + 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; + 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 { + 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 { + 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 {} +} diff --git a/test/compact_imu_test.dart b/test/compact_imu_test.dart new file mode 100644 index 00000000..ff788a84 --- /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); + }); +} diff --git a/test/compact_ppg_test.dart b/test/compact_ppg_test.dart new file mode 100644 index 00000000..317a688e --- /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]), + ); + }); +} diff --git a/test/factory_transport_version_test.dart b/test/factory_transport_version_test.dart new file mode 100644 index 00000000..5a414397 --- /dev/null +++ b/test/factory_transport_version_test.dart @@ -0,0 +1,139 @@ +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)); + 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; + 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); + } + }); +} diff --git a/test/fota_preparation_failure_test.dart b/test/fota_preparation_failure_test.dart new file mode 100644 index 00000000..1396d132 --- /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 00000000..027749c8 --- /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); + }); + } +} diff --git a/test/led_state_reader_test.dart b/test/led_state_reader_test.dart new file mode 100644 index 00000000..dbd020a3 --- /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); + }); +}