From badc66756425ce6e0622631fa58de3eeae8858b5 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Sun, 27 Sep 2026 12:29:36 +0200 Subject: [PATCH 1/3] Handle development firmware labels in update comparisons --- .../repository/firmware_image_repository.dart | 31 +++++++++---- test/firmware_version_comparison_test.dart | 46 +++++++++++++++++++ 2 files changed, 68 insertions(+), 9 deletions(-) create mode 100644 test/firmware_version_comparison_test.dart 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/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); + } + }); +} From 04ea68bd143c0caf0cc751d2df9abc99b242f99d Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 30 Sep 2026 18:38:12 +0200 Subject: [PATCH 2/3] chore(release): prepare 2.4.2 --- CHANGELOG.md | 5 +++++ example/pubspec.lock | 2 +- pubspec.yaml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b33c9fd..2c436c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 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/example/pubspec.lock b/example/pubspec.lock index 4539e62f..1b6d29d8 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -414,7 +414,7 @@ packages: path: ".." relative: true source: path - version: "2.4.1" + version: "2.4.2" open_earable_protocols: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b81f4058..728afd22 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.4.2 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 3/3] 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 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 {} +}