diff --git a/README.md b/README.md index 058e571..345e201 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/CAPABILITIES.md b/doc/CAPABILITIES.md index 11ffc9e..e2b6830 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(); +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(); +if (slotInfo != null) { + final slots = await slotInfo.readFirmwareSlots(); +} +``` + --- ## Summary diff --git a/doc/FOTA.md b/doc/FOTA.md new file mode 100644 index 0000000..f033b50 --- /dev/null +++ b/doc/FOTA.md @@ -0,0 +1,382 @@ +# 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: + +- `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 +- `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 obtain its `FotaCapability`. + +## Step 1: Get The FOTA Capability + +Once your app has a connected wearable, obtain its `FotaCapability`: + +```dart +final fota = wearable.getCapability(); +if (fota == null) { + // This wearable does not support firmware updates. + return; +} +``` + +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 + +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 + +Ask the wearable's `FotaCapability` to create the request for the selected +firmware: + +```dart +final request = fota.createFirmwareUpdateRequest(selectedFirmware); +``` + +For the current mcumgr-backed implementation, this returns: + +- `MultiImageFirmwareUpdateRequest` for remote firmware and local `.zip` files +- `SingleImageFirmwareUpdateRequest` for local `.bin` files + +Apps should depend on `FotaCapability` for request creation instead of building +device-specific request objects themselves. + +## 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 +- `UpdateCompleteAborted`: appears inside the history when the user aborts the update + +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 Column( + children: [ + Text('Uploading ${progressState.progress}%'), + ElevatedButton( + onPressed: () { + context.read().add(AbortUpdate()); + }, + child: const Text('Abort update'), + ), + ], + ); + } + + if (state.isComplete) { + final lastState = state.history.isNotEmpty ? state.history.last : null; + if (lastState is UpdateCompleteFailure) { + return Text('Update failed: ${lastState.error}'); + } + if (lastState is UpdateCompleteAborted) { + return const Text('Update aborted'); + } + return const Text('Update completed'); + } + + return Text(state.currentState?.stage ?? 'Preparing update'); + + default: + return const Text('Unknown update state'); + } + }, +) +``` + +To abort a running update, dispatch: + +```dart +context.read().add(AbortUpdate()); +``` + +## 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(); +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: + +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 +- `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 + +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` +- `lib/src/models/capabilities/fota_capability.dart` +- `lib/src/models/capabilities/fota_slot_info_capability.dart` diff --git a/example/lib/widgets/fota/stepper_view/update_view.dart b/example/lib/widgets/fota/stepper_view/update_view.dart index 0871c9c..e60ae20 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().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); diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index 77791f5..b0baaaf 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/bloc/update_bloc.dart b/lib/src/fota/bloc/update_bloc.dart index 6684a14..3bf94c5 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'; @@ -10,17 +11,26 @@ 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; + StreamSubscription? _logSubscription; + StreamSubscription? _updateSubscription; + bool _abortRequested = false; + /// Creates a bloc for a single update request. UpdateBloc({required this.firmwareUpdateRequest}) : super(UpdateInitial()) { on((event, emit) async { emit(UpdateFirmware('Begin update process')); final handler = createFirmwareUpdateHandler(); _state = null; + _abortRequested = false; + await _cancelSubscriptions(); _firmwareUpdateManager = await handler.handleFirmwareUpdate( firmwareUpdateRequest, @@ -49,7 +59,9 @@ class UpdateBloc extends Bloc { 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); }, @@ -61,7 +73,7 @@ class UpdateBloc extends Bloc { }, ); - rx.CombineLatestStream.combine2( + _updateSubscription = rx.CombineLatestStream.combine2( imageProgressStream, _firmwareUpdateManager!.updateStateStream!, (Tuple2 progressData, FirmwareUpgradeState updateState) { @@ -83,6 +95,9 @@ class UpdateBloc extends Bloc { ).listen( add, onError: (error) { + if (_abortRequested) { + return; + } print(error); add(UploadFailed(error.toString())); }, @@ -119,14 +134,30 @@ class UpdateBloc extends Bloc { emit(_state!); }); on((event, emit) { + if (_abortRequested) { + return; + } _state = _updatedState( UpdateCompleteFailure(event.error), updateManager: _firmwareUpdateManager, ); emit(_state!); }); - on((event, emit) { - _firmwareUpdateManager?.kill(); + on((event, emit) async { + _abortRequested = true; + await _cancelSubscriptions(); + await _firmwareUpdateManager?.cancel(); + _state = _updatedState( + UpdateCompleteAborted(), + updateManager: _firmwareUpdateManager, + ); + emit(_state!); + }); + on((event, emit) async { + await _cancelSubscriptions(); + await _firmwareUpdateManager?.kill(); + _firmwareUpdateManager = null; + _abortRequested = false; }); } @@ -161,7 +192,8 @@ class UpdateBloc extends Bloc { ); } } else if (currentState is UpdateCompleteSuccess || - currentState is UpdateCompleteFailure) { + currentState is UpdateCompleteFailure || + currentState is UpdateCompleteAborted) { return UpdateFirmwareStateHistory( null, _state!.history + [currentState], @@ -188,8 +220,23 @@ class UpdateBloc extends Bloc { return handler; } + + Future _cancelSubscriptions() async { + await _logSubscription?.cancel(); + await _updateSubscription?.cancel(); + _logSubscription = null; + _updateSubscription = null; + } + + @override + Future close() async { + await _cancelSubscriptions(); + await _firmwareUpdateManager?.kill(); + return super.close(); + } } +/// 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 21b0136..718f4ee 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,32 @@ 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 {} + +/// 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 b3b7470..239d3dc 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,12 @@ final class UpdateCompleteFailure extends UpdateFirmware { List 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; final List history; 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 0000000..c162b25 --- /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> 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/fota.dart b/lib/src/fota/fota.dart index 194dac1..7653674 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 45a4572..9e7ec1f 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 e0fd0d2..e761d0f 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 05fb1a3..d5ce90a 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 d421769..2684daf 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 fea4051..c8989b4 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 46acafd..10748c9 100644 --- a/lib/src/fota/providers/firmware_update_request_provider.dart +++ b/lib/src/fota/providers/firmware_update_request_provider.dart @@ -1,16 +1,35 @@ 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 = FirmwareUpdateRequest(peripheral: _updateParameters.peripheral); + notifyListeners(); + return; + } + + final wearable = selectedWearable; + final fota = wearable?.getCapability(); + if (fota != null) { + _updateParameters = fota.createFirmwareUpdateRequest(firmware); + notifyListeners(); return; } @@ -36,21 +55,30 @@ class FirmwareUpdateRequestProvider extends ChangeNotifier { notifyListeners(); } + /// 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(); + if (selectedFirmware != null && fota != null) { + _updateParameters = fota.createFirmwareUpdateRequest(selectedFirmware); + } else { + _updateParameters.peripheral = SelectedPeripheral( + name: wearable.name, + identifier: wearable.deviceId, + ); + } 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 +87,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 fe785b9..1096aa1 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 4183e87..5bf6de5 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 b40c0ba..2fe452a 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; } diff --git a/lib/src/models/capabilities/fota_capability.dart b/lib/src/models/capabilities/fota_capability.dart new file mode 100644 index 0000000..5114c4f --- /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 0000000..0203c2e --- /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 6a8409b..11dadeb 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');