Skip to content
Merged

2.5.0 #149

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
72dd491
chore: update version to 2.5.0 in pubspec.yaml
DennisMoschina Sep 30, 2026
26fe27b
fix(battery): decode GATT status fields in little endian
TobiasRoeddiger Oct 3, 2026
f2a1670
fix(fota): finish failed preparation and early aborts
TobiasRoeddiger Oct 3, 2026
75ee28a
fix: clear BLE connections when the adapter powers off
TobiasRoeddiger Oct 3, 2026
fa73f09
style: satisfy regression test formatting
TobiasRoeddiger Oct 3, 2026
7c44204
fix: release native GATT handles before reconnecting
TobiasRoeddiger Oct 3, 2026
34f4587
fix: complete native shutdown cleanup without chaining itself
TobiasRoeddiger Oct 3, 2026
199f245
feat(ppg): decode firmware 2.3 samples while retaining legacy support
TobiasRoeddiger Oct 4, 2026
bb202f8
build: declare versioned protocol dependency with review overrides
TobiasRoeddiger Oct 4, 2026
358f790
Restore compact IMU BLE samples to existing float values
TobiasRoeddiger Oct 4, 2026
a171dae
Merge pull request #155 from OpenEarable/codex/compact-imu-ble
TobiasRoeddiger Oct 4, 2026
c93ac77
Merge pull request #154 from OpenEarable/codex/compact-ppg-ble
TobiasRoeddiger Oct 4, 2026
a9f6b04
Integrate battery decoding into the 2.5.0 release candidate
TobiasRoeddiger Oct 4, 2026
dd2149c
Integrate FOTA preparation fixes into the 2.5.0 candidate
TobiasRoeddiger Oct 4, 2026
9d08da1
Integrate Bluetooth recovery into the 2.5.0 candidate
TobiasRoeddiger Oct 4, 2026
6b32863
Merge pull request #151 from OpenEarable/codex/battery-status-decoding
TobiasRoeddiger Oct 4, 2026
ee4ab39
Merge pull request #152 from OpenEarable/codex/fota-preparation-failures
TobiasRoeddiger Oct 4, 2026
9dd4398
Merge pull request #153 from OpenEarable/codex/bluetooth-power-cycle-…
TobiasRoeddiger Oct 4, 2026
07456ab
Select BLE sample format from firmware instead of hardware version
TobiasRoeddiger Oct 4, 2026
70063b6
Merge pull request #156 from OpenEarable/codex/select-ble-format-by-f…
TobiasRoeddiger Oct 4, 2026
6b9f4d4
Read saved LED state on firmware 2.3.0
TobiasRoeddiger Oct 5, 2026
582d609
Merge pull request #157 from OpenEarable/codex/led-state-readback
TobiasRoeddiger Oct 5, 2026
6c76ca1
Release Android BLE clients when the Flutter engine closes
TobiasRoeddiger Oct 5, 2026
3b56e89
Merge pull request #158 from OpenEarable/codex/android-engine-ble-cle…
TobiasRoeddiger Oct 5, 2026
aa1b493
chore(release): prepare library 2.5.0 package publication
TobiasRoeddiger Oct 6, 2026
149fbc9
chore(release): lock published protocol dependency
TobiasRoeddiger Oct 6, 2026
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
12 changes: 6 additions & 6 deletions example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
1 change: 1 addition & 0 deletions lib/open_earable_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
49 changes: 36 additions & 13 deletions lib/src/fota/bloc/update_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,28 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> {
_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(
Expand Down Expand Up @@ -104,14 +122,17 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> {
);
});
on<DownloadStarted>((event, emit) {
if (_abortRequested || (_state?.isComplete ?? false)) return;
_state = _updatedState(UpdateFirmware('Download firmware'));
emit(_state!);
});
on<UnpackStarted>((event, emit) {
if (_abortRequested || (_state?.isComplete ?? false)) return;
_state = _updatedState(UpdateFirmware('Unpack firmware'));
emit(_state!);
});
on<UploadState>((event, emit) {
if (_abortRequested || (_state?.isComplete ?? false)) return;
if (event is UploadProgress) {
_state = _updatedState(
UpdateProgressFirmware(
Expand All @@ -127,6 +148,7 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> {
}
});
on<UploadFinished>((event, emit) {
if (_abortRequested || (_state?.isComplete ?? false)) return;
_state = _updatedState(
UpdateCompleteSuccess(),
updateManager: _firmwareUpdateManager,
Expand Down Expand Up @@ -165,6 +187,16 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> {
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,
Expand All @@ -191,15 +223,6 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> {
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,
Expand Down
73 changes: 37 additions & 36 deletions lib/src/fota/handlers/firmware_update_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic> manifestJson = json.decode(manifestString);
Manifest manifest;
// read manifest.json
final manifestFile = File('${destinationDir.path}/manifest.json');
final manifestString = await manifestFile.readAsString();
Map<String, dynamic> 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);
}
}
Expand Down
65 changes: 53 additions & 12 deletions lib/src/managers/ble_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class BleManager extends BleGattManager {
final Map<String, VoidCallback> _disconnectCallbacks = {};

final List<String> _connectedDevicesIds = [];
StreamSubscription<AvailabilityState>? _availabilitySubscription;
final Map<String, Future<void>> _adapterShutdowns = {};

bool _firstScan = true;

Expand Down Expand Up @@ -61,6 +63,25 @@ class BleManager extends BleGattManager {
void _init() {
_scanStreamController = StreamController<DiscoveredDevice>.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,
Expand All @@ -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);
}
};

Expand All @@ -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<bool> checkAndRequestPermissions() async {
bool permGranted = false;

Expand Down Expand Up @@ -219,7 +243,8 @@ class BleManager extends BleGattManager {
Future<(bool, List<BleService>)> 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}");
Expand All @@ -232,8 +257,11 @@ class BleManager extends BleGattManager {

final completer = Completer<(bool, List<BleService>)>();
_connectionCompleters[device.id] = completer;
final connectionFuture = completer.future.whenComplete(() {
_connectionFutures.remove(device.id);
late final Future<(bool, List<BleService>)> connectionFuture;
connectionFuture = completer.future.whenComplete(() {
if (identical(_connectionFutures[device.id], connectionFuture)) {
_connectionFutures.remove(device.id);
}
});
_connectionFutures[device.id] = connectionFuture;

Expand All @@ -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);
}
}
};

Expand All @@ -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);
Expand Down Expand Up @@ -458,6 +498,7 @@ class BleManager extends BleGattManager {

/// Cancel connection state subscription
void dispose() {
_availabilitySubscription?.cancel();
UniversalBle.onConnectionChange = (
String deviceId,
bool isConnected,
Expand Down
20 changes: 20 additions & 0 deletions lib/src/models/capabilities/led_state_reader.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/// Optional readback of the LED override selected on a device.
abstract class LedStateReader {
Future<LedState> 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;
}
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
Loading
Loading