From 9a59112b8e4e0e9c46ec8b36b9e3f69057dd90e1 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:17:58 +0200 Subject: [PATCH 1/9] feat(doc): added high level documentation for FOTA --- README.md | 3 + doc/FOTA.md | 333 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 doc/FOTA.md diff --git a/README.md b/README.md index 058e5714..345e2017 100644 --- a/README.md +++ b/README.md @@ -116,3 +116,6 @@ To get started with the OpenEarable Flutter package, follow these steps: ## Add custom Wearable Support Learn more about how to add support for your own wearable devices in the [Adding Custom Wearable Support](https://github.com/OpenEarable/open_earable_flutter/blob/main/doc/ADD_CUSTOM_WEARABLE.md) documentation. + +## Firmware Updates +Learn more about firmware-over-the-air updates in the [FOTA documentation](https://github.com/OpenEarable/open_earable_flutter/blob/main/doc/FOTA.md). diff --git a/doc/FOTA.md b/doc/FOTA.md new file mode 100644 index 00000000..4fe8739d --- /dev/null +++ b/doc/FOTA.md @@ -0,0 +1,333 @@ +# Firmware Updates In Your App + +This guide is written for app developers using `open_earable_flutter`. It explains how to list firmware versions, let a user select a device and firmware file, start an update, and render progress in your own UI. + +## What The Library Provides + +The FOTA API gives you the building blocks for a firmware update flow: + +- `FirmwareImageRepository` to load stable firmware releases from GitHub +- `UnifiedFirmwareRepository` to load stable releases and optional beta builds +- `RemoteFirmware` and `LocalFirmware` to represent selectable firmware files +- `SelectedPeripheral` to identify the target device +- `SingleImageFirmwareUpdateRequest` and `MultiImageFirmwareUpdateRequest` to describe the update job +- `UpdateBloc` to execute the update and emit UI-friendly progress states + +## Before You Start + +Make sure your app can already: + +1. Discover and connect to a wearable with `WearableManager` +2. Hold on to the connected `Wearable` +3. Ask the user which firmware they want to install + +If you have not connected to a device yet, start there first. A firmware update needs a connected `Wearable` so you can pass its device identifier into `SelectedPeripheral`. + +## Step 1: Get The Target Device + +Once your app has a connected wearable, convert it into a `SelectedPeripheral`: + +```dart +final SelectedPeripheral selectedPeripheral = SelectedPeripheral( + name: wearable.name, + identifier: wearable.deviceId, +); +``` + +The `identifier` is what the underlying update manager uses to talk to the device. + +## Step 2: Offer Firmware Choices + +You can let the user choose firmware from a remote repository, from a local file, or both. + +### Option A: Show Stable Releases + +Use `FirmwareImageRepository` if you only want official releases: + +```dart +final repository = FirmwareImageRepository(); +final List firmwares = await repository.getFirmwareImages(); +``` + +Each `RemoteFirmware` contains: + +- `name`: a UI-friendly label +- `version`: the release version +- `url`: the download URL +- `type`: `FirmwareType.singleImage` or `FirmwareType.multiImage` + +You can use that list in any widget: + +```dart +ListView.builder( + itemCount: firmwares.length, + itemBuilder: (context, index) { + final firmware = firmwares[index]; + return ListTile( + title: Text(firmware.name), + subtitle: Text(firmware.version), + onTap: () { + // store the selected firmware in your state + }, + ); + }, +); +``` + +### Option B: Include Beta Builds + +If your app should optionally expose preview firmware: + +```dart +final repository = UnifiedFirmwareRepository(); +final List entries = await repository.getAllFirmwares( + includeBeta: true, +); +``` + +`FirmwareEntry` tells you whether an item is stable or beta: + +```dart +for (final entry in entries) { + final firmware = entry.firmware; + final sourceLabel = entry.isBeta ? 'Beta' : 'Stable'; + print('${firmware.name} [$sourceLabel]'); +} +``` + +### Option C: Let The User Pick A Local File + +If the user already has a firmware file, create a `LocalFirmware` object from the file bytes. The important part is setting the correct `FirmwareType`. + +```dart +final bytes = await file.readAsBytes(); + +final localFirmware = LocalFirmware( + name: 'my_firmware.zip', + data: bytes, + type: FirmwareType.multiImage, +); +``` + +Use: + +- `FirmwareType.singleImage` for raw single-image files such as `.bin` +- `FirmwareType.multiImage` for archive-based FOTA bundles such as `.zip` + +## Step 3: Build The Update Request + +The request type depends on the selected firmware format. + +### Remote Or Local Multi-Image Firmware + +Use `MultiImageFirmwareUpdateRequest` for `.zip`-based updates: + +```dart +final request = MultiImageFirmwareUpdateRequest( + peripheral: selectedPeripheral, + firmware: selectedFirmware, +); +``` + +`selectedFirmware` can be either: + +- a `RemoteFirmware` with `type == FirmwareType.multiImage` +- a `LocalFirmware` with `type == FirmwareType.multiImage` + +### Local Single-Image Firmware + +Use `SingleImageFirmwareUpdateRequest` for local `.bin` updates: + +```dart +final request = SingleImageFirmwareUpdateRequest( + peripheral: selectedPeripheral, + firmware: localFirmware, +); +``` + +## Step 4: Start The Update With `UpdateBloc` + +Create the bloc with the prepared request and dispatch `BeginUpdateProcess`. + +```dart +final updateBloc = UpdateBloc( + firmwareUpdateRequest: request, +); + +updateBloc.add(BeginUpdateProcess()); +``` + +In a Flutter screen this is usually done with `BlocProvider`: + +```dart +BlocProvider( + create: (_) => UpdateBloc(firmwareUpdateRequest: request), + child: const FirmwareUpdateScreen(), +) +``` + +## Step 5: Render Update Progress + +`UpdateBloc` emits `UpdateState` objects you can map directly to your UI. + +The most important states are: + +- `UpdateInitial`: nothing has started yet +- `UpdateFirmwareStateHistory`: the update is running or has completed +- `UpdateCompleteSuccess`: appears inside the history as the successful end state +- `UpdateCompleteFailure`: appears inside the history as the failed end state + +The simplest integration pattern is: + +```dart +BlocBuilder( + builder: (context, state) { + switch (state) { + case UpdateInitial(): + return ElevatedButton( + onPressed: () { + context.read().add(BeginUpdateProcess()); + }, + child: const Text('Start update'), + ); + + case UpdateFirmwareStateHistory(): + if (state.currentState is UpdateProgressFirmware) { + final progressState = state.currentState as UpdateProgressFirmware; + return Text('Uploading ${progressState.progress}%'); + } + + if (state.isComplete) { + final lastState = state.history.isNotEmpty ? state.history.last : null; + if (lastState is UpdateCompleteFailure) { + return Text('Update failed: ${lastState.error}'); + } + return const Text('Update completed'); + } + + return Text(state.currentState?.stage ?? 'Preparing update'); + + default: + return const Text('Unknown update state'); + } + }, +) +``` + +## What Happens Internally + +You do not need to call the lower-level handler classes directly, but it helps to know what `UpdateBloc` is doing: + +1. `FirmwareDownloader` downloads remote firmware files +2. `FirmwareUnpacker` extracts `.zip` bundles and reads `manifest.json` +3. `FirmwareUpdater` sends the prepared image data to the device + +This means: + +- remote firmware files are downloaded automatically +- multi-image archives are unpacked automatically +- local firmware files skip the download step + +## Complete Example + +This is the minimal end-to-end shape of a typical integration: + +```dart +final repository = FirmwareImageRepository(); +final firmwares = await repository.getFirmwareImages(); + +final selectedFirmware = firmwares.first; + +final request = MultiImageFirmwareUpdateRequest( + peripheral: SelectedPeripheral( + name: wearable.name, + identifier: wearable.deviceId, + ), + firmware: selectedFirmware, +); + +BlocProvider( + create: (_) => UpdateBloc(firmwareUpdateRequest: request), + child: BlocBuilder( + builder: (context, state) { + if (state is UpdateInitial) { + return ElevatedButton( + onPressed: () { + context.read().add(BeginUpdateProcess()); + }, + child: const Text('Install firmware'), + ); + } + + if (state is UpdateFirmwareStateHistory) { + final current = state.currentState; + if (current is UpdateProgressFirmware) { + return Text('Uploading ${current.progress}%'); + } + + if (state.isComplete) { + final last = state.history.isNotEmpty ? state.history.last : null; + if (last is UpdateCompleteFailure) { + return Text('Update failed: ${last.error}'); + } + return const Text('Update complete'); + } + + return Text(current?.stage ?? 'Working...'); + } + + return const SizedBox.shrink(); + }, + ), +); +``` + +## Optional Helper For Multi-Step UIs + +The library also exposes `FirmwareUpdateRequestProvider`. It is a convenience helper used by the example app to collect: + +- the selected firmware +- the selected wearable +- the current step in a stepper-style UI + +You can use it if it matches your UI, but it is not required. Many apps will prefer their own state management and only use: + +- the repository classes +- the request models +- `UpdateBloc` + +## Error Handling And User Guidance + +Your UI should be prepared for these cases: + +- no firmware selected +- no device selected +- network failure while loading remote firmware +- network failure while downloading a remote firmware file +- invalid or unsupported archive contents +- upload failure reported by the device or transport layer + +Recommended UX: + +1. Disable the update button until both firmware and device are selected +2. Show a clear loading state while releases are being fetched +3. Show the current stage text from `UpdateFirmwareStateHistory.currentState` +4. Show the failure message from `UpdateCompleteFailure.error` +5. Allow the user to retry after a failure + +## Notes + +- Multi-image `.zip` updates are expected to contain a valid `manifest.json` +- If a manifest contains multiple images, each file entry must define an image index +- `UnifiedFirmwareRepository` caches results for 15 minutes unless you request a refresh +- The current upload path uses `mcumgr_flutter` under the hood + +## Related Source Files + +If you want to inspect the implementation behind the public APIs, these are the main files: + +- `lib/src/fota/model/firmware_update_request.dart` +- `lib/src/fota/repository/firmware_image_repository.dart` +- `lib/src/fota/repository/unified_firmware_image_repository.dart` +- `lib/src/fota/bloc/update_bloc.dart` +- `lib/src/fota/providers/firmware_update_request_provider.dart` From 45945be60491404f9e05213f6412daa9e6989b0d Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:22:06 +0200 Subject: [PATCH 2/9] feat(fota): added doc strings to classes and methods --- lib/src/fota/bloc/update_bloc.dart | 5 +++ lib/src/fota/bloc/update_event.dart | 11 +++++ lib/src/fota/bloc/update_state.dart | 7 ++++ lib/src/fota/fota.dart | 10 +++++ .../handlers/firmware_update_handler.dart | 13 ++++++ .../fota/handlers/firmware_update_state.dart | 7 ++++ lib/src/fota/model/firmware_image.dart | 9 ++++ .../fota/model/firmware_update_request.dart | 41 +++++++++++++++++++ lib/src/fota/model/manifest.dart | 5 +++ .../firmware_update_request_provider.dart | 14 +++++++ .../repository/beta_image_repository.dart | 6 +++ .../repository/firmware_image_repository.dart | 9 ++++ .../unified_firmware_image_repository.dart | 14 +++++++ 13 files changed, 151 insertions(+) diff --git a/lib/src/fota/bloc/update_bloc.dart b/lib/src/fota/bloc/update_bloc.dart index 6684a144..e9797543 100644 --- a/lib/src/fota/bloc/update_bloc.dart +++ b/lib/src/fota/bloc/update_bloc.dart @@ -10,11 +10,15 @@ import 'package:tuple/tuple.dart'; part 'update_event.dart'; part 'update_state.dart'; +/// Coordinates the end-to-end firmware update flow and exposes UI-friendly +/// progress states. class UpdateBloc extends Bloc { + /// The request currently being processed by the bloc. final FirmwareUpdateRequest firmwareUpdateRequest; UpdateFirmwareStateHistory? _state; FirmwareUpdateManager? _firmwareUpdateManager; + /// Creates a bloc for a single update request. UpdateBloc({required this.firmwareUpdateRequest}) : super(UpdateInitial()) { on((event, emit) async { emit(UpdateFirmware('Begin update process')); @@ -190,6 +194,7 @@ class UpdateBloc extends Bloc { } } +/// Converts handler-level states into bloc events. class _StateConverter { static UpdateEvent convert(FirmwareUpdateState state) { return switch (state) { diff --git a/lib/src/fota/bloc/update_event.dart b/lib/src/fota/bloc/update_event.dart index 21b0136a..73df0a64 100644 --- a/lib/src/fota/bloc/update_event.dart +++ b/lib/src/fota/bloc/update_event.dart @@ -1,20 +1,26 @@ part of 'update_bloc.dart'; +/// Base event processed by [UpdateBloc]. @immutable sealed class UpdateEvent {} +/// Starts the firmware update flow for the configured request. class BeginUpdateProcess extends UpdateEvent {} +/// Signals that the download step has started. class DownloadStarted extends UpdateEvent {} +/// Signals that archive unpacking has started. class UnpackStarted extends UpdateEvent {} +/// Generic upload-stage event carrying a human-readable state label. class UploadState extends UpdateEvent { final String state; UploadState(this.state); } +/// Upload state that also reports progress for the current image. class UploadProgress extends UploadState { final int progress; final int imageNumber; @@ -26,24 +32,29 @@ class UploadProgress extends UploadState { }) : super(stage); } +/// Signals that the upload completed successfully. class UploadFinished extends UpdateEvent {} +/// Signals that the update failed with [error]. class UploadFailed extends UpdateEvent { final String error; UploadFailed(this.error); } +/// Records a firmware selection in event-driven integrations. class FirmwareSelected extends UpdateEvent { final SelectedFirmware firmware; FirmwareSelected(this.firmware); } +/// Records a peripheral selection in event-driven integrations. class PeripheralSelected extends UpdateEvent { final SelectedPeripheral peripheral; PeripheralSelected(this.peripheral); } +/// Stops the active update manager and resets any in-flight upload. class ResetUpdate extends UpdateEvent {} diff --git a/lib/src/fota/bloc/update_state.dart b/lib/src/fota/bloc/update_state.dart index b3b74704..138b1c0c 100644 --- a/lib/src/fota/bloc/update_state.dart +++ b/lib/src/fota/bloc/update_state.dart @@ -1,13 +1,16 @@ part of 'update_bloc.dart'; +/// Base state emitted by [UpdateBloc]. @immutable sealed class UpdateState extends Equatable {} +/// Initial state before an update has started. final class UpdateInitial extends UpdateState { @override List get props => [true]; } +/// High-level firmware update stage description. class UpdateFirmware extends UpdateState { final String stage; @@ -17,6 +20,7 @@ class UpdateFirmware extends UpdateState { List get props => [stage]; } +/// Upload stage with progress information for the active image. final class UpdateProgressFirmware extends UpdateFirmware { final int progress; final int imageNumber; @@ -27,10 +31,12 @@ final class UpdateProgressFirmware extends UpdateFirmware { List get props => [stage, progress]; } +/// Successful completion marker for the update flow. final class UpdateCompleteSuccess extends UpdateFirmware { UpdateCompleteSuccess() : super("Update complete"); } +/// Failure marker for the update flow. final class UpdateCompleteFailure extends UpdateFirmware { final String error; @@ -40,6 +46,7 @@ final class UpdateCompleteFailure extends UpdateFirmware { List get props => [stage, error]; } +/// Snapshot of the current stage plus the completed stage history. class UpdateFirmwareStateHistory extends UpdateState { final UpdateFirmware? currentState; final List history; diff --git a/lib/src/fota/fota.dart b/lib/src/fota/fota.dart index 194dac1b..76536741 100644 --- a/lib/src/fota/fota.dart +++ b/lib/src/fota/fota.dart @@ -1,3 +1,13 @@ +/// Firmware-over-the-air (FOTA) support for discovering firmware images and +/// coordinating firmware update requests. +/// +/// The exported APIs cover three main concerns: +/// - building a [FirmwareUpdateRequest] that identifies the target peripheral +/// and the selected firmware image, +/// - querying repositories for stable or beta firmware artifacts, and +/// - observing update progress through [UpdateBloc]. +library; + export 'bloc/update_bloc.dart'; export 'model/firmware_update_request.dart'; export 'model/firmware_image.dart'; diff --git a/lib/src/fota/handlers/firmware_update_handler.dart b/lib/src/fota/handlers/firmware_update_handler.dart index 45a45726..9e7ec1fa 100644 --- a/lib/src/fota/handlers/firmware_update_handler.dart +++ b/lib/src/fota/handlers/firmware_update_handler.dart @@ -14,20 +14,31 @@ import 'package:mcumgr_flutter/mcumgr_flutter.dart'; part 'firmware_update_state.dart'; +/// Callback used to surface intermediate pipeline states while the request is +/// being prepared and uploaded. typedef FirmwareUpdateCallback = void Function(FirmwareUpdateState state); +/// Base link in the firmware update preparation pipeline. +/// +/// Concrete handlers implement one step of the process and forward the request +/// to the next handler once their work is complete. abstract class FirmwareUpdateHandler { FirmwareUpdateHandler? _nextHandler; + + /// Processes [request] and eventually returns the active firmware update + /// manager. Future handleFirmwareUpdate( FirmwareUpdateRequest request, FirmwareUpdateCallback? callback, ); + /// Configures the next handler in the chain. Future setNextHandler(FirmwareUpdateHandler handler) async { _nextHandler = handler; } } +/// Downloads remote firmware archives before forwarding the request. class FirmwareDownloader extends FirmwareUpdateHandler { @override Future handleFirmwareUpdate( @@ -63,6 +74,7 @@ class FirmwareDownloader extends FirmwareUpdateHandler { } } +/// Extracts multi-image archives and parses their `manifest.json`. class FirmwareUnpacker extends FirmwareUpdateHandler { @override Future handleFirmwareUpdate( @@ -131,6 +143,7 @@ class FirmwareUnpacker extends FirmwareUpdateHandler { } } +/// Uploads the prepared firmware image data through `mcumgr_flutter`. class FirmwareUpdater extends FirmwareUpdateHandler { final UpdateManagerFactory _updateManagerFactory = FirmwareUpdateManagerFactory(); diff --git a/lib/src/fota/handlers/firmware_update_state.dart b/lib/src/fota/handlers/firmware_update_state.dart index e0fd0d2b..e761d0f5 100644 --- a/lib/src/fota/handlers/firmware_update_state.dart +++ b/lib/src/fota/handlers/firmware_update_state.dart @@ -1,19 +1,26 @@ part of 'firmware_update_handler.dart'; +/// Base state emitted by the firmware update handler chain. sealed class FirmwareUpdateState {} +/// Emitted when a remote firmware artifact starts downloading. class FirmwareDownloadStarted extends FirmwareUpdateState {} // class FirmwareDownloadFinished extends FirmwareUpdateState {} +/// Emitted when a multi-image archive starts unpacking. class FirmwareUnpackStarted extends FirmwareUpdateState {} // class FirmwareUnpackFinished extends FirmwareUpdateState {} +/// Emitted when the upload step begins. class FirmwareUploadStarted extends FirmwareUpdateState {} +/// Upload progress emitted by handler implementations that report byte-level +/// progress directly. class FirmwareUploadProgress extends FirmwareUpdateState { final int progress; FirmwareUploadProgress(this.progress); } +/// Emitted when the upload step finishes successfully. class FirmwareUploadFinished extends FirmwareUpdateState {} diff --git a/lib/src/fota/model/firmware_image.dart b/lib/src/fota/model/firmware_image.dart index 05fb1a35..d5ce90ac 100644 --- a/lib/src/fota/model/firmware_image.dart +++ b/lib/src/fota/model/firmware_image.dart @@ -2,6 +2,8 @@ import 'package:json_annotation/json_annotation.dart'; part 'firmware_image.g.dart'; +/// Top-level response describing firmware-capable applications and their +/// released versions. @JsonSerializable(fieldRename: FieldRename.snake) class ApplicationResponse { final int version; @@ -16,6 +18,7 @@ class ApplicationResponse { _$ApplicationResponseFromJson(json); } +/// Application metadata for firmware-distributed builds. @JsonSerializable(fieldRename: FieldRename.snake) class Application { String appId; @@ -40,6 +43,7 @@ class Application { _$ApplicationFromJson(json); } +/// Versioned release metadata for an application firmware package. @JsonSerializable(fieldRename: FieldRename.snake) class Version { bool requiresBonding; @@ -60,6 +64,7 @@ class Version { _$VersionFromJson(json); } +/// Supported hardware board for a firmware version. @JsonSerializable(fieldRename: FieldRename.snake) class Board { String name; @@ -73,6 +78,7 @@ class Board { factory Board.fromJson(Map json) => _$BoardFromJson(json); } +/// Build-specific variant of a firmware package for a board. @JsonSerializable(fieldRename: FieldRename.snake) class BuildConfig { String name; @@ -97,6 +103,7 @@ class BuildConfig { _$BuildConfigFromJson(json); } +/// Child-core configuration included in a build variant. @JsonSerializable(fieldRename: FieldRename.snake) class ChildCore { String name; @@ -111,6 +118,7 @@ class ChildCore { _$ChildCoreFromJson(json); } +/// Optional build-time switch exposed for a firmware build variant. @JsonSerializable(fieldRename: FieldRename.snake) class Option { String name; @@ -124,6 +132,7 @@ class Option { factory Option.fromJson(Map json) => _$OptionFromJson(json); } +/// Human-facing link associated with a firmware version. @JsonSerializable(fieldRename: FieldRename.snake) class Link { String text; diff --git a/lib/src/fota/model/firmware_update_request.dart b/lib/src/fota/model/firmware_update_request.dart index d4217695..2684daf3 100644 --- a/lib/src/fota/model/firmware_update_request.dart +++ b/lib/src/fota/model/firmware_update_request.dart @@ -2,8 +2,16 @@ import 'dart:typed_data'; import 'package:mcumgr_flutter/mcumgr_flutter.dart'; +/// Base request object used by the FOTA pipeline. +/// +/// It contains the selected firmware artifact and the target peripheral. The +/// concrete subclasses describe whether the update uses a single raw binary or +/// a multi-image archive. class FirmwareUpdateRequest { + /// The firmware artifact selected by the user. SelectedFirmware? firmware; + + /// The BLE peripheral that should receive the update. SelectedPeripheral? peripheral; FirmwareUpdateRequest({ @@ -12,7 +20,9 @@ class FirmwareUpdateRequest { }); } +/// Request for single-image updates backed by a local `.bin` payload. class SingleImageFirmwareUpdateRequest extends FirmwareUpdateRequest { + /// Returns the firmware bytes when the selected firmware is a local artifact. Uint8List? get firmwareImage => firmware is LocalFirmware ? (firmware as LocalFirmware).data : null; @@ -22,10 +32,19 @@ class SingleImageFirmwareUpdateRequest extends FirmwareUpdateRequest { }); } +/// Request for multi-image updates distributed as a `.zip` archive. +/// +/// The archive can originate from a remote release or from a local file. During +/// processing, the downloader stores the archive in [zipFile] and the unpacker +/// extracts MCUboot image payloads into [firmwareImages]. class MultiImageFirmwareUpdateRequest extends FirmwareUpdateRequest { + /// Raw bytes of the downloaded or user-provided firmware archive. Uint8List? zipFile; + + /// Parsed image payloads extracted from [zipFile]. List? firmwareImages; + /// Convenience accessor for remote firmware selections. RemoteFirmware? get remoteFirmware => firmware as RemoteFirmware?; MultiImageFirmwareUpdateRequest({ @@ -36,15 +55,24 @@ class MultiImageFirmwareUpdateRequest extends FirmwareUpdateRequest { }); } +/// Base type for firmware choices presented to the user. class SelectedFirmware { + /// Human-readable firmware name shown in selection UIs. String get name => toString(); } +/// Firmware artifact that has to be downloaded before updating. class RemoteFirmware extends SelectedFirmware { @override final String name; + + /// Semantic version or release label associated with the artifact. final String version; + + /// Direct download URL for the artifact. final String url; + + /// Package format of the artifact. final FirmwareType type; RemoteFirmware({ @@ -55,15 +83,24 @@ class RemoteFirmware extends SelectedFirmware { }); } +/// Supported firmware package formats. enum FirmwareType { + /// A single update image, typically a raw `.bin` file. singleImage, + + /// A multi-image archive, typically an MCUboot-compatible `.zip` bundle. multiImage, } +/// Firmware artifact that is already available on the local device. class LocalFirmware extends SelectedFirmware { @override final String name; + + /// Raw contents of the local firmware file. final Uint8List data; + + /// Package format of the local artifact. final FirmwareType type; LocalFirmware({ @@ -73,8 +110,12 @@ class LocalFirmware extends SelectedFirmware { }); } +/// Lightweight identifier for the peripheral that should receive the update. class SelectedPeripheral { + /// Display name shown to the user. final String name; + + /// Platform-specific peripheral identifier used by `mcumgr_flutter`. final String identifier; SelectedPeripheral({ diff --git a/lib/src/fota/model/manifest.dart b/lib/src/fota/model/manifest.dart index fea40517..c8989b42 100644 --- a/lib/src/fota/model/manifest.dart +++ b/lib/src/fota/model/manifest.dart @@ -2,6 +2,7 @@ import 'package:json_annotation/json_annotation.dart'; part 'manifest.g.dart'; +/// Parsed MCUboot manifest for a multi-image firmware archive. @JsonSerializable(fieldRename: FieldRename.snake) class Manifest { @JsonKey(name: 'format-version') @@ -9,6 +10,8 @@ class Manifest { int time; List files; + /// Parses a manifest and validates that multi-image bundles contain image + /// indices for every file entry. factory Manifest.fromJson(Map json) { final manifest = _$ManifestFromJson(json); @@ -30,6 +33,7 @@ class Manifest { }); } +/// Single file entry in a multi-image firmware manifest. @JsonSerializable(fieldRename: FieldRename.snake) class ManifestFile { String? type; @@ -47,6 +51,7 @@ class ManifestFile { String file; String? imageIndex; + /// Numeric image slot derived from [imageIndex]. int get image => int.parse(imageIndex ?? "0"); factory ManifestFile.fromJson(Map json) => diff --git a/lib/src/fota/providers/firmware_update_request_provider.dart b/lib/src/fota/providers/firmware_update_request_provider.dart index 46acafda..09424630 100644 --- a/lib/src/fota/providers/firmware_update_request_provider.dart +++ b/lib/src/fota/providers/firmware_update_request_provider.dart @@ -1,12 +1,22 @@ import 'package:flutter/material.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +/// Mutable helper used by the example flow to collect firmware update inputs +/// across multiple UI steps. class FirmwareUpdateRequestProvider extends ChangeNotifier { FirmwareUpdateRequest _updateParameters = FirmwareUpdateRequest(); + + /// The request currently being assembled by the UI. FirmwareUpdateRequest get updateParameters => _updateParameters; + + /// The currently selected wearable, if any. Wearable? selectedWearable; + + /// Current step index in the example update wizard. int currentStep = 0; + /// Selects a firmware artifact and rebuilds the request with the correct + /// concrete request type. void setFirmware(SelectedFirmware? firmware) { if (firmware == null) { _updateParameters = @@ -36,6 +46,7 @@ class FirmwareUpdateRequestProvider extends ChangeNotifier { notifyListeners(); } + /// Selects the wearable that should receive the update. void setSelectedPeripheral(Wearable wearable) { selectedWearable = wearable; _updateParameters.peripheral = SelectedPeripheral( @@ -45,12 +56,14 @@ class FirmwareUpdateRequestProvider extends ChangeNotifier { notifyListeners(); } + /// Clears the current request and resets the wizard state. void reset() { _updateParameters = FirmwareUpdateRequest(); currentStep = 0; notifyListeners(); } + /// Advances the example wizard by one step. void nextStep() { if (currentStep == 1) { return; @@ -59,6 +72,7 @@ class FirmwareUpdateRequestProvider extends ChangeNotifier { notifyListeners(); } + /// Moves the example wizard one step backwards. void previousStep() { if (currentStep == 0) { return; diff --git a/lib/src/fota/repository/beta_image_repository.dart b/lib/src/fota/repository/beta_image_repository.dart index fe785b9b..1096aa1e 100644 --- a/lib/src/fota/repository/beta_image_repository.dart +++ b/lib/src/fota/repository/beta_image_repository.dart @@ -3,11 +3,17 @@ import 'package:http/http.dart' as http; import '../model/firmware_update_request.dart'; +/// Repository for beta or preview firmware bundles published under the +/// dedicated prerelease tag. class BetaFirmwareImageRepository { static const _org = 'OpenEarable'; static const _repo = 'open-earable-2'; static const _prereleaseTag = 'pr-builds'; + /// Returns preview FOTA bundles generated from pull request builds. + /// + /// The repository expects assets to follow the + /// `pr---openearable_v2_fota.zip` naming convention. Future<List<RemoteFirmware>> getFirmwareImages() async { try { final response = await http.get( diff --git a/lib/src/fota/repository/firmware_image_repository.dart b/lib/src/fota/repository/firmware_image_repository.dart index 4183e87b..5bf6de56 100644 --- a/lib/src/fota/repository/firmware_image_repository.dart +++ b/lib/src/fota/repository/firmware_image_repository.dart @@ -3,7 +3,11 @@ import 'package:http/http.dart' as http; import '../model/firmware_update_request.dart'; +/// Repository for stable firmware releases published from the main GitHub +/// release feed. class FirmwareImageRepository { + /// Returns all non-draft, non-prerelease firmware assets from the upstream + /// OpenEarable release feed. Future<List<RemoteFirmware>> getFirmwareImages() async { final response = await http.get( Uri.parse( @@ -50,6 +54,8 @@ class FirmwareImageRepository { return firmwares; } + /// Checks whether the newest published stable version is newer than + /// [currentVersion]. Future<bool> newerFirmwareVersionAvailable( String? currentVersion, ) async { @@ -65,6 +71,7 @@ class FirmwareImageRepository { } } + /// Returns the newest stable firmware version tag without a leading `v`. Future<String> getLatestFirmwareVersion() async { final response = await http.get( Uri.parse( @@ -80,6 +87,8 @@ class FirmwareImageRepository { return (latestRelease['tag_name'] as String).replaceFirst('v', ''); } + /// Compares semantic version strings and returns `true` when [latest] is + /// newer than [current]. bool isNewerVersion(String latest, String current) { List<int> parse(String v) => v.split('.').map(int.parse).toList(); final latestParts = parse(latest); diff --git a/lib/src/fota/repository/unified_firmware_image_repository.dart b/lib/src/fota/repository/unified_firmware_image_repository.dart index b40c0ba7..2fe452af 100644 --- a/lib/src/fota/repository/unified_firmware_image_repository.dart +++ b/lib/src/fota/repository/unified_firmware_image_repository.dart @@ -1,6 +1,8 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_earable_flutter/src/fota/repository/beta_image_repository.dart'; +/// Aggregates stable and beta firmware repositories behind a small caching +/// layer. class UnifiedFirmwareRepository { final FirmwareImageRepository _stableRepository = FirmwareImageRepository(); final BetaFirmwareImageRepository _betaRepository = @@ -17,6 +19,7 @@ class UnifiedFirmwareRepository { return DateTime.now().difference(_lastFetchTime!) > _cacheDuration; } + /// Returns stable firmware entries, optionally bypassing the cache. Future<List<FirmwareEntry>> getStableFirmwares({ bool forceRefresh = false, }) async { @@ -38,6 +41,7 @@ class UnifiedFirmwareRepository { return _cachedStable!; } + /// Returns beta firmware entries, optionally bypassing the cache. Future<List<FirmwareEntry>> getBetaFirmwares({ bool forceRefresh = false, }) async { @@ -59,6 +63,7 @@ class UnifiedFirmwareRepository { return _cachedBeta!; } + /// Returns stable firmwares and, when requested, beta firmwares in one list. Future<List<FirmwareEntry>> getAllFirmwares({ bool includeBeta = false, }) async { @@ -69,6 +74,7 @@ class UnifiedFirmwareRepository { return [...stable, ...beta]; } + /// Clears all cached repository results. void clearCache() { _cachedStable = null; _cachedBeta = null; @@ -76,10 +82,15 @@ class UnifiedFirmwareRepository { } } +/// Origin of a [FirmwareEntry]. enum FirmwareSource { stable, beta } +/// Firmware entry annotated with its source repository. class FirmwareEntry { + /// The underlying firmware metadata used to build update requests. final RemoteFirmware firmware; + + /// The repository that produced [firmware]. final FirmwareSource source; FirmwareEntry({ @@ -87,6 +98,9 @@ class FirmwareEntry { required this.source, }); + /// Whether this entry came from the beta repository. bool get isBeta => source == FirmwareSource.beta; + + /// Whether this entry came from the stable repository. bool get isStable => source == FirmwareSource.stable; } From b754bf60104fd90948e61967b76dfa2a30635d1b Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:01:38 +0200 Subject: [PATCH 3/9] feat(fota): add wearable fota capabilities --- lib/open_earable_flutter.dart | 2 + lib/src/fota/firmware_slot_manager_impl.dart | 84 +++++++++++++++++++ .../firmware_update_request_provider.dart | 23 ++++- .../models/capabilities/fota_capability.dart | 16 ++++ .../fota_slot_info_capability.dart | 79 +++++++++++++++++ .../models/devices/open_earable_factory.dart | 17 ++++ 6 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 lib/src/fota/firmware_slot_manager_impl.dart create mode 100644 lib/src/models/capabilities/fota_capability.dart create mode 100644 lib/src/models/capabilities/fota_slot_info_capability.dart diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index 77791f55..b0baaaf1 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -35,6 +35,8 @@ export 'src/managers/wearable_disconnect_notifier.dart'; export 'src/models/capabilities/device_firmware_version.dart' hide DeviceFirmwareVersionNumberExt; +export 'src/models/capabilities/fota_capability.dart'; +export 'src/models/capabilities/fota_slot_info_capability.dart'; export 'src/models/capabilities/device_hardware_version.dart'; export 'src/models/capabilities/device_identifier.dart'; export 'src/models/capabilities/battery_level.dart'; diff --git a/lib/src/fota/firmware_slot_manager_impl.dart b/lib/src/fota/firmware_slot_manager_impl.dart new file mode 100644 index 00000000..c162b253 --- /dev/null +++ b/lib/src/fota/firmware_slot_manager_impl.dart @@ -0,0 +1,84 @@ +import 'package:open_earable_flutter/src/models/capabilities/fota_capability.dart'; +import 'package:open_earable_flutter/src/models/capabilities/fota_slot_info_capability.dart'; + +import 'package:mcumgr_flutter/mcumgr_flutter.dart'; + +import 'model/firmware_update_request.dart'; + +/// Standard BLE service UUID for the MCUmgr SMP transport. +const String mcuMgrSmpServiceUuid = '8d53dc1d-1db7-4cd3-868b-8a527460aa84'; + +/// mcumgr-backed implementation of [FotaManager]. +class McuMgrFotaCapability implements FotaManager { + final String _deviceId; + final String _deviceName; + + McuMgrFotaCapability({ + required String deviceId, + required String deviceName, + }) : _deviceId = deviceId, + _deviceName = deviceName; + + @override + FirmwareUpdateRequest createFirmwareUpdateRequest(SelectedFirmware firmware) { + final peripheral = SelectedPeripheral( + name: _deviceName, + identifier: _deviceId, + ); + + if (firmware is RemoteFirmware) { + return MultiImageFirmwareUpdateRequest( + peripheral: peripheral, + firmware: firmware, + ); + } + + if (firmware is LocalFirmware) { + if (firmware.type == FirmwareType.singleImage) { + return SingleImageFirmwareUpdateRequest( + peripheral: peripheral, + firmware: firmware, + ); + } + + return MultiImageFirmwareUpdateRequest( + peripheral: peripheral, + firmware: firmware, + ); + } + + return FirmwareUpdateRequest( + peripheral: peripheral, + firmware: firmware, + ); + } +} + +/// mcumgr-backed implementation of [FotaSlotInfoCapability]. +class McuMgrFotaSlotInfoManager implements FotaSlotInfoCapability { + final String _deviceId; + final UpdateManagerFactory _updateManagerFactory; + + McuMgrFotaSlotInfoManager({ + required String deviceId, + UpdateManagerFactory? updateManagerFactory, + }) : _deviceId = deviceId, + _updateManagerFactory = + updateManagerFactory ?? FirmwareUpdateManagerFactory(); + + @override + Future<List<FirmwareSlotInfo>> readFirmwareSlots() async { + final updateManager = await _updateManagerFactory.getUpdateManager(_deviceId); + try { + final slots = await updateManager.readImageList(); + if (slots == null) { + return const []; + } + return slots + .map(FirmwareSlotInfo.fromImageSlot) + .toList(growable: false); + } finally { + await updateManager.kill(); + } + } +} diff --git a/lib/src/fota/providers/firmware_update_request_provider.dart b/lib/src/fota/providers/firmware_update_request_provider.dart index 09424630..10748c94 100644 --- a/lib/src/fota/providers/firmware_update_request_provider.dart +++ b/lib/src/fota/providers/firmware_update_request_provider.dart @@ -21,6 +21,15 @@ class FirmwareUpdateRequestProvider extends ChangeNotifier { if (firmware == null) { _updateParameters = FirmwareUpdateRequest(peripheral: _updateParameters.peripheral); + notifyListeners(); + return; + } + + final wearable = selectedWearable; + final fota = wearable?.getCapability<FotaManager>(); + if (fota != null) { + _updateParameters = fota.createFirmwareUpdateRequest(firmware); + notifyListeners(); return; } @@ -49,10 +58,16 @@ class FirmwareUpdateRequestProvider extends ChangeNotifier { /// Selects the wearable that should receive the update. void setSelectedPeripheral(Wearable wearable) { selectedWearable = wearable; - _updateParameters.peripheral = SelectedPeripheral( - name: wearable.name, - identifier: wearable.deviceId, - ); + final selectedFirmware = _updateParameters.firmware; + final fota = wearable.getCapability<FotaManager>(); + if (selectedFirmware != null && fota != null) { + _updateParameters = fota.createFirmwareUpdateRequest(selectedFirmware); + } else { + _updateParameters.peripheral = SelectedPeripheral( + name: wearable.name, + identifier: wearable.deviceId, + ); + } notifyListeners(); } diff --git a/lib/src/models/capabilities/fota_capability.dart b/lib/src/models/capabilities/fota_capability.dart new file mode 100644 index 00000000..5114c4f7 --- /dev/null +++ b/lib/src/models/capabilities/fota_capability.dart @@ -0,0 +1,16 @@ +import '../../fota/model/firmware_update_request.dart'; + +/// Generic capability for wearable firmware-over-the-air (FOTA) operations. +/// +/// This capability is intentionally independent of a specific transport or +/// update backend. A wearable may implement it using mcumgr today and a +/// different firmware update mechanism in the future while keeping the same +/// high-level integration surface for apps. +abstract class FotaManager { + /// Creates a firmware update request for this wearable and the selected + /// [firmware]. + /// + /// Implementations may return backend-specific subclasses of + /// [FirmwareUpdateRequest]. + FirmwareUpdateRequest createFirmwareUpdateRequest(SelectedFirmware firmware); +} diff --git a/lib/src/models/capabilities/fota_slot_info_capability.dart b/lib/src/models/capabilities/fota_slot_info_capability.dart new file mode 100644 index 00000000..0203c2ef --- /dev/null +++ b/lib/src/models/capabilities/fota_slot_info_capability.dart @@ -0,0 +1,79 @@ +import 'package:meta/meta.dart'; +import 'package:mcumgr_flutter/mcumgr_flutter.dart'; + +/// Optional capability for wearables that expose firmware slot or image-table +/// state as part of their update mechanism. +/// +/// This is separate from [FotaCapability] because not every FOTA backend uses +/// MCUboot-style slots. +abstract class FotaSlotInfoCapability { + /// Reads the firmware images or slots currently reported by the wearable. + Future<List<FirmwareSlotInfo>> readFirmwareSlots(); +} + +/// Snapshot of one firmware image slot reported by the wearable. +@immutable +class FirmwareSlotInfo { + /// Image number in the device's firmware image table. + final int image; + + /// Slot index for the image. + final int slot; + + /// Human-readable firmware version, when provided by the device. + final String? version; + + /// Raw image hash bytes. + final List<int> hash; + + /// Whether the slot contains a bootable image. + final bool bootable; + + /// Whether the image is marked as pending for the next boot. + final bool pending; + + /// Whether the image is confirmed. + final bool confirmed; + + /// Whether the image is currently active. + final bool active; + + /// Whether the image is marked as permanent. + final bool permanent; + + /// Hex-encoded representation of [hash]. + final String hashString; + + const FirmwareSlotInfo({ + required this.image, + required this.slot, + required this.version, + required this.hash, + required this.bootable, + required this.pending, + required this.confirmed, + required this.active, + required this.permanent, + required this.hashString, + }); + + /// Creates a library-level slot model from the underlying mcumgr type. + factory FirmwareSlotInfo.fromImageSlot(ImageSlot slot) { + return FirmwareSlotInfo( + image: slot.image, + slot: slot.slot, + version: slot.version, + hash: List<int>.unmodifiable(slot.hash), + bootable: slot.bootable, + pending: slot.pending, + confirmed: slot.confirmed, + active: slot.active, + permanent: slot.permanent, + hashString: slot.hashString, + ); + } +} + +/// Deprecated alias kept for compatibility with the earlier slot-only API. +@Deprecated('Use FotaSlotInfoCapability instead.') +abstract class FirmwareSlotManager implements FotaSlotInfoCapability {} diff --git a/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index 6a8409b0..11dadebd 100644 --- a/lib/src/models/devices/open_earable_factory.dart +++ b/lib/src/models/devices/open_earable_factory.dart @@ -10,6 +10,8 @@ import '../../../open_earable_flutter.dart' show logger; import '../../managers/v2_sensor_handler.dart'; import '../../utils/sensor_value_parser/v2_sensor_value_parser.dart'; import '../capabilities/audio_mode_manager.dart'; +import '../capabilities/fota_capability.dart'; +import '../capabilities/fota_slot_info_capability.dart'; import '../capabilities/sensor.dart'; import '../capabilities/sensor_configuration.dart'; import '../capabilities/sensor_configuration_specializations/recordable_sensor_configuration.dart'; @@ -21,6 +23,7 @@ import 'discovered_device.dart'; import 'open_earable_v1.dart'; import 'open_earable_v2.dart'; import 'wearable.dart'; +import '../../fota/firmware_slot_manager_impl.dart'; const String _deviceInfoServiceUuid = "45622510-6468-465a-b141-0b9b0f96b468"; const String _deviceFirmwareVersionCharacteristicUuid = @@ -96,6 +99,20 @@ class OpenEarableFactory extends WearableFactory { ), ); } + if (await bleManager!.hasService( + deviceId: device.id, + serviceId: mcuMgrSmpServiceUuid, + )) { + wearable.registerCapability<FotaManager>( + McuMgrFotaCapability( + deviceId: device.id, + deviceName: device.name, + ), + ); + wearable.registerCapability<FotaSlotInfoCapability>( + McuMgrFotaSlotInfoManager(deviceId: device.id), + ); + } return wearable; } else { throw Exception('OpenEarable version is not supported'); From 0edf44476141749307bf1a55398e575f1c234ab8 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:02:08 +0200 Subject: [PATCH 4/9] docs(fota): document capability-based firmware updates --- doc/CAPABILITIES.md | 25 +++++++++++++ doc/FOTA.md | 91 ++++++++++++++++++++++++++++++--------------- 2 files changed, 85 insertions(+), 31 deletions(-) diff --git a/doc/CAPABILITIES.md b/doc/CAPABILITIES.md index 11ffc9e0..e2b68300 100644 --- a/doc/CAPABILITIES.md +++ b/doc/CAPABILITIES.md @@ -178,6 +178,31 @@ if (deviceIdentifierService != null) { } ``` +#### FotaCapability + +Provides the device-level abstraction for firmware update operations. + +```dart +final fota = wearable.getCapability<FotaCapability>(); +if (fota != null) { + final request = fota.createFirmwareUpdateRequest(selectedFirmware); +} +``` + +Use `createFirmwareUpdateRequest(...)` to build a wearable-specific update +request without depending on the underlying FOTA backend. + +#### FotaSlotInfoCapability + +Provides firmware slot or image-table state for FOTA backends that expose it. + +```dart +final slotInfo = wearable.getCapability<FotaSlotInfoCapability>(); +if (slotInfo != null) { + final slots = await slotInfo.readFirmwareSlots(); +} +``` + --- ## Summary diff --git a/doc/FOTA.md b/doc/FOTA.md index 4fe8739d..1dda4193 100644 --- a/doc/FOTA.md +++ b/doc/FOTA.md @@ -6,10 +6,10 @@ This guide is written for app developers using `open_earable_flutter`. It explai The FOTA API gives you the building blocks for a firmware update flow: +- `FotaCapability` as the device-level abstraction layer for firmware updates - `FirmwareImageRepository` to load stable firmware releases from GitHub - `UnifiedFirmwareRepository` to load stable releases and optional beta builds - `RemoteFirmware` and `LocalFirmware` to represent selectable firmware files -- `SelectedPeripheral` to identify the target device - `SingleImageFirmwareUpdateRequest` and `MultiImageFirmwareUpdateRequest` to describe the update job - `UpdateBloc` to execute the update and emit UI-friendly progress states @@ -21,20 +21,23 @@ Make sure your app can already: 2. Hold on to the connected `Wearable` 3. Ask the user which firmware they want to install -If you have not connected to a device yet, start there first. A firmware update needs a connected `Wearable` so you can pass its device identifier into `SelectedPeripheral`. +If you have not connected to a device yet, start there first. A firmware update needs a connected `Wearable` so you can obtain its `FotaCapability`. -## Step 1: Get The Target Device +## Step 1: Get The FOTA Capability -Once your app has a connected wearable, convert it into a `SelectedPeripheral`: +Once your app has a connected wearable, obtain its `FotaCapability`: ```dart -final SelectedPeripheral selectedPeripheral = SelectedPeripheral( - name: wearable.name, - identifier: wearable.deviceId, -); +final fota = wearable.getCapability<FotaCapability>(); +if (fota == null) { + // This wearable does not support firmware updates. + return; +} ``` -The `identifier` is what the underlying update manager uses to talk to the device. +This is the abstraction layer your app should use for device-specific firmware +operations. The wearable may implement it using mcumgr today and a different +backend in the future. ## Step 2: Offer Firmware Choices @@ -116,34 +119,20 @@ Use: ## Step 3: Build The Update Request -The request type depends on the selected firmware format. - -### Remote Or Local Multi-Image Firmware - -Use `MultiImageFirmwareUpdateRequest` for `.zip`-based updates: +Ask the wearable's `FotaCapability` to create the request for the selected +firmware: ```dart -final request = MultiImageFirmwareUpdateRequest( - peripheral: selectedPeripheral, - firmware: selectedFirmware, -); +final request = fota.createFirmwareUpdateRequest(selectedFirmware); ``` -`selectedFirmware` can be either: - -- a `RemoteFirmware` with `type == FirmwareType.multiImage` -- a `LocalFirmware` with `type == FirmwareType.multiImage` - -### Local Single-Image Firmware +For the current mcumgr-backed implementation, this returns: -Use `SingleImageFirmwareUpdateRequest` for local `.bin` updates: +- `MultiImageFirmwareUpdateRequest` for remote firmware and local `.zip` files +- `SingleImageFirmwareUpdateRequest` for local `.bin` files -```dart -final request = SingleImageFirmwareUpdateRequest( - peripheral: selectedPeripheral, - firmware: localFirmware, -); -``` +Apps should depend on `FotaCapability` for request creation instead of building +device-specific request objects themselves. ## Step 4: Start The Update With `UpdateBloc` @@ -214,6 +203,42 @@ BlocBuilder<UpdateBloc, UpdateState>( ) ``` +## Read The Current Firmware Slot State + +Some wearables also expose `FotaSlotInfoCapability` for implementations that +have a slot or image-table concept. This is separate from `FotaCapability`, +because not every firmware update backend uses the same slot model. + +```dart +final slotInfo = wearable.getCapability<FotaSlotInfoCapability>(); +if (slotInfo != null) { + final slots = await slotInfo.readFirmwareSlots(); + + for (final slot in slots) { + print( + 'image=${slot.image} slot=${slot.slot} ' + 'version=${slot.version} active=${slot.active} ' + 'confirmed=${slot.confirmed} pending=${slot.pending}', + ); + } +} +``` + +Each `FirmwareSlotInfo` contains: + +- `image` +- `slot` +- `version` +- `hash` and `hashString` +- `bootable` +- `pending` +- `confirmed` +- `active` +- `permanent` + +This is useful when you want to show the current primary and secondary images +before or after an update. + ## What Happens Internally You do not need to call the lower-level handler classes directly, but it helps to know what `UpdateBloc` is doing: @@ -321,6 +346,8 @@ Recommended UX: - If a manifest contains multiple images, each file entry must define an image index - `UnifiedFirmwareRepository` caches results for 15 minutes unless you request a refresh - The current upload path uses `mcumgr_flutter` under the hood +- `FotaSlotInfoCapability` is optional and only available on wearables whose firmware backend exposes slot-style state +- `mcumgr_flutter 0.6.1` does not expose an API to erase an individual image slot, so this library does not currently offer slot erase either ## Related Source Files @@ -331,3 +358,5 @@ If you want to inspect the implementation behind the public APIs, these are the - `lib/src/fota/repository/unified_firmware_image_repository.dart` - `lib/src/fota/bloc/update_bloc.dart` - `lib/src/fota/providers/firmware_update_request_provider.dart` +- `lib/src/models/capabilities/fota_capability.dart` +- `lib/src/models/capabilities/fota_slot_info_capability.dart` From b32f8b837075515f3c37c976ff66225c1417bada Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:20:53 +0200 Subject: [PATCH 5/9] feat(fota): added function to abort fota --- lib/src/fota/bloc/update_bloc.dart | 52 ++++++++++++++++++++++++++--- lib/src/fota/bloc/update_event.dart | 3 ++ lib/src/fota/bloc/update_state.dart | 5 +++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/lib/src/fota/bloc/update_bloc.dart b/lib/src/fota/bloc/update_bloc.dart index e9797543..3bf94c59 100644 --- a/lib/src/fota/bloc/update_bloc.dart +++ b/lib/src/fota/bloc/update_bloc.dart @@ -1,4 +1,5 @@ import 'package:bloc/bloc.dart'; +import 'dart:async'; import 'package:equatable/equatable.dart'; import 'package:mcumgr_flutter/mcumgr_flutter.dart'; import '../handlers/firmware_update_handler.dart'; @@ -17,6 +18,9 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> { final FirmwareUpdateRequest firmwareUpdateRequest; UpdateFirmwareStateHistory? _state; FirmwareUpdateManager? _firmwareUpdateManager; + StreamSubscription? _logSubscription; + StreamSubscription? _updateSubscription; + bool _abortRequested = false; /// Creates a bloc for a single update request. UpdateBloc({required this.firmwareUpdateRequest}) : super(UpdateInitial()) { @@ -25,6 +29,8 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> { final handler = createFirmwareUpdateHandler(); _state = null; + _abortRequested = false; + await _cancelSubscriptions(); _firmwareUpdateManager = await handler.handleFirmwareUpdate( firmwareUpdateRequest, @@ -53,7 +59,9 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> { final logManager = _firmwareUpdateManager!.logger; - logManager.logMessageStream.where((log) => log.level.rawValue > 1).listen( + _logSubscription = logManager.logMessageStream + .where((log) => log.level.rawValue > 1) + .listen( (log) { print(log.message); }, @@ -65,7 +73,7 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> { }, ); - rx.CombineLatestStream.combine2( + _updateSubscription = rx.CombineLatestStream.combine2( imageProgressStream, _firmwareUpdateManager!.updateStateStream!, (Tuple2<int, double> progressData, FirmwareUpgradeState updateState) { @@ -87,6 +95,9 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> { ).listen( add, onError: (error) { + if (_abortRequested) { + return; + } print(error); add(UploadFailed(error.toString())); }, @@ -123,14 +134,30 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> { emit(_state!); }); on<UploadFailed>((event, emit) { + if (_abortRequested) { + return; + } _state = _updatedState( UpdateCompleteFailure(event.error), updateManager: _firmwareUpdateManager, ); emit(_state!); }); - on<ResetUpdate>((event, emit) { - _firmwareUpdateManager?.kill(); + on<AbortUpdate>((event, emit) async { + _abortRequested = true; + await _cancelSubscriptions(); + await _firmwareUpdateManager?.cancel(); + _state = _updatedState( + UpdateCompleteAborted(), + updateManager: _firmwareUpdateManager, + ); + emit(_state!); + }); + on<ResetUpdate>((event, emit) async { + await _cancelSubscriptions(); + await _firmwareUpdateManager?.kill(); + _firmwareUpdateManager = null; + _abortRequested = false; }); } @@ -165,7 +192,8 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> { ); } } else if (currentState is UpdateCompleteSuccess || - currentState is UpdateCompleteFailure) { + currentState is UpdateCompleteFailure || + currentState is UpdateCompleteAborted) { return UpdateFirmwareStateHistory( null, _state!.history + [currentState], @@ -192,6 +220,20 @@ class UpdateBloc extends Bloc<UpdateEvent, UpdateState> { return handler; } + + Future<void> _cancelSubscriptions() async { + await _logSubscription?.cancel(); + await _updateSubscription?.cancel(); + _logSubscription = null; + _updateSubscription = null; + } + + @override + Future<void> close() async { + await _cancelSubscriptions(); + await _firmwareUpdateManager?.kill(); + return super.close(); + } } /// Converts handler-level states into bloc events. diff --git a/lib/src/fota/bloc/update_event.dart b/lib/src/fota/bloc/update_event.dart index 73df0a64..718f4eed 100644 --- a/lib/src/fota/bloc/update_event.dart +++ b/lib/src/fota/bloc/update_event.dart @@ -58,3 +58,6 @@ class PeripheralSelected extends UpdateEvent { /// Stops the active update manager and resets any in-flight upload. class ResetUpdate extends UpdateEvent {} + +/// Aborts the active firmware update. +class AbortUpdate extends UpdateEvent {} diff --git a/lib/src/fota/bloc/update_state.dart b/lib/src/fota/bloc/update_state.dart index 138b1c0c..239d3dc4 100644 --- a/lib/src/fota/bloc/update_state.dart +++ b/lib/src/fota/bloc/update_state.dart @@ -46,6 +46,11 @@ final class UpdateCompleteFailure extends UpdateFirmware { List<Object?> get props => [stage, error]; } +/// Aborted marker for the update flow. +final class UpdateCompleteAborted extends UpdateFirmware { + UpdateCompleteAborted() : super("Update aborted"); +} + /// Snapshot of the current stage plus the completed stage history. class UpdateFirmwareStateHistory extends UpdateState { final UpdateFirmware? currentState; From 27e76ba209ba05d30c01b100ef1162e8e8b3d7b6 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:21:12 +0200 Subject: [PATCH 6/9] feat(doc): document fota abort --- doc/FOTA.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/doc/FOTA.md b/doc/FOTA.md index 1dda4193..f033b50e 100644 --- a/doc/FOTA.md +++ b/doc/FOTA.md @@ -165,6 +165,7 @@ The most important states are: - `UpdateFirmwareStateHistory`: the update is running or has completed - `UpdateCompleteSuccess`: appears inside the history as the successful end state - `UpdateCompleteFailure`: appears inside the history as the failed end state +- `UpdateCompleteAborted`: appears inside the history when the user aborts the update The simplest integration pattern is: @@ -183,7 +184,17 @@ BlocBuilder<UpdateBloc, UpdateState>( case UpdateFirmwareStateHistory(): if (state.currentState is UpdateProgressFirmware) { final progressState = state.currentState as UpdateProgressFirmware; - return Text('Uploading ${progressState.progress}%'); + return Column( + children: [ + Text('Uploading ${progressState.progress}%'), + ElevatedButton( + onPressed: () { + context.read<UpdateBloc>().add(AbortUpdate()); + }, + child: const Text('Abort update'), + ), + ], + ); } if (state.isComplete) { @@ -191,6 +202,9 @@ BlocBuilder<UpdateBloc, UpdateState>( if (lastState is UpdateCompleteFailure) { return Text('Update failed: ${lastState.error}'); } + if (lastState is UpdateCompleteAborted) { + return const Text('Update aborted'); + } return const Text('Update completed'); } @@ -203,6 +217,12 @@ BlocBuilder<UpdateBloc, UpdateState>( ) ``` +To abort a running update, dispatch: + +```dart +context.read<UpdateBloc>().add(AbortUpdate()); +``` + ## Read The Current Firmware Slot State Some wearables also expose `FotaSlotInfoCapability` for implementations that From fa491ecb002c9a5fd3cdef8a9643d0c746d65388 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:21:25 +0200 Subject: [PATCH 7/9] feat(example): added fota abort --- example/lib/widgets/fota/stepper_view/update_view.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/example/lib/widgets/fota/stepper_view/update_view.dart b/example/lib/widgets/fota/stepper_view/update_view.dart index 0871c9cf..e60ae203 100644 --- a/example/lib/widgets/fota/stepper_view/update_view.dart +++ b/example/lib/widgets/fota/stepper_view/update_view.dart @@ -50,6 +50,13 @@ class UpdateStepView extends StatelessWidget { _currentState(state), ], ), + if (!state.isComplete) + ElevatedButton( + onPressed: () { + context.read<UpdateBloc>().add(AbortUpdate()); + }, + child: const Text('Abort Update'), + ), if (state.isComplete && state.updateManager?.logger != null) ElevatedButton( onPressed: () { @@ -79,7 +86,7 @@ class UpdateStepView extends StatelessWidget { } Icon _stateIcon(UpdateFirmware state, Color successColor) { - if (state is UpdateCompleteFailure) { + if (state is UpdateCompleteFailure || state is UpdateCompleteAborted) { return const Icon(size: 24, Icons.error_outline, color: Colors.red); } else { return Icon(size: 24, Icons.check_circle_outline, color: successColor); From adeb4fbb577fddeea3c9d1b8d1cfc6f1fc77213d Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:50:22 +0200 Subject: [PATCH 8/9] feat(release): bump version to 2.3.5 and update changelog for FOTA capabilities --- CHANGELOG.md | 6 ++++++ pubspec.yaml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e2ab795..79bef6d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.3.5 + +* added FOTA capabilities for wearables to support capability-based firmware updates +* added support for aborting an in-progress FOTA update +* expanded FOTA documentation and API doc comments, including abort flow examples + ## 2.3.4 * fixed a bug where the stream of sensor values would not be properly closed when the device is disconnected, which could lead to memory leaks and other issues diff --git a/pubspec.yaml b/pubspec.yaml index 89caa604..64a1e8ff 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.3.4 +version: 2.3.5 repository: https://github.com/OpenEarable/open_earable_flutter/tree/main platforms: From 2951bd8d50adcceb555bb6a7656eb2861a0e4cd4 Mon Sep 17 00:00:00 2001 From: Dennis Moschina <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:02:51 +0200 Subject: [PATCH 9/9] docs(readme): add contributing guidelines to enhance contribution quality --- CONTRIBUTING.md | 127 +++++++++++++++++++++++++++++++++++++++++++ README.md | 3 + example/pubspec.lock | 18 +++--- 3 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..d6f9a345 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,127 @@ +# Contributing to OpenEarable Flutter + +Thank you for contributing to `open_earable_flutter`. + +This package is published publicly and supports multiple wearable integrations, so contribution quality matters. Keep changes small, reviewable, and well documented. + +## Core Expectations + +- Use [Conventional Commits](https://www.conventionalcommits.org/) for every commit. +- Rebase your branch on top of the target branch. Do not merge the target branch into your feature branch. +- Run `dart format .` and ensure `flutter analyze` passes before pushing. +- Document classes, public APIs, and non-obvious functions. +- Update package documentation when public behavior, setup, or capabilities change. +- Keep pull requests focused. Do not mix refactors, formatting-only edits, and feature work unless they are directly related. +- Never commit secrets, local environment files, build artifacts, or unrelated generated output. + +## Development Setup + +The repository uses the Flutter version pinned in [`.flutter_version`](https://github.com/OpenEarable/open_earable_flutter/blob/main/.flutter_version). + +1. Install the required Flutter SDK version. +2. Fetch dependencies: + +```bash +flutter pub get +``` + +3. If you work on the example app as well, fetch its dependencies too: + +```bash +cd example +flutter pub get +``` + +## Branching Workflow + +1. Create a feature branch from the current target branch. +2. Make focused commits with conventional commit messages. +3. Rebase onto the latest target branch before opening or updating your pull request. +4. Resolve conflicts locally and rerun the verification steps. + +Example commit messages: + +```text +feat(sensor): add open ring calibration support +fix(fota): avoid duplicate update state emission +docs(readme): clarify bluetooth permission setup +refactor(device): simplify wearable factory registration +test(pairing): cover stereo reconnection flow +``` + +## Code Quality Standards + +### Architecture + +- Prefer small, composable abstractions over large multi-purpose classes. +- Keep responsibilities clearly separated across managers, models, capabilities, and utilities. +- Avoid introducing tight coupling between device-specific implementations and shared infrastructure. +- Preserve backward compatibility for public APIs unless the change is intentional and clearly documented. + +### Documentation + +- Add Dart documentation comments for every public class, enum, extension, mixin, typedef, constructor, method, and top-level function you introduce or materially change. +- Add documentation for internal functions and classes when the behavior is not immediately obvious. +- Explain why something exists or how it should be used, not just what the code literally does. +- Update the relevant files in [`doc/`](https://github.com/OpenEarable/open_earable_flutter/tree/main/doc) and [README.md](https://github.com/OpenEarable/open_earable_flutter/blob/main/README.md) when contributors change user-facing functionality. + +### Style + +- Follow the repository lint rules in [analysis_options.yaml](https://github.com/OpenEarable/open_earable_flutter/blob/main/analysis_options.yaml). +- Use trailing commas where appropriate and keep return types explicit. +- Prefer clear naming over clever shorthand. +- Avoid unrelated drive-by changes in files touched for another purpose. + +## Verification Before Pushing + +Run these commands from the repository root: + +```bash +dart format . +flutter analyze +flutter test +``` + +Also validate the example app when your change can affect it: + +```bash +cd example +flutter test +flutter build web --dart-define=BUILD_COMMIT=$(git rev-parse --short HEAD) --dart-define=BUILD_BRANCH=$(git rev-parse --abbrev-ref HEAD) +``` + +Notes: + +- CI currently enforces `flutter analyze` and builds the example web app for pull requests. +- If `flutter test` does not cover your change sufficiently, add targeted tests instead of relying on manual verification only. +- If a command is not applicable to your change, mention what you ran and why in the pull request description. + +## Pull Requests + +Before opening a pull request, make sure: + +- the branch is rebased on the latest target branch; +- commits use conventional commit messages; +- formatting, analysis, tests, and relevant example validation pass; +- documentation is updated for API or behavior changes; +- the pull request description explains the problem, the solution, and any migration or validation notes. + +If your change affects public APIs, protocol behavior, permissions, supported devices, or firmware update flows, call that out explicitly in the pull request. + +## Commit and History Hygiene + +- Prefer multiple clean commits over one noisy commit history during development, then squash if the maintainers request it. +- Do not rewrite shared branch history after others have based work on it unless you have coordinated the change. +- Do not use merge commits to sync your branch with the target branch. Use `git fetch` followed by `git rebase`. + +## What To Update When You Change Behavior + +Depending on the change, update one or more of the following: + +- [README.md](https://github.com/OpenEarable/open_earable_flutter/blob/main/README.md) +- files in [`doc/`](https://github.com/OpenEarable/open_earable_flutter/tree/main/doc) +- [CHANGELOG.md](https://github.com/OpenEarable/open_earable_flutter/blob/main/CHANGELOG.md) +- example code in [`example/lib/`](https://github.com/OpenEarable/open_earable_flutter/tree/main/example/lib) +- tests in [`example/test/`](https://github.com/OpenEarable/open_earable_flutter/tree/main/example/test) + +High-quality contributions are easier to review, safer to release, and faster to maintain. Optimize for clarity, correctness, and a clean project history. diff --git a/README.md b/README.md index 345e2017..40cdc7be 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ This Dart package provides functionality for interacting with OpenEarable device [![Button](https://raw.githubusercontent.com/OpenEarable/open_earable_flutter/main/.github/assets/show_on_pub_dev_button.svg)](https://pub.dev/packages/open_earable_flutter) +## Contributing + +See [CONTRIBUTING.md](https://github.com/OpenEarable/open_earable_flutter/CONTRIBUTING.md) for the contribution workflow, code quality expectations, rebasing policy, and required verification steps. ## Permissions For your app to be able to use [UniversalBLE](https://pub.dev/packages/universal_ble) in this package, you need to grant the following permissions: diff --git a/example/pubspec.lock b/example/pubspec.lock index 2e0d5e69..a88ca564 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -300,18 +300,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" mcumgr_flutter: dependency: "direct main" description: @@ -350,7 +350,7 @@ packages: path: ".." relative: true source: path - version: "2.3.4" + version: "2.3.5" path: dependency: transitive description: @@ -568,10 +568,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" tuple: dependency: transitive description: