diff --git a/CHANGELOG.md b/CHANGELOG.md index bb04eec3..d92911fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ - 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.2 + +* fixed firmware update version comparisons for development firmware labels, prereleases, build metadata, and release tag prefixes. +* fixed malformed firmware version labels causing errors or suggesting incorrect updates. + ## 2.4.1 * fixed microphone gain capability detection so it is only registered when the device exposes the required characteristic. diff --git a/lib/src/fota/repository/firmware_image_repository.dart b/lib/src/fota/repository/firmware_image_repository.dart index 5bf6de56..a4f31d66 100644 --- a/lib/src/fota/repository/firmware_image_repository.dart +++ b/lib/src/fota/repository/firmware_image_repository.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:pub_semver/pub_semver.dart'; import '../model/firmware_update_request.dart'; @@ -87,17 +88,29 @@ class FirmwareImageRepository { return (latestRelease['tag_name'] as String).replaceFirst('v', ''); } - /// Compares semantic version strings and returns `true` when [latest] is - /// newer than [current]. + /// Returns whether [latest] is newer, ignoring build metadata. + /// Unrecognized version labels do not trigger an update recommendation. bool isNewerVersion(String latest, String current) { - List parse(String v) => v.split('.').map(int.parse).toList(); - final latestParts = parse(latest); - final currentParts = parse(current); + Version parse(String label) { + // Older firmware included the C string terminator in its GATT value. + final normalized = label + .trim() + .replaceFirst(RegExp(r'\x00+$'), '') + .trim() + .replaceFirst(RegExp(r'^[vV]'), ''); + final version = Version.parse(normalized); + return Version( + version.major, + version.minor, + version.patch, + pre: version.preRelease.join('.'), + ); + } - for (int i = 0; i < latestParts.length; i++) { - if (latestParts[i] > currentParts[i]) return true; - if (latestParts[i] < currentParts[i]) return false; + try { + return parse(latest) > parse(current); + } on FormatException { + return false; } - return false; } } 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/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/firmware_version_comparison_test.dart b/test/firmware_version_comparison_test.dart new file mode 100644 index 00000000..4a27082d --- /dev/null +++ b/test/firmware_version_comparison_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/src/fota/repository/firmware_image_repository.dart'; + +void main() { + final repository = FirmwareImageRepository(); + + test('compares stable release components numerically', () { + expect(repository.isNewerVersion('2.2.10', '2.2.9'), isTrue); + expect(repository.isNewerVersion('2.3.0', '2.2.10'), isTrue); + expect(repository.isNewerVersion('2.2.9', '2.2.9'), isFalse); + expect(repository.isNewerVersion('2.2.8', '2.2.9'), isFalse); + }); + + test('accepts the development label observed on the earable', () { + const current = '2.2.9-dev.100+g94986934.dirty'; + expect(repository.isNewerVersion('2.2.8', current), isFalse); + expect(repository.isNewerVersion('2.2.9', current), isTrue); + expect(repository.isNewerVersion('2.2.10', current), isTrue); + }); + + test('orders prereleases numerically and below the stable release', () { + expect(repository.isNewerVersion('2.2.9-dev.10', '2.2.9-dev.9'), isTrue); + expect(repository.isNewerVersion('2.2.9-pr292', '2.2.9'), isFalse); + expect(repository.isNewerVersion('2.2.9', '2.2.9-pr292'), isTrue); + }); + + test('ignores hashes and other build metadata', () { + expect(repository.isNewerVersion('2.2.9+zzz', '2.2.9+aaa'), isFalse); + expect( + repository.isNewerVersion('2.2.9-dev.1+gabc', '2.2.9-dev.1+gdef.dirty'), + isFalse, + ); + }); + + test('accepts release tag prefixes and legacy trailing NUL bytes', () { + expect(repository.isNewerVersion('v2.2.9', '2.2.8\u0000'), isTrue); + expect(repository.isNewerVersion(' V2.2.9 ', '2.2.9\u0000'), isFalse); + }); + + test('malformed or incomplete versions do not throw or suggest updates', () { + for (final label in ['', 'unknown', '2', '2.2.x', 'garbage2.2.9']) { + expect(repository.isNewerVersion('2.2.9', label), isFalse); + expect(repository.isNewerVersion(label, '2.2.9'), isFalse); + } + }); +}