Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 22 additions & 9 deletions lib/src/fota/repository/firmware_image_repository.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<int> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> 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(
Expand Down
83 changes: 83 additions & 0 deletions test/battery_gatt_reader_test.dart
Original file line number Diff line number Diff line change
@@ -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<int> bytes;
@override
Future<List<int>> 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<void> disconnect() async {}
}
46 changes: 46 additions & 0 deletions test/firmware_version_comparison_test.dart
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
Loading